// src/lib/items-ship/service.ts "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import { asc, desc, ilike, and, or, eq, count, inArray, sql } from "drizzle-orm"; import { GetItemsSchema, UpdateItemSchema, ShipbuildingItemCreateData, TypedItemCreateData, OffshoreTopItemCreateData, OffshoreHullItemCreateData } from "./validations"; import { Item, items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; import { findAllItems } from "./repository"; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ /** * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getShipbuildingItems(input: GetItemsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: items, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(items.itemCode, s), ilike(items.itemName, s), ilike(items.description, s) ); } const finalWhere = and( advancedWhere, globalWhere ); const where = finalWhere; const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(items[item.id]) : asc(items[item.id]) ) : [asc(items.createdAt)]; // 조선 아이템 테이블과 기본 아이템 테이블 조인하여 조회 const result = await db.select({ id: itemShipbuilding.id, itemId: itemShipbuilding.itemId, workType: itemShipbuilding.workType, shipTypes: itemShipbuilding.shipTypes, itemList: itemShipbuilding.itemList, itemCode: items.itemCode, itemName: items.itemName, description: items.description, createdAt: itemShipbuilding.createdAt, updatedAt: itemShipbuilding.updatedAt, }) .from(itemShipbuilding) .innerJoin(items, eq(itemShipbuilding.itemId, items.id)) .where(where) .orderBy(...orderBy) .offset(offset) .limit(input.perPage); // 전체 데이터 개수 조회 const [{ count: total }] = await db.select({ count: count() }) .from(itemShipbuilding) .innerJoin(items, eq(itemShipbuilding.itemId, items.id)) .where(where); const pageCount = Math.ceil(Number(total) / input.perPage); return { data: result, pageCount }; } catch (err) { console.error("Error fetching shipbuilding items:", err); return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: ["items"], } )(); } export async function getOffshoreTopItems(input: GetItemsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: items, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(items.itemCode, s), ilike(items.itemName, s), ilike(items.description, s) ); } const finalWhere = and( advancedWhere, globalWhere ); const where = finalWhere; const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(items[item.id]) : asc(items[item.id]) ) : [asc(items.createdAt)]; // 해양 TOP 아이템 테이블과 기본 아이템 테이블 조인하여 조회 const result = await db.select({ id: itemOffshoreTop.id, itemId: itemOffshoreTop.itemId, workType: itemOffshoreTop.workType, itemList: itemOffshoreTop.itemList, subItemList: itemOffshoreTop.subItemList, itemCode: items.itemCode, itemName: items.itemName, description: items.description, createdAt: itemOffshoreTop.createdAt, updatedAt: itemOffshoreTop.updatedAt, }) .from(itemOffshoreTop) .innerJoin(items, eq(itemOffshoreTop.itemId, items.id)) .where(where) .orderBy(...orderBy) .offset(offset) .limit(input.perPage); // 전체 데이터 개수 조회 const [{ count: total }] = await db.select({ count: count() }) .from(itemOffshoreTop) .innerJoin(items, eq(itemOffshoreTop.itemId, items.id)) .where(where); const pageCount = Math.ceil(Number(total) / input.perPage); return { data: result, pageCount }; } catch (err) { console.error("Error fetching offshore top items:", err); return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: ["items"], } )(); } export async function getOffshoreHullItems(input: GetItemsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: items, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(items.itemCode, s), ilike(items.itemName, s), ilike(items.description, s) ); } const finalWhere = and( advancedWhere, globalWhere ); const where = finalWhere; const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(items[item.id]) : asc(items[item.id]) ) : [asc(items.createdAt)]; // 해양 HULL 아이템 테이블과 기본 아이템 테이블 조인하여 조회 const result = await db.select({ id: itemOffshoreHull.id, itemId: itemOffshoreHull.itemId, workType: itemOffshoreHull.workType, itemList: itemOffshoreHull.itemList, subItemList: itemOffshoreHull.subItemList, itemCode: items.itemCode, itemName: items.itemName, description: items.description, createdAt: itemOffshoreHull.createdAt, updatedAt: itemOffshoreHull.updatedAt, }) .from(itemOffshoreHull) .innerJoin(items, eq(itemOffshoreHull.itemId, items.id)) .where(where) .orderBy(...orderBy) .offset(offset) .limit(input.perPage); // 전체 데이터 개수 조회 const [{ count: total }] = await db.select({ count: count() }) .from(itemOffshoreHull) .innerJoin(items, eq(itemOffshoreHull.itemId, items.id)) .where(where); const pageCount = Math.ceil(Number(total) / input.perPage); return { data: result, pageCount }; } catch (err) { console.error("Error fetching offshore hull items:", err); return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: ["items"], } )(); } /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ /** * Item 생성 - 아이템 타입에 따라 해당 테이블에 데이터 삽입 */ export async function createShipbuildingItem(input: TypedItemCreateData) { unstable_noStore() try { if (!input.itemCode) { return { success: false, message: "아이템 코드는 필수입니다", data: null, error: "필수 필드 누락" } } // itemName이 없으면 "기술영업"으로 설정 if (!input.itemName) { input.itemName = "기술영업" } const result = await db.transaction(async (tx) => { // 1. itemCode 정규화해서 직접 쿼리 const existRows = await tx.select().from(items) .where(eq(items.itemCode, input.itemCode)); const existingItem = existRows[0]; let itemId: number; let itemResult: any; if (existingItem) { // 기존 아이템이 있으면 업데이트하지 않고 그대로 사용 itemId = existingItem.id; itemResult = [existingItem]; // 배열 형태로 반환 } else { // 없으면 새로 생성 // 현재 가장 큰 ID 값 가져오기 const maxIdResult = await tx.select({ maxId: sql`MAX(id)` }).from(items); const maxId = maxIdResult[0]?.maxId || 0; const newId = Number(maxId) + 1; // 새 ID로 아이템 생성 itemResult = await tx.insert(items).values({ id: newId, itemCode: input.itemCode, itemName: input.itemName, description: input.description, }).returning(); itemId = itemResult[0].id; } const shipData = input as ShipbuildingItemCreateData; const typeResult = await tx.insert(itemShipbuilding).values({ itemId: itemId, workType: shipData.workType ? (shipData.workType as '기장' | '전장' | '선실' | '배관' | '철의') : '기장', shipTypes: shipData.shipTypes || '', itemList: shipData.itemList || null, createdAt: new Date(), updatedAt: new Date() }).returning(); return { itemData: itemResult[0], shipbuildingData: typeResult[0] }; }) revalidateTag("items") return { success: true, data: result || 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) } } } /** * Excel import를 위한 조선 아이템 생성 함수 * 하나의 아이템 코드에 대해 여러 선종을 처리 (1:N 관계) */ export async function createShipbuildingImportItem(input: { itemCode: string; itemName: string; workType: '기장' | '전장' | '선실' | '배관' | '철의'; description?: string | null; itemList?: string | null; shipTypes: Record; }) { unstable_noStore(); try { if (!input.itemCode) { return { success: false, message: "아이템 코드는 필수입니다", data: null, error: "필수 필드 누락" } } // itemName이 없을 경우 "기술영업"으로 설정 if (!input.itemName) { input.itemName = "기술영업"; } const results = await db.transaction(async (tx) => { // 1. itemCode 정규화해서 직접 쿼리 const existRows = await tx.select().from(items) .where(eq(items.itemCode, input.itemCode)); const existingItem = existRows[0]; console.log('DB에서 직접 조회한 기존 아이템:', existingItem); let itemId: number; if (existingItem) { // 기존 아이템이 있으면 업데이트하지 않고 그대로 사용 itemId = existingItem.id; console.log('기존 아이템 사용, id:', itemId); } else { // 없으면 새로 생성 // 현재 가장 큰 ID 값 가져오기 const maxIdResult = await tx.select({ maxId: sql`MAX(id)` }).from(items); const maxId = maxIdResult[0]?.maxId || 0; const newId = Number(maxId) + 1; console.log('새 아이템 생성을 위한 ID 계산:', { maxId, newId }); // 새 ID로 아이템 생성 const insertResult = await tx.insert(items).values({ id: newId, itemCode: input.itemCode, itemName: input.itemName, description: input.description, }).returning(); itemId = insertResult[0].id; console.log('새 아이템 생성 완료, id:', itemId); } const createdItems = []; for (const shipType of Object.keys(input.shipTypes)) { // 그대로 선종명 string으로 저장 const existShip = await tx.select().from(itemShipbuilding) .where( and( eq(itemShipbuilding.itemId, itemId), eq(itemShipbuilding.shipTypes, shipType) ) ); if (!existShip[0]) { const shipbuildingResult = await tx.insert(itemShipbuilding).values({ itemId: itemId, workType: input.workType, shipTypes: shipType, itemList: input.itemList || null, createdAt: new Date(), updatedAt: new Date() }).returning(); createdItems.push({ ...shipbuildingResult[0] }); console.log('조선아이템 생성:', shipType, shipbuildingResult[0]); } else { console.log('이미 존재하는 조선아이템:', shipType); } } return createdItems; }); revalidateTag("items"); return { success: true, data: results, error: null } } catch (err) { return { success: false, message: getErrorMessage(err), data: null, error: getErrorMessage(err) } } } export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) { unstable_noStore() try { if (!data.itemCode) { return { success: false, message: "아이템 코드는 필수입니다", data: null, error: "필수 필드 누락" } } // itemName이 없으면 "기술영업"으로 설정 if (!data.itemName) { data.itemName = "기술영업" } // 트랜잭션 내에서 처리 const result = await db.transaction(async (tx) => { // 1. itemCode 정규화해서 직접 쿼리 const existRows = await tx.select().from(items) .where(eq(items.itemCode, data.itemCode)); const existingItem = existRows[0]; let itemId: number; let itemResult: any; if (existingItem) { // 기존 아이템이 있으면 업데이트하지 않고 그대로 사용 itemId = existingItem.id; itemResult = [existingItem]; // 배열 형태로 반환 } else { // 없으면 새로 생성 // 현재 가장 큰 ID 값 가져오기 const maxIdResult = await tx.select({ maxId: sql`MAX(id)` }).from(items); const maxId = maxIdResult[0]?.maxId || 0; const newId = Number(maxId) + 1; // 새 ID로 아이템 생성 itemResult = await tx.insert(items).values({ id: newId, itemCode: data.itemCode, itemName: data.itemName, description: data.description, }).returning(); itemId = itemResult[0].id; } const [offshoreTop] = await tx .insert(itemOffshoreTop) .values({ itemId: itemId, workType: data.workType, itemList: data.itemList, subItemList: data.subItemList, createdAt: new Date(), updatedAt: new Date() }) .returning(); return { itemData: itemResult[0], offshoreTopData: offshoreTop }; }) revalidateTag("items") return { success: true, data: result, 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) } } } export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) { unstable_noStore() try { if (!data.itemCode) { return { success: false, message: "아이템 코드는 필수입니다", data: null, error: "필수 필드 누락" } } // itemName이 없으면 "기술영업"으로 설정 if (!data.itemName) { data.itemName = "기술영업" } // 트랜잭션 내에서 처리 const result = await db.transaction(async (tx) => { // 1. itemCode 정규화해서 직접 쿼리 const existRows = await tx.select().from(items) .where(eq(items.itemCode, data.itemCode)); const existingItem = existRows[0]; let itemId: number; let itemResult: any; if (existingItem) { // 기존 아이템이 있으면 업데이트하지 않고 그대로 사용 itemId = existingItem.id; itemResult = [existingItem]; // 배열 형태로 반환 } else { // 없으면 새로 생성 // 현재 가장 큰 ID 값 가져오기 const maxIdResult = await tx.select({ maxId: sql`MAX(id)` }).from(items); const maxId = maxIdResult[0]?.maxId || 0; const newId = Number(maxId) + 1; // 새 ID로 아이템 생성 itemResult = await tx.insert(items).values({ id: newId, itemCode: data.itemCode, itemName: data.itemName, description: data.description, }).returning(); itemId = itemResult[0].id; } const [offshoreHull] = await tx .insert(itemOffshoreHull) .values({ itemId: itemId, workType: data.workType, itemList: data.itemList, subItemList: data.subItemList, createdAt: new Date(), updatedAt: new Date() }) .returning(); return { itemData: itemResult[0], offshoreHullData: offshoreHull }; }) revalidateTag("items") return { success: true, data: result, 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) 업데이트 ----------------------------------------------------- */ // 업데이트 타입 정의 인터페이스 interface UpdateShipbuildingItemInput extends UpdateItemSchema { id: number; workType?: string; shipTypes?: string; itemList?: string; itemCode?: string; itemName?: string; description?: string; } /** 단건 업데이트 */ export async function modifyShipbuildingItem(input: UpdateShipbuildingItemInput) { unstable_noStore(); try { const result = await db.transaction(async (tx) => { // 기존 아이템 조회 const existingShipbuilding = await tx.query.itemShipbuilding.findFirst({ where: eq(itemShipbuilding.id, input.id), with: { item: true } }); if (!existingShipbuilding) { throw new Error("아이템을 찾을 수 없습니다."); } // 세부 아이템 테이블만 업데이트 (items 테이블은 변경하지 않음) const updateData: Record = {}; if (input.workType) updateData.workType = input.workType as '기장' | '전장' | '선실' | '배관' | '철의'; if (input.shipTypes) updateData.shipTypes = input.shipTypes; if (input.itemList !== undefined) updateData.itemList = input.itemList; if (Object.keys(updateData).length > 0) { await tx.update(itemShipbuilding) .set(updateData) .where(eq(itemShipbuilding.id, input.id)); } return { data: { id: input.id }, error: null, success: true, message: "아이템이 성공적으로 업데이트되었습니다." }; }); // 캐시 무효화 revalidateTag("items"); return result; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 업데이트 중 오류가 발생했습니다." }; } } // Offshore TOP 업데이트 타입 정의 인터페이스 interface UpdateOffshoreTopItemInput extends UpdateItemSchema { id: number; workType?: string; itemList?: string; subItemList?: string; itemCode?: string; itemName?: string; description?: string; } /** Offshore TOP 단건 업데이트 */ export async function modifyOffshoreTopItem(input: UpdateOffshoreTopItemInput) { unstable_noStore(); try { const result = await db.transaction(async (tx) => { // 기존 아이템 조회 const existingOffshoreTop = await tx.query.itemOffshoreTop.findFirst({ where: eq(itemOffshoreTop.id, input.id), with: { item: true } }); if (!existingOffshoreTop) { throw new Error("아이템을 찾을 수 없습니다."); } // 세부 아이템 테이블만 업데이트 (items 테이블은 변경하지 않음) const updateData: Record = {}; if (input.workType) updateData.workType = input.workType as 'TM' | 'TS' | 'TE' | 'TP'; if (input.itemList !== undefined) updateData.itemList = input.itemList; if (input.subItemList !== undefined) updateData.subItemList = input.subItemList; if (Object.keys(updateData).length > 0) { await tx.update(itemOffshoreTop) .set(updateData) .where(eq(itemOffshoreTop.id, input.id)); } return { data: { id: input.id }, error: null, success: true, message: "아이템이 성공적으로 업데이트되었습니다." }; }); // 캐시 무효화 revalidateTag("items"); return result; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 업데이트 중 오류가 발생했습니다." }; } } // Offshore HULL 업데이트 타입 정의 인터페이스 interface UpdateOffshoreHullItemInput extends UpdateItemSchema { id: number; workType?: string; itemList?: string; subItemList?: string; itemCode?: string; itemName?: string; description?: string; } /** Offshore HULL 단건 업데이트 */ export async function modifyOffshoreHullItem(input: UpdateOffshoreHullItemInput) { unstable_noStore(); try { const result = await db.transaction(async (tx) => { // 기존 아이템 조회 const existingOffshoreHull = await tx.query.itemOffshoreHull.findFirst({ where: eq(itemOffshoreHull.id, input.id), with: { item: true } }); if (!existingOffshoreHull) { throw new Error("아이템을 찾을 수 없습니다."); } // 세부 아이템 테이블만 업데이트 (items 테이블은 변경하지 않음) const updateData: Record = {}; if (input.workType) updateData.workType = input.workType as 'HA' | 'HE' | 'HH' | 'HM' | 'NC'; if (input.itemList !== undefined) updateData.itemList = input.itemList; if (input.subItemList !== undefined) updateData.subItemList = input.subItemList; if (Object.keys(updateData).length > 0) { await tx.update(itemOffshoreHull) .set(updateData) .where(eq(itemOffshoreHull.id, input.id)); } return { data: { id: input.id }, error: null, success: true, message: "아이템이 성공적으로 업데이트되었습니다." }; }); // 캐시 무효화 revalidateTag("items"); return result; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 업데이트 중 오류가 발생했습니다." }; } } /* ----------------------------------------------------- 4) 삭제 ----------------------------------------------------- */ // 삭제 타입 정의 인터페이스 interface DeleteItemInput { id: number; } interface DeleteItemsInput { ids: number[]; } /** 단건 삭제 */ export async function removeShipbuildingItem(input: DeleteItemInput) { unstable_noStore(); try { await db.transaction(async (tx) => { // 세부 아이템만 삭제 (아이템 테이블은 유지) await tx.delete(itemShipbuilding) .where(eq(itemShipbuilding.id, input.id)); }); revalidateTag("items"); return { data: null, error: null, success: true, message: "아이템이 성공적으로 삭제되었습니다." }; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 삭제 중 오류가 발생했습니다." }; } } /** 복수 삭제 */ export async function removeShipbuildingItems(input: DeleteItemsInput) { unstable_noStore(); try { await db.transaction(async (tx) => { if (input.ids.length > 0) { // 세부 아이템만 삭제 (아이템 테이블은 유지) await tx.delete(itemShipbuilding) .where(inArray(itemShipbuilding.id, input.ids)); } }); revalidateTag("items"); return { data: null, error: null, success: true, message: "아이템이 성공적으로 삭제되었습니다." }; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 삭제 중 오류가 발생했습니다." }; } } /** Offshore TOP 단건 삭제 */ export async function removeOffshoreTopItem(input: DeleteItemInput) { unstable_noStore(); try { await db.transaction(async (tx) => { // 세부 아이템만 삭제 (아이템 테이블은 유지) await tx.delete(itemOffshoreTop) .where(eq(itemOffshoreTop.id, input.id)); }); revalidateTag("items"); return { data: null, error: null, success: true, message: "아이템이 성공적으로 삭제되었습니다." }; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 삭제 중 오류가 발생했습니다." }; } } /** Offshore TOP 복수 삭제 */ export async function removeOffshoreTopItems(input: DeleteItemsInput) { unstable_noStore(); try { await db.transaction(async (tx) => { if (input.ids.length > 0) { // 세부 아이템만 삭제 (아이템 테이블은 유지) await tx.delete(itemOffshoreTop) .where(inArray(itemOffshoreTop.id, input.ids)); } }); revalidateTag("items"); return { data: null, error: null, success: true, message: "아이템이 성공적으로 삭제되었습니다." }; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 삭제 중 오류가 발생했습니다." }; } } /** Offshore HULL 단건 삭제 */ export async function removeOffshoreHullItem(input: DeleteItemInput) { unstable_noStore(); try { await db.transaction(async (tx) => { // 세부 아이템만 삭제 (아이템 테이블은 유지) await tx.delete(itemOffshoreHull) .where(eq(itemOffshoreHull.id, input.id)); }); revalidateTag("items"); return { data: null, error: null, success: true, message: "아이템이 성공적으로 삭제되었습니다." }; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 삭제 중 오류가 발생했습니다." }; } } /** Offshore HULL 복수 삭제 */ export async function removeOffshoreHullItems(input: DeleteItemsInput) { unstable_noStore(); try { await db.transaction(async (tx) => { if (input.ids.length > 0) { // 세부 아이템만 삭제 (아이템 테이블은 유지) await tx.delete(itemOffshoreHull) .where(inArray(itemOffshoreHull.id, input.ids)); } }); revalidateTag("items"); return { data: null, error: null, success: true, message: "아이템이 성공적으로 삭제되었습니다." }; } catch (err) { return { data: null, error: getErrorMessage(err), success: false, message: "아이템 삭제 중 오류가 발생했습니다." }; } } export async function getAllShipbuildingItems(): Promise { try { return await findAllItems(); } catch (error) { console.error("Failed to get items:", error); throw new Error("Failed to get items"); } }