// src/lib/items/service.ts "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { customAlphabet } from "nanoid"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, isNull, ne, gt } from "drizzle-orm"; import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations"; import { Item, items } from "@/db/schema/items"; import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository"; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ /** * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getItems(input: GetItemsSchema) { const safePerPage = Math.min(input.perPage, 100); return unstable_cache( async () => { try { const offset = (input.page - 1) * safePerPage; const advancedWhere = filterColumns({ table: items, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(items.ProjectNo, s), ilike(items.itemCode, s), ilike(items.itemName, s), ilike(items.smCode, s), ilike(items.packageCode, s), ilike(items.description, s), ); } const finalWhere = and(advancedWhere, globalWhere); const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(items[item.id]) : asc(items[item.id]) ) : [asc(items.createdAt)]; const { data, total } = await db.transaction(async (tx) => { const data = await selectItems(tx, { where: finalWhere, orderBy, offset, limit: safePerPage, }); const total = await countItems(tx, finalWhere); return { data, total }; }); console.log(data) const pageCount = Math.ceil(total / safePerPage); return { data, pageCount }; } catch (err) { console.error(err); return { data: [], pageCount: 0 }; } }, [JSON.stringify({...input, perPage: safePerPage})], { revalidate: 3600, tags: ["items"], } )(); } export interface GetItemsInfiniteInput extends Omit { cursor?: string; limit?: number; } // 무한 스크롤 결과 타입 export interface GetItemsInfiniteResult { data: any[]; hasNextPage: boolean; nextCursor: string | null; total?: number | null; } export async function getItemsInfinite(input: GetItemsInfiniteInput): Promise { return unstable_cache( async () => { try { // 페이지 크기 제한 (기존과 동일한 방식) const safeLimit = Math.min(input.limit || 50, 100); // 고급 필터링 (기존과 완전 동일) const advancedWhere = filterColumns({ table: items, filters: input.filters, joinOperator: input.joinOperator, }); // 전역 검색 (기존과 완전 동일) let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(items.itemLevel, s), ilike(items.itemCode, s), ilike(items.itemName, s), ilike(items.description, s), ilike(items.parentItemCode, s), ilike(items.unitOfMeasure, s), ilike(items.steelType, s), ilike(items.gradeMaterial, s), ilike(items.baseUnitOfMeasure, s), ilike(items.changeDate, s) ); } // 커서 기반 페이지네이션 조건 추가 let cursorWhere; if (input.cursor) { cursorWhere = gt(items.id, input.cursor); } // 모든 조건 결합 const finalWhere = and(advancedWhere, globalWhere, cursorWhere); // 정렬 (기존과 동일하지만 id 정렬 보장) let orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(items[item.id]) : asc(items[item.id]) ) : [asc(items.createdAt)]; // 무한 스크롤에서는 id 정렬이 필수 (커서 기반 페이지네이션용) const hasIdSort = orderBy.some(sort => { const column = sort.constructor.name.includes('desc') ? sort.column : sort.column; return column === items.id; }); if (!hasIdSort) { orderBy.push(asc(items.id)); } // 트랜잭션으로 데이터 조회 (기존과 동일한 패턴) const { data, total } = await db.transaction(async (tx) => { // limit + 1로 다음 페이지 존재 여부 확인 const data = await selectItems(tx, { where: finalWhere, orderBy, limit: safeLimit + 1, }); // 첫 페이지에서만 전체 개수 계산 (성능 최적화) let total = null; if (!input.cursor) { // 커서 조건 제외하고 전체 개수 계산 const countWhere = and(advancedWhere, globalWhere); total = await countItems(tx, countWhere); } return { data, total }; }); // 다음 페이지 존재 여부 및 커서 설정 const hasNextPage = data.length > safeLimit; const resultItems = hasNextPage ? data.slice(0, safeLimit) : data; const nextCursor = hasNextPage && resultItems.length > 0 ? resultItems[resultItems.length - 1].id : null; return { data: resultItems, hasNextPage, nextCursor, total, }; } catch (err) { console.error('getItemsInfinite error:', err); return { data: [], hasNextPage: false, nextCursor: null, total: 0, }; } }, [JSON.stringify({ ...input, limit: Math.min(input.limit || 50, 100) })], { revalidate: 3600, tags: ["items"], } )(); } // 통합된 Items 조회 함수 (모드별 자동 분기) export async function getItemsUnified(input: GetItemsSchema & { mode?: 'pagination' | 'infinite'; cursor?: string }): Promise { // perPage 기반 모드 자동 결정 const isInfiniteMode = input.perPage >= 1_000_000; if (isInfiniteMode || input.mode === 'infinite') { // 무한 스크롤 모드 return getItemsInfinite({ ...input, limit: 50, // 실제로는 50개씩 로드 cursor: input.cursor, }); } else { // 기존 페이지네이션 모드 return getItems(input); } } /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ export interface ItemCreateData { itemCode: string itemName: string description: string | null parentItemCode?: string | null itemLevel?: number | null deleteFlag?: string | null unitOfMeasure?: string | null steelType?: string | null gradeMaterial?: string | null changeDate?: string | null baseUnitOfMeasure?: string | null } /** * Item 생성 후, (가장 오래된 Item 1개) 삭제로 * 전체 Item 개수를 고정 */ export async function createItem(input: ItemCreateData) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 try { if (!input.itemCode || !input.itemName) { return { success: false, message: "아이템 코드와 아이템 명은 필수입니다", data: null, error: "필수 필드 누락" } } // result 변수에 명시적으로 타입과 초기값 할당 let result: any[] = [] // 트랜잭션 결과를 result에 할당 result = await db.transaction(async (tx) => { // 기존 아이템 확인 (itemCode는 unique) const existingItem = await tx.query.items.findFirst({ where: eq(items.itemCode, input.itemCode), }) let txResult if (existingItem) { // 기존 아이템 업데이트 txResult = await updateItem(tx, existingItem.id, { itemName: input.itemName, description: input.description, parentItemCode: input.parentItemCode, itemLevel: input.itemLevel, deleteFlag: input.deleteFlag, unitOfMeasure: input.unitOfMeasure, steelType: input.steelType, gradeMaterial: input.gradeMaterial, changeDate: input.changeDate, baseUnitOfMeasure: input.baseUnitOfMeasure, }) } else { // 새 아이템 생성 txResult = await insertItem(tx, { itemCode: input.itemCode, itemName: input.itemName, description: input.description, parentItemCode: input.parentItemCode, itemLevel: input.itemLevel, deleteFlag: input.deleteFlag, unitOfMeasure: input.unitOfMeasure, steelType: input.steelType, gradeMaterial: input.gradeMaterial, changeDate: input.changeDate, baseUnitOfMeasure: input.baseUnitOfMeasure, }) } return txResult }) // 캐시 무효화 revalidateTag("items") return { success: true, data: result[0] || null, error: null } } catch (err) { console.error("아이템 생성/업데이트 오류:", err) // 중복 키 오류 처리 if (err instanceof Error && err.message.includes("unique constraint")) { return { success: false, message: "이미 존재하는 아이템 코드입니다", data: null, error: "중복 키 오류" } } return { success: false, message: getErrorMessage(err), data: null, error: getErrorMessage(err) } } } /* ----------------------------------------------------- 3) 업데이트 ----------------------------------------------------- */ /** 단건 업데이트 */ export async function modifyItem(input: UpdateItemSchema & { id: number }) { unstable_noStore(); try { await db.transaction(async (tx) => { await updateItem(tx, input.id, { itemCode: input.itemCode, itemName: input.itemName, description: input.description, parentItemCode: input.parentItemCode, itemLevel: input.itemLevel, deleteFlag: input.deleteFlag, unitOfMeasure: input.unitOfMeasure, steelType: input.steelType, gradeMaterial: input.gradeMaterial, changeDate: input.changeDate, baseUnitOfMeasure: input.baseUnitOfMeasure, }); }); revalidateTag("items"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 단건 삭제 */ export async function removeItem(input: { id: number }) { unstable_noStore(); try { await db.transaction(async (tx) => { // 삭제 await deleteItemById(tx, input.id); // 바로 새 Item 생성 }); revalidateTag("items"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 복수 삭제 */ export async function removeItems(input: { ids: number[] }) { unstable_noStore(); try { await db.transaction(async (tx) => { // 삭제 await deleteItemsByIds(tx, input.ids); }); revalidateTag("items"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export async function getAllItems(): Promise { try { return await findAllItems(); } catch (err) { throw new Error("Failed to get items"); } } // PQ용 아이템 검색 함수 export async function searchItemsForPQ(query: string): Promise<{ itemCode: string; itemName: string }[]> { unstable_noStore(); try { if (!query || query.trim().length < 1) { return []; } const searchQuery = `%${query.trim()}%`; const results = await db .select({ itemCode: items.itemCode, itemName: items.itemName, }) .from(items) .where( and( or( ilike(items.itemCode, searchQuery), ilike(items.itemName, searchQuery) ), // 삭제되지 않은 아이템만 or( isNull(items.deleteFlag), ne(items.deleteFlag, 'Y') ) ) ) .limit(20) // 최대 20개 결과만 반환 .orderBy(asc(items.itemCode)); return results; } catch (err) { console.error("PQ 아이템 검색 오류:", err); return []; } } export interface PackageItem { packageCode: string; description: string; smCode: string | null; } export async function getPackagesByProject(projectNo: string): Promise { try { // selectDistinct를 사용하여 중복 제거 const result = await db .selectDistinct({ packageCode: items.packageCode, description: items.description, smCode: items.smCode, }) .from(items) .where(eq(items.ProjectNo, projectNo)) .orderBy(items.packageCode); // null 값을 처리하고 타입을 정리 return result.map(item => ({ packageCode: item.packageCode, description: item.description || item.packageCode, smCode: item.smCode || null })); } catch (error) { console.error("Failed to fetch packages:", error); return []; } }