"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, type TechVendor } from "@/db/schema/techVendors"; import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; import { users } from "@/db/schema/users"; import ExcelJS from "exceljs"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import { insertTechVendor, updateTechVendor, groupByTechVendorStatus, selectTechVendorContacts, countTechVendorContacts, insertTechVendorContact, selectTechVendorsWithAttachments, countTechVendorsWithAttachments, } from "./repository"; import type { CreateTechVendorSchema, UpdateTechVendorSchema, GetTechVendorsSchema, GetTechVendorContactsSchema, CreateTechVendorContactSchema, GetTechVendorItemsSchema, GetTechVendorRfqHistorySchema, GetTechVendorPossibleItemsSchema, CreateTechVendorPossibleItemSchema, UpdateTechVendorContactSchema, } from "./validations"; import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm"; import path from "path"; import { sql } from "drizzle-orm"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { deleteFile, saveDRMFile } from "../file-stroage"; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ /** * 복잡한 조건으로 기술영업 Vendor 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getTechVendors(input: GetTechVendorsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 1) 고급 필터 (workTypes와 techVendorType 제외 - 별도 처리) const filteredFilters = input.filters.filter( filter => filter.id !== "workTypes" && filter.id !== "techVendorType" ); const advancedWhere = filterColumns({ table: techVendors, filters: filteredFilters, joinOperator: input.joinOperator, }); // 2) 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(techVendors.vendorName, s), ilike(techVendors.vendorCode, s), ilike(techVendors.email, s), ilike(techVendors.status, s) ); } // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); // 벤더 타입 필터링 로직 추가 let vendorTypeWhere; if (input.vendorType) { // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑 const vendorTypeMap = { "ship": "조선", "top": "해양TOP", "hull": "해양HULL" }; const actualVendorType = input.vendorType in vendorTypeMap ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap] : undefined; if (actualVendorType) { // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용 vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`); } } // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName ? ilike(techVendors.vendorName, `%${input.vendorName}%`) : undefined, input.status ? ilike(techVendors.status, input.status) : undefined, input.country ? ilike(techVendors.country, `%${input.country}%`) : undefined ); // TechVendorType 필터링 로직 추가 (고급 필터에서) let techVendorTypeWhere; const techVendorTypeFilters = input.filters.filter(filter => filter.id === "techVendorType"); if (techVendorTypeFilters.length > 0) { const typeFilter = techVendorTypeFilters[0]; if (Array.isArray(typeFilter.value) && typeFilter.value.length > 0) { // 각 타입에 대해 LIKE 조건으로 OR 연결 const typeConditions = typeFilter.value.map(type => ilike(techVendors.techVendorType, `%${type}%`) ); techVendorTypeWhere = or(...typeConditions); } } // WorkTypes 필터링 로직 추가 let workTypesWhere; const workTypesFilters = input.filters.filter(filter => filter.id === "workTypes"); if (workTypesFilters.length > 0) { const workTypeFilter = workTypesFilters[0]; if (Array.isArray(workTypeFilter.value) && workTypeFilter.value.length > 0) { // workTypes에 해당하는 벤더 ID들을 서브쿼리로 찾음 const vendorIdsWithWorkTypes = db .selectDistinct({ vendorId: techVendorPossibleItems.vendorId }) .from(techVendorPossibleItems) .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) .where( or( inArray(itemShipbuilding.workType, workTypeFilter.value), inArray(itemOffshoreTop.workType, workTypeFilter.value), inArray(itemOffshoreHull.workType, workTypeFilter.value) ) ); workTypesWhere = inArray(techVendors.id, vendorIdsWithWorkTypes); } } // 실제 사용될 where (vendorType, techVendorType, workTypes 필터링 추가) const where = and(finalWhere, vendorTypeWhere, techVendorTypeWhere, workTypesWhere); // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(techVendors[item.id]) : asc(techVendors[item.id]) ) : [asc(techVendors.createdAt)]; // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 1) vendor 목록 조회 (with attachments) const vendorsData = await selectTechVendorsWithAttachments(tx, { where, orderBy, offset, limit: input.perPage, }); // 2) 전체 개수 const total = await countTechVendorsWithAttachments(tx, where); return { data: vendorsData, total }; }); // 페이지 수 const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { console.error("Error fetching tech vendors:", err); // 에러 발생 시 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, tags: ["tech-vendors"], // revalidateTag("tech-vendors") 호출 시 무효화 } )(); } /** * 기술영업 벤더 상태별 카운트 조회 */ export async function getTechVendorStatusCounts() { return unstable_cache( async () => { try { const initial: Record = { "PENDING_INVITE": 0, "INVITED": 0, "QUOTE_COMPARISON": 0, "ACTIVE": 0, "INACTIVE": 0, "BLACKLISTED": 0, }; const result = await db.transaction(async (tx) => { const rows = await groupByTechVendorStatus(tx); type StatusCountRow = { status: TechVendor["status"]; count: number }; return (rows as StatusCountRow[]).reduce>((acc, { status, count }) => { acc[status] = count; return acc; }, initial); }); return result; } catch (err) { return {} as Record; } }, ["tech-vendor-status-counts"], // 캐싱 키 { revalidate: 3600, } )(); } /** * 벤더 상세 정보 조회 */ export async function getTechVendorById(id: number) { return unstable_cache( async () => { try { const result = await getTechVendorDetailById(id); return { data: result }; } catch (err) { console.error("기술영업 벤더 상세 조회 오류:", err); return { data: null }; } }, [`tech-vendor-${id}`], { revalidate: 3600, tags: ["tech-vendors", `tech-vendor-${id}`], } )(); } /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ /** * 첨부파일 저장 헬퍼 함수 */ async function storeTechVendorFiles( tx: any, vendorId: number, files: File[], attachmentType: string ) { for (const file of files) { const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`) // Insert attachment record await tx.insert(techVendorAttachments).values({ vendorId, fileName: file.name, filePath: saveResult.publicPath, attachmentType, }); } } /** * 신규 기술영업 벤더 생성 */ export async function createTechVendor(input: CreateTechVendorSchema) { unstable_noStore(); try { // 이메일 중복 검사 const existingVendorByEmail = await db .select({ id: techVendors.id, vendorName: techVendors.vendorName }) .from(techVendors) .where(eq(techVendors.email, input.email)) .limit(1); // 이미 동일한 이메일을 가진 업체가 존재하면 에러 반환 if (existingVendorByEmail.length > 0) { return { success: false, data: null, error: `이미 등록된 이메일입니다. (업체명: ${existingVendorByEmail[0].vendorName})` }; } // taxId 중복 검사 const existingVendorByTaxId = await db .select({ id: techVendors.id }) .from(techVendors) .where(eq(techVendors.taxId, input.taxId)) .limit(1); // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 if (existingVendorByTaxId.length > 0) { return { success: false, data: null, error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)` }; } const result = await db.transaction(async (tx) => { // 1. 벤더 생성 const [newVendor] = await insertTechVendor(tx, { vendorName: input.vendorName, vendorCode: input.vendorCode || null, taxId: input.taxId, address: input.address || null, country: input.country, countryEng: null, countryFab: null, agentName: null, agentPhone: null, agentEmail: null, phone: input.phone || null, email: input.email, website: input.website || null, techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType, representativeName: input.representativeName || null, representativeBirth: input.representativeBirth || null, representativeEmail: input.representativeEmail || null, representativePhone: input.representativePhone || null, items: input.items || null, status: "ACTIVE", isQuoteComparison: false, }); // 2. 연락처 정보 등록 for (const contact of input.contacts) { await insertTechVendorContact(tx, { vendorId: newVendor.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: contact.isPrimary ?? false, contactCountry: contact.contactCountry || null, contactTitle: contact.contactTitle || null, }); } // 3. 첨부파일 저장 if (input.files && input.files.length > 0) { await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL"); } return newVendor; }); revalidateTag("tech-vendors"); return { success: true, data: result, error: null }; } catch (err) { console.error("기술영업 벤더 생성 오류:", err); return { success: false, data: null, error: getErrorMessage(err) }; } } /* ----------------------------------------------------- 3) 업데이트 (단건/복수) ----------------------------------------------------- */ /** 단건 업데이트 */ export async function modifyTechVendor( input: UpdateTechVendorSchema & { id: string; } ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { // 벤더 정보 업데이트 const [res] = await updateTechVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, address: input.address, country: input.country, countryEng: input.countryEng, countryFab: input.countryFab, phone: input.phone, email: input.email, website: input.website, status: input.status, // 에이전트 정보 추가 agentName: input.agentName, agentEmail: input.agentEmail, agentPhone: input.agentPhone, // 대표자 정보 추가 representativeName: input.representativeName, representativeEmail: input.representativeEmail, representativePhone: input.representativePhone, representativeBirth: input.representativeBirth, // techVendorType 처리 techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType, }); return res; }); // 캐시 무효화 revalidateTag("tech-vendors"); revalidateTag(`tech-vendor-${input.id}`); return { data: updated, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /* ----------------------------------------------------- 4) 연락처 관리 ----------------------------------------------------- */ export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 필터링 설정 const advancedWhere = filterColumns({ table: techVendorContacts, filters: input.filters, joinOperator: input.joinOperator, }); // 검색 조건 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(techVendorContacts.contactName, s), ilike(techVendorContacts.contactPosition, s), ilike(techVendorContacts.contactEmail, s), ilike(techVendorContacts.contactPhone, s) ); } // 해당 벤더 조건 const vendorWhere = eq(techVendorContacts.vendorId, id); // 최종 조건 결합 const finalWhere = and(advancedWhere, globalWhere, vendorWhere); // 정렬 조건 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id]) ) : [asc(techVendorContacts.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectTechVendorContacts(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); const total = await countTechVendorContacts(tx, finalWhere); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`tech-vendor-contacts-${id}`], } )(); } export async function createTechVendorContact(input: CreateTechVendorContactSchema) { unstable_noStore(); try { await db.transaction(async (tx) => { // DB Insert const [newContact] = await insertTechVendorContact(tx, { vendorId: input.vendorId, contactName: input.contactName, contactPosition: input.contactPosition || "", contactEmail: input.contactEmail, contactPhone: input.contactPhone || "", contactCountry: input.contactCountry || "", isPrimary: input.isPrimary || false, contactTitle: input.contactTitle || "", }); return newContact; }); // 캐시 무효화 revalidateTag(`tech-vendor-contacts-${input.vendorId}`); revalidateTag("users"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export async function updateTechVendorContact(input: UpdateTechVendorContactSchema & { id: number; vendorId: number }) { unstable_noStore(); try { const [updatedContact] = await db .update(techVendorContacts) .set({ contactName: input.contactName, contactPosition: input.contactPosition || null, contactEmail: input.contactEmail, contactPhone: input.contactPhone || null, contactCountry: input.contactCountry || null, isPrimary: input.isPrimary || false, contactTitle: input.contactTitle || null, updatedAt: new Date(), }) .where(eq(techVendorContacts.id, input.id)) .returning(); // 캐시 무효화 revalidateTag(`tech-vendor-contacts-${input.vendorId}`); revalidateTag("users"); return { data: updatedContact, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export async function deleteTechVendorContact(contactId: number, vendorId: number) { unstable_noStore(); try { const [deletedContact] = await db .delete(techVendorContacts) .where(eq(techVendorContacts.id, contactId)) .returning(); // 캐시 무효화 revalidateTag(`tech-vendor-contacts-${contactId}`); revalidateTag(`tech-vendor-contacts-${vendorId}`); return { data: deletedContact, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /* ----------------------------------------------------- 5) 아이템 관리 ----------------------------------------------------- */ export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // 벤더 ID 조건 const vendorWhere = eq(techVendorPossibleItems.vendorId, id); // 조선 아이템들 조회 const shipItems = await db .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, itemCode: itemShipbuilding.itemCode, workType: itemShipbuilding.workType, itemList: itemShipbuilding.itemList, shipTypes: itemShipbuilding.shipTypes, subItemList: sql`null`.as("subItemList"), techVendorType: techVendors.techVendorType, }) .from(techVendorPossibleItems) .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) .where(and(vendorWhere, not(isNull(techVendorPossibleItems.shipbuildingItemId)))); // 해양 TOP 아이템들 조회 const topItems = await db .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, itemCode: itemOffshoreTop.itemCode, workType: itemOffshoreTop.workType, itemList: itemOffshoreTop.itemList, shipTypes: sql`null`.as("shipTypes"), subItemList: itemOffshoreTop.subItemList, techVendorType: techVendors.techVendorType, }) .from(techVendorPossibleItems) .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreTopItemId)))); // 해양 HULL 아이템들 조회 const hullItems = await db .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, itemCode: itemOffshoreHull.itemCode, workType: itemOffshoreHull.workType, itemList: itemOffshoreHull.itemList, shipTypes: sql`null`.as("shipTypes"), subItemList: itemOffshoreHull.subItemList, techVendorType: techVendors.techVendorType, }) .from(techVendorPossibleItems) .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreHullItemId)))); // 모든 아이템들 합치기 const allItems = [...shipItems, ...topItems, ...hullItems]; // 필터링 적용 let filteredItems = allItems; if (input.search) { const s = input.search.toLowerCase(); filteredItems = filteredItems.filter(item => item.itemCode?.toLowerCase().includes(s) || item.workType?.toLowerCase().includes(s) || item.itemList?.toLowerCase().includes(s) || item.shipTypes?.toLowerCase().includes(s) || item.subItemList?.toLowerCase().includes(s) ); } // 정렬 적용 if (input.sort.length > 0) { const sortConfig = input.sort[0]; filteredItems.sort((a, b) => { const aVal = a[sortConfig.id as keyof typeof a]; const bVal = b[sortConfig.id as keyof typeof b]; if (aVal == null && bVal == null) return 0; if (aVal == null) return sortConfig.desc ? 1 : -1; if (bVal == null) return sortConfig.desc ? -1 : 1; const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; return sortConfig.desc ? -comparison : comparison; }); } // 페이지네이션 적용 const total = filteredItems.length; const paginatedItems = filteredItems.slice(offset, offset + input.perPage); const pageCount = Math.ceil(total / input.perPage); return { data: paginatedItems, pageCount }; } catch (err) { console.error("기술영업 벤더 아이템 조회 오류:", err); return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(id)], // 캐싱 키 { revalidate: 3600, tags: [`tech-vendor-items-${id}`], } )(); } export interface ItemDropdownOption { itemCode: string; itemList: string; workType: string | null; shipTypes: string | null; subItemList: string | null; } /** * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) * 아이템 코드, 이름, 설명만 간소화해서 반환 */ export async function getItemsForTechVendor(vendorId: number) { return unstable_cache( async () => { try { // 1. 벤더 정보 조회로 벤더 타입 확인 const vendor = await db.query.techVendors.findFirst({ where: eq(techVendors.id, vendorId), columns: { techVendorType: true } }); if (!vendor) { return { data: [], error: "벤더를 찾을 수 없습니다.", }; } // 2. 해당 벤더가 이미 가지고 있는 아이템 ID 목록 조회 const existingItems = await db .select({ shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, }) .from(techVendorPossibleItems) .where(eq(techVendorPossibleItems.vendorId, vendorId)); const existingShipItemIds = existingItems.map(item => item.shipbuildingItemId).filter(id => id !== null); const existingTopItemIds = existingItems.map(item => item.offshoreTopItemId).filter(id => id !== null); const existingHullItemIds = existingItems.map(item => item.offshoreHullItemId).filter(id => id !== null); // 3. 벤더 타입에 따라 해당 타입의 아이템만 조회 (기존에 없는 것만) const availableItems: Array<{ id: number; itemCode: string | null; itemList: string | null; workType: string | null; shipTypes?: string | null; subItemList?: string | null; itemType: "SHIP" | "TOP" | "HULL"; createdAt: Date; updatedAt: Date; }> = []; // 벤더 타입 파싱 - 콤마로 구분된 문자열을 배열로 변환 let vendorTypes: string[] = []; if (typeof vendor.techVendorType === 'string') { // 콤마로 구분된 문자열을 split하여 배열로 변환하고 공백 제거 vendorTypes = vendor.techVendorType.split(',').map(type => type.trim()).filter(type => type.length > 0); } else { vendorTypes = [vendor.techVendorType]; } // 각 벤더 타입별로 아이템 조회 for (const vendorType of vendorTypes) { if (vendorType === "조선") { const shipbuildingItems = await db .select({ id: itemShipbuilding.id, createdAt: itemShipbuilding.createdAt, updatedAt: itemShipbuilding.updatedAt, itemCode: itemShipbuilding.itemCode, itemList: itemShipbuilding.itemList, workType: itemShipbuilding.workType, shipTypes: itemShipbuilding.shipTypes, }) .from(itemShipbuilding) .where( existingShipItemIds.length > 0 ? not(inArray(itemShipbuilding.id, existingShipItemIds)) : undefined ) .orderBy(asc(itemShipbuilding.itemCode)); availableItems.push(...shipbuildingItems .filter(item => item.itemCode != null) .map(item => ({ id: item.id, createdAt: item.createdAt, updatedAt: item.updatedAt, itemCode: item.itemCode, itemList: item.itemList, workType: item.workType, shipTypes: item.shipTypes, itemType: "SHIP" as const }))); } if (vendorType === "해양TOP") { const offshoreTopItems = await db .select({ id: itemOffshoreTop.id, createdAt: itemOffshoreTop.createdAt, updatedAt: itemOffshoreTop.updatedAt, itemCode: itemOffshoreTop.itemCode, itemList: itemOffshoreTop.itemList, workType: itemOffshoreTop.workType, subItemList: itemOffshoreTop.subItemList, }) .from(itemOffshoreTop) .where( existingTopItemIds.length > 0 ? not(inArray(itemOffshoreTop.id, existingTopItemIds)) : undefined ) .orderBy(asc(itemOffshoreTop.itemCode)); availableItems.push(...offshoreTopItems .filter(item => item.itemCode != null) .map(item => ({ id: item.id, createdAt: item.createdAt, updatedAt: item.updatedAt, itemCode: item.itemCode, itemList: item.itemList, workType: item.workType, subItemList: item.subItemList, itemType: "TOP" as const }))); } if (vendorType === "해양HULL") { const offshoreHullItems = await db .select({ id: itemOffshoreHull.id, createdAt: itemOffshoreHull.createdAt, updatedAt: itemOffshoreHull.updatedAt, itemCode: itemOffshoreHull.itemCode, itemList: itemOffshoreHull.itemList, workType: itemOffshoreHull.workType, subItemList: itemOffshoreHull.subItemList, }) .from(itemOffshoreHull) .where( existingHullItemIds.length > 0 ? not(inArray(itemOffshoreHull.id, existingHullItemIds)) : undefined ) .orderBy(asc(itemOffshoreHull.itemCode)); availableItems.push(...offshoreHullItems .filter(item => item.itemCode != null) .map(item => ({ id: item.id, createdAt: item.createdAt, updatedAt: item.updatedAt, itemCode: item.itemCode, itemList: item.itemList, workType: item.workType, subItemList: item.subItemList, itemType: "HULL" as const }))); } } // 중복 제거 (같은 id와 itemType을 가진 아이템) const uniqueItems = availableItems.filter((item, index, self) => index === self.findIndex((t) => t.id === item.id && t.itemType === item.itemType) ); return { data: uniqueItems, error: null }; } catch (err) { console.error("Failed to fetch items for tech vendor dropdown:", err); return { data: [], error: "아이템 목록을 불러오는데 실패했습니다.", }; } }, // 캐시 키를 vendorId 별로 달리 해야 한다. ["items-for-tech-vendor", String(vendorId)], { revalidate: 3600, // 1시간 캐싱 tags: ["items"], // revalidateTag("items") 호출 시 무효화 } )(); } /** * 벤더 타입과 아이템 코드에 따른 아이템 조회 */ export async function getItemsByVendorType(vendorType: string, itemCode: string) { try { let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = []; switch (vendorType) { case "조선": const shipbuildingResults = await db .select({ id: itemShipbuilding.id, itemCode: itemShipbuilding.itemCode, workType: itemShipbuilding.workType, shipTypes: itemShipbuilding.shipTypes, itemList: itemShipbuilding.itemList, createdAt: itemShipbuilding.createdAt, updatedAt: itemShipbuilding.updatedAt, }) .from(itemShipbuilding) .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined); items = shipbuildingResults; break; case "해양TOP": const offshoreTopResults = await db .select({ id: itemOffshoreTop.id, itemCode: itemOffshoreTop.itemCode, workType: itemOffshoreTop.workType, itemList: itemOffshoreTop.itemList, subItemList: itemOffshoreTop.subItemList, createdAt: itemOffshoreTop.createdAt, updatedAt: itemOffshoreTop.updatedAt, }) .from(itemOffshoreTop) .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined); items = offshoreTopResults; break; case "해양HULL": const offshoreHullResults = await db .select({ id: itemOffshoreHull.id, itemCode: itemOffshoreHull.itemCode, workType: itemOffshoreHull.workType, itemList: itemOffshoreHull.itemList, subItemList: itemOffshoreHull.subItemList, createdAt: itemOffshoreHull.createdAt, updatedAt: itemOffshoreHull.updatedAt, }) .from(itemOffshoreHull) .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined); items = offshoreHullResults; break; default: items = []; } const result = items.map(item => ({ ...item, techVendorType: vendorType })); return { data: result, error: null }; } catch (err) { console.error("Error fetching items by vendor type:", err); return { data: [], error: "Failed to fetch items" }; } } /* ----------------------------------------------------- 6) 기술영업 벤더 승인/거부 ----------------------------------------------------- */ interface ApproveTechVendorsInput { ids: string[]; } /** * 기술영업 벤더 승인 (상태를 ACTIVE로 변경) */ export async function approveTechVendors(input: ApproveTechVendorsInput) { unstable_noStore(); try { // 트랜잭션 내에서 협력업체 상태 업데이트 const result = await db.transaction(async (tx) => { // 협력업체 상태 업데이트 const [updated] = await tx .update(techVendors) .set({ status: "ACTIVE", updatedAt: new Date() }) .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) .returning(); return updated; }); // 캐시 무효화 revalidateTag("tech-vendors"); revalidateTag("tech-vendor-status-counts"); return { data: result, error: null }; } catch (err) { console.error("Error approving tech vendors:", err); return { data: null, error: getErrorMessage(err) }; } } /** * 기술영업 벤더 거부 (상태를 REJECTED로 변경) */ export async function rejectTechVendors(input: ApproveTechVendorsInput) { unstable_noStore(); try { // 트랜잭션 내에서 협력업체 상태 업데이트 const result = await db.transaction(async (tx) => { // 협력업체 상태 업데이트 const [updated] = await tx .update(techVendors) .set({ status: "INACTIVE", updatedAt: new Date() }) .where(inArray(techVendors.id, input.ids.map(id => parseInt(id)))) .returning(); return updated; }); // 캐시 무효화 revalidateTag("tech-vendors"); revalidateTag("tech-vendor-status-counts"); return { data: result, error: null }; } catch (err) { console.error("Error rejecting tech vendors:", err); return { data: null, error: getErrorMessage(err) }; } } /* ----------------------------------------------------- 7) 엑셀 내보내기 ----------------------------------------------------- */ /** * 벤더 연락처 목록 엑셀 내보내기 */ export async function exportTechVendorContacts(vendorId: number) { try { const contacts = await db .select() .from(techVendorContacts) .where(eq(techVendorContacts.vendorId, vendorId)) .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName); return contacts; } catch (err) { console.error("기술영업 벤더 연락처 내보내기 오류:", err); return []; } } /** * 벤더 정보 엑셀 내보내기 */ export async function exportTechVendorDetails(vendorIds: number[]) { try { if (!vendorIds.length) return []; // 벤더 기본 정보 조회 const vendorsData = await db .select({ id: techVendors.id, vendorName: techVendors.vendorName, vendorCode: techVendors.vendorCode, taxId: techVendors.taxId, address: techVendors.address, country: techVendors.country, phone: techVendors.phone, email: techVendors.email, website: techVendors.website, status: techVendors.status, representativeName: techVendors.representativeName, representativeEmail: techVendors.representativeEmail, representativePhone: techVendors.representativePhone, representativeBirth: techVendors.representativeBirth, items: techVendors.items, createdAt: techVendors.createdAt, updatedAt: techVendors.updatedAt, }) .from(techVendors) .where( vendorIds.length === 1 ? eq(techVendors.id, vendorIds[0]) : inArray(techVendors.id, vendorIds) ); // 벤더별 상세 정보를 포함하여 반환 const vendorsWithDetails = await Promise.all( vendorsData.map(async (vendor) => { // 연락처 조회 const contacts = await exportTechVendorContacts(vendor.id); // // 아이템 조회 // const items = await exportTechVendorItems(vendor.id); return { ...vendor, vendorContacts: contacts, // vendorItems: items, }; }) ); return vendorsWithDetails; } catch (err) { console.error("기술영업 벤더 상세 내보내기 오류:", err); return []; } } /** * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함) */ export async function getTechVendorDetailById(id: number) { try { const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1); if (!vendor || vendor.length === 0) { console.error(`Vendor not found with id: ${id}`); return null; } const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id)); const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id)); const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id)); return { ...vendor[0], contacts, attachments, possibleItems }; } catch (error) { console.error("Error fetching tech vendor detail:", error); return null; } } /** * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션 * @param vendorId 기술영업 벤더 ID * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) { try { // API 경로 생성 (단일 파일 또는 모든 파일) const url = fileId ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`; // fetch 요청 (기본적으로 Blob으로 응답 받기) const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } // 파일명 가져오기 (Content-Disposition 헤더에서) const contentDisposition = response.headers.get('content-disposition'); let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`; if (contentDisposition) { const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); if (matches && matches[1]) { fileName = matches[1].replace(/['"]/g, ''); } } // Blob으로 응답 변환 const blob = await response.blob(); // Blob URL 생성 const blobUrl = window.URL.createObjectURL(blob); return { url: blobUrl, fileName, blob }; } catch (error) { console.error('Download API error:', error); throw error; } } /** * 임시 ZIP 파일 정리를 위한 서버 액션 * @param fileName 정리할 파일명 */ export async function cleanupTechTempFiles(fileName: string) { 'use server'; try { await deleteFile(`tmp/${fileName}`) return { success: true }; } catch (error) { console.error('임시 파일 정리 오류:', error); return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }; } } export const findVendorById = async (id: number): Promise => { try { // 직접 DB에서 조회 const vendor = await db .select() .from(techVendors) .where(eq(techVendors.id, id)) .limit(1) .then(rows => rows[0] || null); if (!vendor) { console.error(`Vendor not found with id: ${id}`); return null; } return vendor; } catch (error) { console.error('Error fetching vendor:', error); return null; } }; /* ----------------------------------------------------- 8) 기술영업 벤더 RFQ 히스토리 조회 ----------------------------------------------------- */ /** * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전) */ export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) { try { // 먼저 해당 벤더의 견적서가 있는지 확인 const { techSalesVendorQuotations } = await import("@/db/schema/techSales"); const quotationCheck = await db .select({ count: sql`count(*)`.as("count") }) .from(techSalesVendorQuotations) .where(eq(techSalesVendorQuotations.vendorId, id)); console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count); if (quotationCheck[0]?.count === 0) { console.log("해당 벤더의 견적서가 없습니다."); return { data: [], pageCount: 0 }; } const offset = (input.page - 1) * input.perPage; const { techSalesRfqs } = await import("@/db/schema/techSales"); const { biddingProjects } = await import("@/db/schema/projects"); // 간단한 조회 let whereCondition = eq(techSalesVendorQuotations.vendorId, id); // 검색이 있다면 추가 if (input.search) { const s = `%${input.search}%`; const searchCondition = and( whereCondition, or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.description, s), ilike(biddingProjects.pspid, s), ilike(biddingProjects.projNm, s) ) ); whereCondition = searchCondition || whereCondition; } // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가) const data = await db .select({ id: techSalesRfqs.id, rfqCode: techSalesRfqs.rfqCode, description: techSalesRfqs.description, projectCode: biddingProjects.pspid, projectName: biddingProjects.projNm, projectType: biddingProjects.pjtType, // 프로젝트 타입 추가 status: techSalesRfqs.status, totalAmount: techSalesVendorQuotations.totalPrice, currency: techSalesVendorQuotations.currency, dueDate: techSalesRfqs.dueDate, createdAt: techSalesRfqs.createdAt, quotationCode: techSalesVendorQuotations.quotationCode, submittedAt: techSalesVendorQuotations.submittedAt, }) .from(techSalesVendorQuotations) .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(whereCondition) .orderBy(desc(techSalesRfqs.createdAt)) .limit(input.perPage) .offset(offset); console.log("조회된 데이터:", data.length, "개"); // 전체 개수 조회 const totalResult = await db .select({ count: sql`count(*)`.as("count") }) .from(techSalesVendorQuotations) .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(whereCondition); const total = totalResult[0]?.count || 0; const pageCount = Math.ceil(total / input.perPage); console.log("기술영업 벤더 RFQ 히스토리 조회 완료", { id, dataLength: data.length, total, pageCount }); return { data, pageCount }; } catch (err) { console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", { err, id, stack: err instanceof Error ? err.stack : undefined }); return { data: [], pageCount: 0 }; } } /** * 기술영업 벤더 엑셀 import 시 유저 생성 및 담당자 등록 */ export async function importTechVendorsFromExcel( vendors: Array<{ vendorName: string; vendorCode?: string | null; email: string; taxId: string; country?: string | null; countryEng?: string | null; countryFab?: string | null; agentName?: string | null; agentPhone?: string | null; agentEmail?: string | null; address?: string | null; phone?: string | null; website?: string | null; techVendorType: string; representativeName?: string | null; representativeEmail?: string | null; representativePhone?: string | null; representativeBirth?: string | null; items: string; contacts?: Array<{ contactName: string; contactPosition?: string; contactEmail: string; contactPhone?: string; contactCountry?: string | null; isPrimary?: boolean; }>; }>, ) { unstable_noStore(); try { console.log("Import 시작 - 벤더 수:", vendors.length); console.log("첫 번째 벤더 데이터:", vendors[0]); const result = await db.transaction(async (tx) => { const createdVendors = []; const skippedVendors = []; const errors = []; for (const vendor of vendors) { console.log("벤더 처리 시작:", vendor.vendorName); try { // 0. 이메일 타입 검사 // - 문자열이 아니거나, '@' 미포함, 혹은 객체(예: 하이퍼링크 등)인 경우 모두 거절 const isEmailString = typeof vendor.email === "string"; const isEmailContainsAt = isEmailString && vendor.email.includes("@"); // 하이퍼링크 등 객체로 넘어온 경우 (예: { href: "...", ... } 등) 방지 const isEmailPlainString = isEmailString && Object.prototype.toString.call(vendor.email) === "[object String]"; if (!isEmailPlainString || !isEmailContainsAt) { console.log("이메일 형식이 올바르지 않습니다:", vendor.email); errors.push({ vendorName: vendor.vendorName, email: vendor.email, error: "이메일 형식이 올바르지 않습니다" }); continue; } // 1. 이메일로 기존 벤더 중복 체크 let existingVendor = await tx.query.techVendors.findFirst({ where: eq(techVendors.email, vendor.email), columns: { id: true, vendorName: true, vendorCode: true, email: true } }); // 2. 이메일이 중복되지 않은 경우 벤더 코드나 이름으로 추가 확인 if (!existingVendor && vendor.vendorCode) { existingVendor = await tx.query.techVendors.findFirst({ where: eq(techVendors.vendorCode, vendor.vendorCode), columns: { id: true, vendorName: true, vendorCode: true, email: true } }); } // 3. 벤더 코드도 일치하지 않는 경우 벤더 이름으로 확인 if (!existingVendor) { existingVendor = await tx.query.techVendors.findFirst({ where: eq(techVendors.vendorName, vendor.vendorName), columns: { id: true, vendorName: true, vendorCode: true, email: true } }); } // 4. 일치하는 벤더가 있는 경우 처리 if (existingVendor) { console.log("기존 벤더에 담당자 추가:", existingVendor.vendorName, vendor.email); // 기존 벤더의 벤더 타입 업데이트 (새로운 타입 추가) const existingVendorFull = await tx.query.techVendors.findFirst({ where: eq(techVendors.id, existingVendor.id), columns: { id: true, techVendorType: true } }); if (existingVendorFull) { const existingTypes = existingVendorFull.techVendorType ? existingVendorFull.techVendorType.split(',').map(t => t.trim()) : []; const newType = vendor.techVendorType.trim(); // 새로운 타입이 기존에 없는 경우에만 추가 if (!existingTypes.includes(newType)) { const updatedTypes = [...existingTypes, newType]; const updatedTypeString = updatedTypes.join(', '); await tx.update(techVendors) .set({ techVendorType: updatedTypeString }) .where(eq(techVendors.id, existingVendor.id)); console.log(`벤더 타입 업데이트: ${existingVendorFull.techVendorType} -> ${updatedTypeString}`); } } // 담당자 정보를 기존 벤더에 추가 let contactName = vendor.vendorName; let contactEmail = vendor.email; // vendor.contacts가 있고, contactName이 있으면 contactName 사용 if (vendor.contacts && vendor.contacts.length > 0 && vendor.contacts[0].contactName) { contactName = vendor.contacts[0].contactName; // 만약 contactEmail이 있으면 그걸 사용, 없으면 vendor.email 사용 if (vendor.contacts[0].contactEmail) { contactEmail = vendor.contacts[0].contactEmail; } } // 담당자 이메일 중복 체크 const existingContact = await tx.query.techVendorContacts.findFirst({ where: and( eq(techVendorContacts.vendorId, existingVendor.id), eq(techVendorContacts.contactEmail, contactEmail) ), columns: { id: true, contactEmail: true } }); if (existingContact) { console.log("담당자 이메일 중복:", contactEmail); errors.push({ vendorName: vendor.vendorName, email: vendor.email, error: `담당자 이메일 '${contactEmail}'이(가) 이미 등록되어 있습니다` }); } else { // 담당자 생성 await tx.insert(techVendorContacts).values({ vendorId: existingVendor.id, contactName: contactName, contactPosition: null, contactEmail: contactEmail, contactPhone: null, contactCountry: null, isPrimary: false, }); console.log("담당자 추가 성공:", contactName, contactEmail); } // 기존 벤더에 담당자 추가했으므로 벤더 생성은 스킵하고 유저 생성으로 넘어감 skippedVendors.push({ vendorName: vendor.vendorName, email: vendor.email, reason: `기존 벤더에 담당자 추가됨 (기존 업체: ${existingVendor.vendorName})` }); // 유저 생성 (기존 벤더의 담당자로 추가된 경우) if (contactEmail) { console.log("유저 생성 시도:", contactEmail); // 이미 존재하는 유저인지 확인 const existingUser = await tx.query.users.findFirst({ where: eq(users.email, contactEmail), columns: { id: true } }); if (!existingUser) { // 유저가 존재하지 않는 경우 생성 await tx.insert(users).values({ name: contactName, email: contactEmail, techCompanyId: existingVendor.id, domain: "partners", }); console.log("유저 생성 성공"); } else { // 이미 존재하는 유저라면 techCompanyId 업데이트 await tx.update(users) .set({ techCompanyId: existingVendor.id }) .where(eq(users.id, existingUser.id)); console.log("이미 존재하는 유저, techCompanyId 업데이트:", existingUser.id); } } continue; // 벤더 생성 부분으로 넘어가지 않음 } // 2. 벤더 생성 const [newVendor] = await tx.insert(techVendors).values({ vendorName: vendor.vendorName, vendorCode: vendor.vendorCode || null, taxId: vendor.taxId, country: vendor.country || null, countryEng: vendor.countryEng || null, countryFab: vendor.countryFab || null, agentName: vendor.agentName || null, agentPhone: vendor.agentPhone || null, agentEmail: vendor.agentEmail || null, address: vendor.address || null, phone: vendor.phone || null, email: vendor.email, website: vendor.website || null, techVendorType: vendor.techVendorType, status: "ACTIVE", representativeName: vendor.representativeName || null, representativeEmail: vendor.representativeEmail || null, representativePhone: vendor.representativePhone || null, representativeBirth: vendor.representativeBirth || null, }).returning(); console.log("벤더 생성 성공:", newVendor.id); // 2. 담당자 생성 (최소 1명 이상 등록) if (vendor.contacts && vendor.contacts.length > 0) { console.log("담당자 생성 시도:", vendor.contacts.length, "명"); for (const contact of vendor.contacts) { await tx.insert(techVendorContacts).values({ vendorId: newVendor.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, contactCountry: contact.contactCountry || null, isPrimary: contact.isPrimary || false, }); console.log("담당자 생성 성공:", contact.contactName, contact.contactEmail); } } else { // 담당자 정보가 없는 경우 벤더 정보로 기본 담당자 생성 console.log("기본 담당자 생성"); await tx.insert(techVendorContacts).values({ vendorId: newVendor.id, contactName: vendor.representativeName || vendor.vendorName || "기본 담당자", contactPosition: null, contactEmail: vendor.email, contactPhone: vendor.representativePhone || vendor.phone || null, contactCountry: vendor.country || null, isPrimary: true, }); console.log("기본 담당자 생성 성공:", vendor.email); } // 3. 유저 생성 (이메일이 있는 경우) if (vendor.email) { console.log("유저 생성 시도:", vendor.email); // 이미 존재하는 유저인지 확인 const existingUser = await tx.query.users.findFirst({ where: eq(users.email, vendor.email), columns: { id: true } }); if (!existingUser) { // 유저가 존재하지 않는 경우 생성 await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, techCompanyId: newVendor.id, domain: "partners", }); console.log("유저 생성 성공"); } else { // 이미 존재하는 유저라면 techCompanyId 업데이트 await tx.update(users) .set({ techCompanyId: newVendor.id }) .where(eq(users.id, existingUser.id)); console.log("이미 존재하는 유저, techCompanyId 업데이트:", existingUser.id); } } createdVendors.push(newVendor); console.log("벤더 처리 완료:", vendor.vendorName); } catch (error) { console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error); errors.push({ vendorName: vendor.vendorName, email: vendor.email, error: error instanceof Error ? error.message : "알 수 없는 오류" }); // 개별 벤더 오류는 전체 트랜잭션을 롤백하지 않도록 continue continue; } } console.log("모든 벤더 처리 완료:", { 생성됨: createdVendors.length, 스킵됨: skippedVendors.length, 오류: errors.length }); return { createdVendors, skippedVendors, errors, totalProcessed: vendors.length, successCount: createdVendors.length, skipCount: skippedVendors.length, errorCount: errors.length }; }); // 캐시 무효화 revalidateTag("tech-vendors"); revalidateTag("tech-vendor-contacts"); revalidateTag("users"); console.log("Import 완료 - 결과:", result); // 결과 메시지 생성 const messages = []; if (result.successCount > 0) { messages.push(`${result.successCount}개 벤더 생성 성공`); } if (result.skipCount > 0) { messages.push(`${result.skipCount}개 벤더 중복으로 스킵`); } if (result.errorCount > 0) { messages.push(`${result.errorCount}개 벤더 처리 중 오류`); } return { success: true, data: result, message: messages.join(", "), details: { created: result.createdVendors, skipped: result.skippedVendors, errors: result.errors } }; } catch (error) { console.error("Import 실패:", error); return { success: false, error: getErrorMessage(error) }; } } export async function findTechVendorById(id: number): Promise { const result = await db .select() .from(techVendors) .where(eq(techVendors.id, id)) .limit(1) return result[0] || null } /** * 회원가입 폼을 통한 기술영업 벤더 생성 (초대 토큰 기반) */ export async function createTechVendorFromSignup(params: { vendorData: { vendorName: string vendorCode?: string items: string website?: string taxId: string address?: string email: string phone?: string country: string techVendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[] representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string } files?: File[] contacts: { contactName: string contactPosition?: string contactEmail: string contactPhone?: string isPrimary?: boolean }[] selectedItemCodes?: string[] // 선택된 아이템 코드들 invitationToken?: string // 초대 토큰 }) { unstable_noStore(); try { console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName); // 초대 토큰 검증 let existingVendorId: number | null = null; if (params.invitationToken) { const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token"); const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken); if (!tokenPayload) { throw new Error("유효하지 않은 초대 토큰입니다."); } existingVendorId = tokenPayload.vendorId; console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId); } const result = await db.transaction(async (tx) => { let vendorResult; if (existingVendorId) { // 기존 벤더 정보 업데이트 const [updatedVendor] = await tx.update(techVendors) .set({ vendorName: params.vendorData.vendorName, vendorCode: params.vendorData.vendorCode || null, taxId: params.vendorData.taxId, country: params.vendorData.country, address: params.vendorData.address || null, phone: params.vendorData.phone || null, email: params.vendorData.email, website: params.vendorData.website || null, techVendorType: Array.isArray(params.vendorData.techVendorType) ? params.vendorData.techVendorType[0] : params.vendorData.techVendorType, status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경 representativeName: params.vendorData.representativeName || null, representativeEmail: params.vendorData.representativeEmail || null, representativePhone: params.vendorData.representativePhone || null, representativeBirth: params.vendorData.representativeBirth || null, items: params.vendorData.items, updatedAt: new Date(), }) .where(eq(techVendors.id, existingVendorId)) .returning(); vendorResult = updatedVendor; console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id); } else { // 1. 이메일 중복 체크 (새 벤더인 경우) const existingVendor = await tx.query.techVendors.findFirst({ where: eq(techVendors.email, params.vendorData.email), columns: { id: true, vendorName: true } }); if (existingVendor) { throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email} (기존 업체: ${existingVendor.vendorName})`); } // 2. 새 벤더 생성 const [newVendor] = await tx.insert(techVendors).values({ vendorName: params.vendorData.vendorName, vendorCode: params.vendorData.vendorCode || null, taxId: params.vendorData.taxId, country: params.vendorData.country, address: params.vendorData.address || null, phone: params.vendorData.phone || null, email: params.vendorData.email, website: params.vendorData.website || null, techVendorType: Array.isArray(params.vendorData.techVendorType) ? params.vendorData.techVendorType[0] : params.vendorData.techVendorType, status: "QUOTE_COMPARISON", isQuoteComparison: false, representativeName: params.vendorData.representativeName || null, representativeEmail: params.vendorData.representativeEmail || null, representativePhone: params.vendorData.representativePhone || null, representativeBirth: params.vendorData.representativeBirth || null, items: params.vendorData.items, }).returning(); vendorResult = newVendor; console.log("새 벤더 생성 완료:", vendorResult.id); } // 이 부분은 위에서 이미 처리되었으므로 주석 처리 // 3. 연락처 생성 if (params.contacts && params.contacts.length > 0) { for (const [index, contact] of params.contacts.entries()) { await tx.insert(techVendorContacts).values({ vendorId: vendorResult.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정 }); } console.log("연락처 생성 완료:", params.contacts.length, "개"); } // 4. 선택된 아이템들을 tech_vendor_possible_items에 저장 if (params.selectedItemCodes && params.selectedItemCodes.length > 0) { for (const itemCode of params.selectedItemCodes) { // 아이템 코드로 각 테이블에서 찾기 let itemId = null; let itemType = null; // 조선 아이템에서 찾기 const shipbuildingItem = await tx.query.itemShipbuilding.findFirst({ where: eq(itemShipbuilding.itemCode, itemCode) }); if (shipbuildingItem) { itemId = shipbuildingItem.id; itemType = "SHIP"; } else { // 해양 TOP 아이템에서 찾기 const offshoreTopItem = await tx.query.itemOffshoreTop.findFirst({ where: eq(itemOffshoreTop.itemCode, itemCode) }); if (offshoreTopItem) { itemId = offshoreTopItem.id; itemType = "TOP"; } else { // 해양 HULL 아이템에서 찾기 const offshoreHullItem = await tx.query.itemOffshoreHull.findFirst({ where: eq(itemOffshoreHull.itemCode, itemCode) }); if (offshoreHullItem) { itemId = offshoreHullItem.id; itemType = "HULL"; } } } if (itemId && itemType) { // 중복 체크 // let existingItem; const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorResult.id)]; if (itemType === "SHIP") { whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, itemId)); } else if (itemType === "TOP") { whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, itemId)); } else if (itemType === "HULL") { whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, itemId)); } const existingItem = await tx.query.techVendorPossibleItems.findFirst({ where: and(...whereConditions) }); if (!existingItem) { // 새 아이템 추가 const insertData: { vendorId: number; shipbuildingItemId?: number; offshoreTopItemId?: number; offshoreHullItemId?: number; } = { vendorId: vendorResult.id, }; if (itemType === "SHIP") { insertData.shipbuildingItemId = itemId; } else if (itemType === "TOP") { insertData.offshoreTopItemId = itemId; } else if (itemType === "HULL") { insertData.offshoreHullItemId = itemId; } await tx.insert(techVendorPossibleItems).values(insertData); } } } console.log("선택된 아이템 저장 완료:", params.selectedItemCodes.length, "개"); } // 4. 첨부파일 처리 if (params.files && params.files.length > 0) { await storeTechVendorFiles(tx, vendorResult.id, params.files, "GENERAL"); console.log("첨부파일 저장 완료:", params.files.length, "개"); } // 5. 유저 생성 (techCompanyId 설정) console.log("유저 생성 시도:", params.vendorData.email); const existingUser = await tx.query.users.findFirst({ where: eq(users.email, params.vendorData.email), columns: { id: true, techCompanyId: true } }); let userId = null; if (!existingUser) { const [newUser] = await tx.insert(users).values({ name: params.vendorData.vendorName, email: params.vendorData.email, techCompanyId: vendorResult.id, // 중요: techCompanyId 설정 domain: "partners", }).returning(); userId = newUser.id; console.log("유저 생성 성공:", userId); } else { // 기존 유저의 techCompanyId 업데이트 if (!existingUser.techCompanyId) { await tx.update(users) .set({ techCompanyId: vendorResult.id }) .where(eq(users.id, existingUser.id)); console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id); } userId = existingUser.id; } return { vendor: vendorResult, userId }; }); // 캐시 무효화 revalidateTag("tech-vendors"); revalidateTag("tech-vendor-possible-items"); revalidateTag("users"); console.log("기술영업 벤더 회원가입 완료:", result); return { success: true, data: result }; } catch (error) { console.error("기술영업 벤더 회원가입 실패:", error); return { success: false, error: getErrorMessage(error) }; } } /** * 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성) */ export async function addTechVendor(input: { vendorName: string; vendorCode?: string | null; email: string; taxId: string; country?: string | null; countryEng?: string | null; countryFab?: string | null; agentName?: string | null; agentPhone?: string | null; agentEmail?: string | null; address?: string | null; phone?: string | null; website?: string | null; techVendorType: string; representativeName?: string | null; representativeEmail?: string | null; representativePhone?: string | null; representativeBirth?: string | null; isQuoteComparison?: boolean; }) { unstable_noStore(); try { console.log("벤더 추가 시작:", input.vendorName); const result = await db.transaction(async (tx) => { // 1. 이메일 중복 체크 const existingVendor = await tx.query.techVendors.findFirst({ where: eq(techVendors.email, input.email), columns: { id: true, vendorName: true } }); if (existingVendor) { throw new Error(`이미 등록된 이메일입니다: ${input.email} (업체명: ${existingVendor.vendorName})`); } // 2. 벤더 생성 console.log("벤더 생성 시도:", { vendorName: input.vendorName, email: input.email, techVendorType: input.techVendorType }); const [newVendor] = await tx.insert(techVendors).values({ vendorName: input.vendorName, vendorCode: input.vendorCode || null, taxId: input.taxId || null, country: input.country || null, countryEng: input.countryEng || null, countryFab: input.countryFab || null, agentName: input.agentName || null, agentPhone: input.agentPhone || null, agentEmail: input.agentEmail || null, address: input.address || null, phone: input.phone || null, email: input.email, website: input.website || null, techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType, status: input.isQuoteComparison ? "PENDING_INVITE" : "ACTIVE", isQuoteComparison: input.isQuoteComparison || false, representativeName: input.representativeName || null, representativeEmail: input.representativeEmail || null, representativePhone: input.representativePhone || null, representativeBirth: input.representativeBirth || null, }).returning(); console.log("벤더 생성 성공:", newVendor.id); // 3. 견적비교용 벤더인 경우 PENDING_REVIEW 상태로 생성됨 // 초대는 별도의 초대 버튼을 통해 진행 console.log("벤더 생성 완료:", newVendor.id, "상태:", newVendor.status); // 4. 견적비교용 벤더(isQuoteComparison)가 아닌 경우에만 유저 생성 let userId = null; if (!input.isQuoteComparison) { console.log("유저 생성 시도:", input.email); // 이미 존재하는 유저인지 확인 const existingUser = await tx.query.users.findFirst({ where: eq(users.email, input.email), columns: { id: true, techCompanyId: true } }); // 유저가 존재하지 않는 경우에만 생성 if (!existingUser) { const [newUser] = await tx.insert(users).values({ name: input.vendorName, email: input.email, techCompanyId: newVendor.id, // techCompanyId 설정 domain: "partners", }).returning(); userId = newUser.id; console.log("유저 생성 성공:", userId); } else { // 이미 존재하는 유저의 techCompanyId가 null인 경우 업데이트 if (!existingUser.techCompanyId) { await tx.update(users) .set({ techCompanyId: newVendor.id }) .where(eq(users.id, existingUser.id)); console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id); } userId = existingUser.id; console.log("이미 존재하는 유저:", userId); } } else { console.log("견적비교용 벤더이므로 유저를 생성하지 않습니다."); } return { vendor: newVendor, userId }; }); // 캐시 무효화 revalidateTag("tech-vendors"); revalidateTag("users"); console.log("벤더 추가 완료:", result); return { success: true, data: result }; } catch (error) { console.error("벤더 추가 실패:", error); return { success: false, error: getErrorMessage(error) }; } } /** * 벤더의 possible items 개수 조회 */ export async function getTechVendorPossibleItemsCount(vendorId: number): Promise { try { const result = await db .select({ count: sql`count(*)`.as("count") }) .from(techVendorPossibleItems) .where(eq(techVendorPossibleItems.vendorId, vendorId)); return result[0]?.count || 0; } catch (err) { console.error("Error getting tech vendor possible items count:", err); return 0; } } /** * 기술영업 벤더 초대 메일 발송 */ export async function inviteTechVendor(params: { vendorId: number; subject: string; message: string; recipientEmail: string; }) { unstable_noStore(); try { console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId); const result = await db.transaction(async (tx) => { // 벤더 정보 조회 const vendor = await tx.query.techVendors.findFirst({ where: eq(techVendors.id, params.vendorId), }); if (!vendor) { throw new Error("벤더를 찾을 수 없습니다."); } // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서) if (vendor.status !== "PENDING_INVITE") { throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)"); } await tx.update(techVendors) .set({ status: "INVITED", updatedAt: new Date(), }) .where(eq(techVendors.id, params.vendorId)); // 초대 토큰 생성 const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token"); const { sendEmail } = await import("@/lib/mail/sendEmail"); const invitationToken = await createTechVendorInvitationToken({ vendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[], vendorId: vendor.id, vendorName: vendor.vendorName, email: params.recipientEmail, }); const signupUrl = await createTechVendorSignupUrl(invitationToken); // 초대 메일 발송 await sendEmail({ to: params.recipientEmail, subject: params.subject, template: "tech-vendor-invitation", context: { companyName: vendor.vendorName, language: "ko", registrationLink: signupUrl, customMessage: params.message, } }); console.log("초대 메일 발송 완료:", params.recipientEmail); return { vendor, invitationToken, signupUrl }; }); // 캐시 무효화 revalidateTag("tech-vendors"); console.log("기술영업 벤더 초대 완료:", result); return { success: true, data: result }; } catch (error) { console.error("기술영업 벤더 초대 실패:", error); return { success: false, error: getErrorMessage(error) }; } } /* ----------------------------------------------------- Possible Items 관련 함수들 ----------------------------------------------------- */ /** * 특정 벤더의 possible items 조회 (페이지네이션 포함) - 새 스키마에 맞게 수정 */ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema, vendorId: number) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage // 벤더 ID 조건 const vendorWhere = eq(techVendorPossibleItems.vendorId, vendorId) // 조선 아이템들 조회 const shipItems = await db .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, itemCode: itemShipbuilding.itemCode, workType: itemShipbuilding.workType, itemList: itemShipbuilding.itemList, shipTypes: itemShipbuilding.shipTypes, subItemList: sql`null`.as("subItemList"), techVendorType: techVendors.techVendorType, }) .from(techVendorPossibleItems) .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) .where(and(vendorWhere, not(isNull(techVendorPossibleItems.shipbuildingItemId)))) // 해양 TOP 아이템들 조회 const topItems = await db .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, itemCode: itemOffshoreTop.itemCode, workType: itemOffshoreTop.workType, itemList: itemOffshoreTop.itemList, shipTypes: sql`null`.as("shipTypes"), subItemList: itemOffshoreTop.subItemList, techVendorType: techVendors.techVendorType, }) .from(techVendorPossibleItems) .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreTopItemId)))) // 해양 HULL 아이템들 조회 const hullItems = await db .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, itemCode: itemOffshoreHull.itemCode, workType: itemOffshoreHull.workType, itemList: itemOffshoreHull.itemList, shipTypes: sql`null`.as("shipTypes"), subItemList: itemOffshoreHull.subItemList, techVendorType: techVendors.techVendorType, }) .from(techVendorPossibleItems) .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreHullItemId)))) // 모든 아이템들 합치기 const allItems = [...shipItems, ...topItems, ...hullItems] // 필터링 적용 let filteredItems = allItems if (input.search) { const s = input.search.toLowerCase() filteredItems = filteredItems.filter(item => item.itemCode?.toLowerCase().includes(s) || item.workType?.toLowerCase().includes(s) || item.itemList?.toLowerCase().includes(s) || item.shipTypes?.toLowerCase().includes(s) || item.subItemList?.toLowerCase().includes(s) ) } if (input.itemCode) { filteredItems = filteredItems.filter(item => item.itemCode?.toLowerCase().includes(input.itemCode!.toLowerCase()) ) } if (input.workType) { filteredItems = filteredItems.filter(item => item.workType?.toLowerCase().includes(input.workType!.toLowerCase()) ) } if (input.itemList) { filteredItems = filteredItems.filter(item => item.itemList?.toLowerCase().includes(input.itemList!.toLowerCase()) ) } if (input.shipTypes) { filteredItems = filteredItems.filter(item => item.shipTypes?.toLowerCase().includes(input.shipTypes!.toLowerCase()) ) } if (input.subItemList) { filteredItems = filteredItems.filter(item => item.subItemList?.toLowerCase().includes(input.subItemList!.toLowerCase()) ) } // 정렬 if (input.sort.length > 0) { filteredItems.sort((a, b) => { for (const sortItem of input.sort) { let aVal = (a as any)[sortItem.id] let bVal = (b as any)[sortItem.id] if (aVal === null || aVal === undefined) aVal = "" if (bVal === null || bVal === undefined) bVal = "" if (aVal < bVal) return sortItem.desc ? 1 : -1 if (aVal > bVal) return sortItem.desc ? -1 : 1 } return 0 }) } else { // 기본 정렬: createdAt 내림차순 filteredItems.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) } const total = filteredItems.length const pageCount = Math.ceil(total / input.perPage) // 페이지네이션 적용 const data = filteredItems.slice(offset, offset + input.perPage) return { data, pageCount } } catch (err) { console.error("Error fetching tech vendor possible items:", err) return { data: [], pageCount: 0 } } }, [JSON.stringify(input), String(vendorId)], { revalidate: 3600, tags: [`tech-vendor-possible-items-${vendorId}`], } )() } export async function createTechVendorPossibleItemNew(input: CreateTechVendorPossibleItemSchema) { unstable_noStore() try { // 중복 체크 - 새 스키마에 맞게 수정 let existing = null if (input.shipbuildingItemId) { existing = await db .select({ id: techVendorPossibleItems.id }) .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, input.vendorId), eq(techVendorPossibleItems.shipbuildingItemId, input.shipbuildingItemId) ) ) .limit(1) } else if (input.offshoreTopItemId) { existing = await db .select({ id: techVendorPossibleItems.id }) .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, input.vendorId), eq(techVendorPossibleItems.offshoreTopItemId, input.offshoreTopItemId) ) ) .limit(1) } else if (input.offshoreHullItemId) { existing = await db .select({ id: techVendorPossibleItems.id }) .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, input.vendorId), eq(techVendorPossibleItems.offshoreHullItemId, input.offshoreHullItemId) ) ) .limit(1) } if (existing && existing.length > 0) { return { data: null, error: "이미 등록된 아이템입니다." } } const [newItem] = await db .insert(techVendorPossibleItems) .values({ vendorId: input.vendorId, shipbuildingItemId: input.shipbuildingItemId || null, offshoreTopItemId: input.offshoreTopItemId || null, offshoreHullItemId: input.offshoreHullItemId || null, }) .returning() revalidateTag(`tech-vendor-possible-items-${input.vendorId}`) return { data: newItem, error: null } } catch (err) { console.error("Error creating tech vendor possible item:", err) return { data: null, error: getErrorMessage(err) } } } export async function deleteTechVendorPossibleItemsNew(ids: number[], vendorId: number) { unstable_noStore() try { await db .delete(techVendorPossibleItems) .where(inArray(techVendorPossibleItems.id, ids)) revalidateTag(`tech-vendor-possible-items-${vendorId}`) return { data: null, error: null } } catch (err) { return { data: null, error: getErrorMessage(err) } } } export async function addTechVendorPossibleItem(input: { vendorId: number; itemId: number; itemType: "SHIP" | "TOP" | "HULL"; }) { unstable_noStore(); try { // 중복 체크 // let existingItem; const whereConditions = [eq(techVendorPossibleItems.vendorId, input.vendorId)]; if (input.itemType === "SHIP") { whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, input.itemId)); } else if (input.itemType === "TOP") { whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, input.itemId)); } else if (input.itemType === "HULL") { whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, input.itemId)); } const existingItem = await db.query.techVendorPossibleItems.findFirst({ where: and(...whereConditions) }); if (existingItem) { return { success: false, error: "이미 추가된 아이템입니다." }; } // 새 아이템 추가 const insertData: { vendorId: number; shipbuildingItemId?: number; offshoreTopItemId?: number; offshoreHullItemId?: number; } = { vendorId: input.vendorId, }; if (input.itemType === "SHIP") { insertData.shipbuildingItemId = input.itemId; } else if (input.itemType === "TOP") { insertData.offshoreTopItemId = input.itemId; } else if (input.itemType === "HULL") { insertData.offshoreHullItemId = input.itemId; } const [newItem] = await db .insert(techVendorPossibleItems) .values(insertData) .returning(); revalidateTag(`tech-vendor-possible-items-${input.vendorId}`); return { success: true, data: newItem }; } catch (err) { return { success: false, error: getErrorMessage(err) }; } } /** * 아이템 추가 시 중복 체크 함수 * 조선의 경우 아이템코드+선종 조합으로, 나머지는 아이템코드만으로 중복 체크 */ export async function checkTechVendorItemDuplicate( vendorId: number, itemType: "SHIP" | "TOP" | "HULL", itemCode: string, shipTypes?: string ) { try { if (itemType === "SHIP") { // 조선의 경우 아이템코드 + 선종 조합으로 중복 체크 const shipItem = await db .select({ id: itemShipbuilding.id }) .from(itemShipbuilding) .where( and( eq(itemShipbuilding.itemCode, itemCode), shipTypes ? eq(itemShipbuilding.shipTypes, shipTypes) : isNull(itemShipbuilding.shipTypes) ) ) .limit(1) if (!shipItem.length) { return { isDuplicate: false, error: null } } const existing = await db .select({ id: techVendorPossibleItems.id }) .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, vendorId), eq(techVendorPossibleItems.shipbuildingItemId, shipItem[0].id) ) ) .limit(1) if (existing.length > 0) { return { isDuplicate: true, error: "이미 사용중인 아이템 코드 및 선종 입니다" } } } else if (itemType === "TOP") { // 해양 TOP의 경우 아이템코드만으로 중복 체크 const topItem = await db .select({ id: itemOffshoreTop.id }) .from(itemOffshoreTop) .where(eq(itemOffshoreTop.itemCode, itemCode)) .limit(1) if (!topItem.length) { return { isDuplicate: false, error: null } } const existing = await db .select({ id: techVendorPossibleItems.id }) .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, vendorId), eq(techVendorPossibleItems.offshoreTopItemId, topItem[0].id) ) ) .limit(1) if (existing.length > 0) { return { isDuplicate: true, error: "이미 사용중인 아이템 코드 입니다" } } } else if (itemType === "HULL") { // 해양 HULL의 경우 아이템코드만으로 중복 체크 const hullItem = await db .select({ id: itemOffshoreHull.id }) .from(itemOffshoreHull) .where(eq(itemOffshoreHull.itemCode, itemCode)) .limit(1) if (!hullItem.length) { return { isDuplicate: false, error: null } } const existing = await db .select({ id: techVendorPossibleItems.id }) .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, vendorId), eq(techVendorPossibleItems.offshoreHullItemId, hullItem[0].id) ) ) .limit(1) if (existing.length > 0) { return { isDuplicate: true, error: "이미 사용중인 아이템 코드 입니다" } } } return { isDuplicate: false, error: null } } catch (err) { console.error("Error checking duplicate:", err) return { isDuplicate: false, error: getErrorMessage(err) } } } export async function deleteTechVendorPossibleItem(itemId: number, vendorId: number) { unstable_noStore(); try { const [deletedItem] = await db .delete(techVendorPossibleItems) .where(eq(techVendorPossibleItems.id, itemId)) .returning(); revalidateTag(`tech-vendor-possible-items-${vendorId}`); return { success: true, data: deletedItem }; } catch (err) { return { success: false, error: getErrorMessage(err) }; } } //기술영업 담당자 연락처 관련 함수들 export interface ImportContactData { vendorEmail: string // 벤더 대표이메일 (유니크) contactName: string contactPosition?: string contactEmail: string contactPhone?: string contactCountry?: string isPrimary?: boolean } export interface ImportResult { success: boolean totalRows: number successCount: number failedRows: Array<{ row: number error: string vendorEmail: string contactName: string contactEmail: string }> } /** * 벤더 대표이메일로 벤더 찾기 */ async function getTechVendorByEmail(email: string) { const vendor = await db .select({ id: techVendors.id, vendorName: techVendors.vendorName, email: techVendors.email, }) .from(techVendors) .where(eq(techVendors.email, email)) .limit(1) return vendor[0] || null } /** * 연락처 이메일 중복 체크 */ async function checkContactEmailExists(vendorId: number, contactEmail: string) { const existing = await db .select() .from(techVendorContacts) .where( and( eq(techVendorContacts.vendorId, vendorId), eq(techVendorContacts.contactEmail, contactEmail) ) ) .limit(1) return existing.length > 0 } /** * 벤더 연락처 일괄 import */ export async function importTechVendorContacts( data: ImportContactData[] ): Promise { const result: ImportResult = { 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, contactName: row.contactName, contactEmail: row.contactEmail, }) continue } const vendor = await getTechVendorByEmail(row.vendorEmail.trim()) if (!vendor) { result.failedRows.push({ row: rowNumber, error: `벤더 대표이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`, vendorEmail: row.vendorEmail, contactName: row.contactName, contactEmail: row.contactEmail, }) continue } // 2. 연락처 이메일 중복 체크 const isDuplicate = await checkContactEmailExists(vendor.id, row.contactEmail) if (isDuplicate) { result.failedRows.push({ row: rowNumber, error: `이미 존재하는 연락처 이메일입니다: ${row.contactEmail}`, vendorEmail: row.vendorEmail, contactName: row.contactName, contactEmail: row.contactEmail, }) continue } // 3. 연락처 생성 await db.insert(techVendorContacts).values({ vendorId: vendor.id, contactName: row.contactName, contactPosition: row.contactPosition || null, contactEmail: row.contactEmail, contactPhone: row.contactPhone || null, contactCountry: row.contactCountry || null, isPrimary: row.isPrimary || false, }) result.successCount++ } catch (error) { result.failedRows.push({ row: rowNumber, error: error instanceof Error ? error.message : "알 수 없는 오류", vendorEmail: row.vendorEmail, contactName: row.contactName, contactEmail: row.contactEmail, }) } } // 캐시 무효화 revalidateTag("tech-vendor-contacts") return result } /** * 벤더 연락처 import 템플릿 생성 */ export async function generateContactImportTemplate(): Promise { const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet("벤더연락처_템플릿") // 헤더 설정 worksheet.columns = [ { header: "벤더대표이메일*", key: "vendorEmail", width: 25 }, { header: "담당자명*", key: "contactName", width: 20 }, { header: "직책", key: "contactPosition", width: 15 }, { header: "담당자이메일*", key: "contactEmail", width: 25 }, { header: "담당자연락처", key: "contactPhone", width: 15 }, { header: "담당자국가", key: "contactCountry", width: 15 }, { header: "주담당자여부", key: "isPrimary", width: 12 }, ] // 헤더 스타일 설정 const headerRow = worksheet.getRow(1) headerRow.font = { bold: true } headerRow.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE0E0E0" }, } // 예시 데이터 추가 worksheet.addRow({ vendorEmail: "example@company.com", contactName: "홍길동", contactPosition: "대표", contactEmail: "hong@company.com", contactPhone: "010-1234-5678", contactCountry: "대한민국", isPrimary: "Y", }) worksheet.addRow({ vendorEmail: "example@company.com", contactName: "김철수", contactPosition: "과장", contactEmail: "kim@company.com", contactPhone: "010-9876-5432", contactCountry: "대한민국", isPrimary: "N", }) const buffer = await workbook.xlsx.writeBuffer() return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }) } /** * Excel 파일에서 연락처 데이터 파싱 */ export async function parseContactImportFile(file: File): Promise { 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: ImportContactData[] = [] worksheet.eachRow((row, index) => { console.log(`행 ${index} 처리 중:`, row.values) // 헤더 행 건너뛰기 (1행) if (index === 1) return const values = row.values as (string | null)[] if (!values || values.length < 4) return const vendorEmail = values[1]?.toString().trim() const contactName = values[2]?.toString().trim() const contactPosition = values[3]?.toString().trim() const contactEmail = values[4]?.toString().trim() const contactPhone = values[5]?.toString().trim() const contactCountry = values[6]?.toString().trim() const isPrimary = values[7]?.toString().trim() // 필수 필드 검증 if (!vendorEmail || !contactName || !contactEmail) { return } data.push({ vendorEmail, contactName, contactPosition: contactPosition || undefined, contactEmail, contactPhone: contactPhone || undefined, contactCountry: contactCountry || undefined, isPrimary: isPrimary === "Y" || isPrimary === "y", }) // rowNumber++ }) 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 { 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`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`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 { 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 { 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 } let vendor = await findVendorByEmail(row.vendorEmail.trim()) // 2. 벤더 테이블에서 찾을 수 없는 경우, 담당자 테이블에서 찾기 if (!vendor) { console.log(`벤더 테이블에서 찾을 수 없음, 담당자 테이블에서 검색: ${row.vendorEmail}`) // 담당자 테이블에서 해당 이메일로 검색 const contact = await db.query.techVendorContacts.findFirst({ where: eq(techVendorContacts.contactEmail, row.vendorEmail.trim()), columns: { vendorId: true, contactEmail: true } }) if (contact) { console.log(`담당자 테이블에서 찾음, 벤더 ID: ${contact.vendorId}`) // 해당 벤더 정보 가져오기 vendor = await db.query.techVendors.findFirst({ where: eq(techVendors.id, contact.vendorId), columns: { id: true, vendorName: true, email: true } }) if (vendor) { console.log(`담당자를 통해 벤더 찾음: ${vendor.vendorName}`) } } } 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 { 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 { 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", }) }