"use server" import db from "@/db/db" import { formEntries, forms, tagClasses, tags, tagSubfieldOptions, tagSubfields, tagTypes } from "@/db/schema/vendorData" // import { eq } from "drizzle-orm" import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type CreateTagSchema } from "./validations" import { revalidateTag, unstable_noStore } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne, count, isNull } from "drizzle-orm"; import { countTags, insertTag, selectTags } from "./repository"; import { getErrorMessage } from "../handle-error"; import { getFormMappingsByTagType } from './form-mapping-service'; import { contractItems, contracts } from "@/db/schema/contract"; import { getCodeListsByID } from "../sedp/sync-object-class"; import { projects, vendors } from "@/db/schema"; import { randomBytes } from 'crypto'; // 폼 결과를 위한 인터페이스 정의 interface CreatedOrExistingForm { id: number; formCode: string; formName: string; isNewlyCreated: boolean; } /** * 16진수 24자리 고유 식별자 생성 * @returns 24자리 16진수 문자열 (예: "a1b2c3d4e5f6789012345678") */ function generateTagIdx(): string { return randomBytes(12).toString('hex'); // 12바이트 = 24자리 16진수 } export async function getTags(input: GetTagsSchema, packagesId: number) { // return unstable_cache( // async () => { try { const offset = (input.page - 1) * input.perPage; // (1) advancedWhere const advancedWhere = filterColumns({ table: tags, filters: input.filters, joinOperator: input.joinOperator, }); // (2) globalWhere let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(tags.tagNo, s), ilike(tags.tagType, s), ilike(tags.description, s) ); } // (4) 최종 where const finalWhere = and(advancedWhere, globalWhere, eq(tags.contractItemId, packagesId)); // (5) 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(tags[item.id]) : asc(tags[item.id]) ) : [asc(tags.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectTags(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); const total = await countTags(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(packagesId)], // 캐싱 키에 packagesId 추가 // { // revalidate: 3600, // tags: [`tags-${packagesId}`], // 패키지별 태그 사용 // } // )(); } export async function createTag( formData: CreateTagSchema, selectedPackageId: number | null ) { if (!selectedPackageId) { return { error: "No selectedPackageId provided" } } // Validate formData const validated = createTagSchema.safeParse(formData) if (!validated.success) { return { error: validated.error.flatten().formErrors.join(", ") } } // React 서버 액션에서 매 요청마다 실행 unstable_noStore() try { // 하나의 트랜잭션에서 모든 작업 수행 return await db.transaction(async (tx) => { // 1) 선택된 contractItem의 contractId 가져오기 const contractItemResult = await tx .select({ contractId: contractItems.contractId, projectId: contracts.projectId // projectId 추가 }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 .where(eq(contractItems.id, selectedPackageId)) .limit(1) if (contractItemResult.length === 0) { return { error: "Contract item not found" } } const contractId = contractItemResult[0].contractId const projectId = contractItemResult[0].projectId // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인 const duplicateCheck = await tx .select({ count: sql`count(*)` }) .from(tags) .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) .where( and( eq(contractItems.contractId, contractId), eq(tags.tagNo, validated.data.tagNo) ) ) if (duplicateCheck[0].count > 0) { return { error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, } } // 3) 태그 타입에 따른 폼 정보 가져오기 const allFormMappings = await getFormMappingsByTagType( validated.data.tagType, projectId, // projectId 전달 validated.data.class ) // ep가 "IMEP"인 것만 필터링 const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [] // 폼 매핑이 없으면 로그만 남기고 진행 if (!formMappings || formMappings.length === 0) { console.log( "No form mappings found for tag type:", validated.data.tagType, "in project:", projectId ) } // 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 let primaryFormId: number | null = null const createdOrExistingForms: CreatedOrExistingForm[] = [] if (formMappings && formMappings.length > 0) { console.log(selectedPackageId, formMappings) for (const formMapping of formMappings) { // 4-1) 이미 존재하는 폼인지 확인 const existingForm = await tx .select({ id: forms.id, im: forms.im, eng: forms.eng }) // eng 필드도 추가로 조회 .from(forms) .where( and( eq(forms.contractItemId, selectedPackageId), eq(forms.formCode, formMapping.formCode) ) ) .limit(1) let formId: number if (existingForm.length > 0) { // 이미 존재하면 해당 ID 사용 formId = existingForm[0].id // 업데이트할 필드들 준비 const updateValues: any = {}; let shouldUpdate = false; // im 필드 체크 if (existingForm[0].im !== true) { updateValues.im = true; shouldUpdate = true; } // eng 필드 체크 - remark에 "VD_"가 포함되어 있을 때만 if (formMapping.remark && formMapping.remark.includes("VD_") && existingForm[0].eng !== true) { updateValues.eng = true; shouldUpdate = true; } if (shouldUpdate) { await tx .update(forms) .set(updateValues) .where(eq(forms.id, formId)) console.log(`Form ${formId} updated with:`, updateValues) } createdOrExistingForms.push({ id: formId, formCode: formMapping.formCode, formName: formMapping.formName, isNewlyCreated: false, }) } else { // 존재하지 않으면 새로 생성 const insertValues: any = { contractItemId: selectedPackageId, formCode: formMapping.formCode, formName: formMapping.formName, im: true, }; // remark에 "VD_"가 포함되어 있을 때만 eng: true 설정 if (formMapping.remark && formMapping.remark.includes("VD_")) { insertValues.eng = true; } const insertResult = await tx .insert(forms) .values(insertValues) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) console.log("insertResult:", insertResult) formId = insertResult[0].id createdOrExistingForms.push({ id: formId, formCode: insertResult[0].formCode, formName: insertResult[0].formName, isNewlyCreated: true, }) } // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용 if (primaryFormId === null) { primaryFormId = formId } } } // 🆕 16진수 24자리 태그 고유 식별자 생성 const generatedTagIdx = generateTagIdx(); console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); // 5) 새 Tag 생성 (tagIdx 추가) const [newTag] = await insertTag(tx, { contractItemId: selectedPackageId, formId: primaryFormId, tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 tagNo: validated.data.tagNo, class: validated.data.class, tagType: validated.data.tagType, description: validated.data.description ?? null, }) console.log(`tags-${selectedPackageId}`, "create", newTag) // 6) 생성된 각 form에 대해 formEntries에 데이터 추가 (TAG_IDX 포함) for (const form of createdOrExistingForms) { try { // 기존 formEntry 가져오기 const existingEntry = await tx.query.formEntries.findFirst({ where: and( eq(formEntries.formCode, form.formCode), eq(formEntries.contractItemId, selectedPackageId) ) }); // 새로운 태그 데이터 객체 생성 (TAG_IDX 포함) const newTagData = { TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 TAG_NO: validated.data.tagNo, TAG_DESC: validated.data.description ?? null, status: "New" // 수동으로 생성된 태그임을 표시 }; if (existingEntry && existingEntry.id) { // 기존 formEntry가 있는 경우 - TAG_IDX 타입 추가 let existingData: Array<{ TAG_IDX?: string; // 🆕 TAG_IDX 필드 추가 TAG_NO: string; TAG_DESC?: string; status?: string; [key: string]: any; }> = []; if (Array.isArray(existingEntry.data)) { existingData = existingEntry.data; } // TAG_IDX 또는 TAG_NO가 이미 존재하는지 확인 (우선순위: TAG_IDX) const existingTagIndex = existingData.findIndex( item => item.TAG_IDX === generatedTagIdx || (item.TAG_NO === validated.data.tagNo && !item.TAG_IDX) ); if (existingTagIndex === -1) { // 태그가 없으면 새로 추가 const updatedData = [...existingData, newTagData]; await tx .update(formEntries) .set({ data: updatedData, updatedAt: new Date() }) .where(eq(formEntries.id, existingEntry.id)); console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} with tagIdx ${generatedTagIdx} to existing formEntry for form ${form.formCode}`); } else { console.log(`[CREATE TAG] Tag ${validated.data.tagNo} already exists in formEntry for form ${form.formCode}`); } } else { // formEntry가 없는 경우 새로 생성 (TAG_IDX 포함) await tx.insert(formEntries).values({ formCode: form.formCode, contractItemId: selectedPackageId, data: [newTagData], createdAt: new Date(), updatedAt: new Date(), }); console.log(`[CREATE TAG] Created new formEntry with tag ${validated.data.tagNo} and tagIdx ${generatedTagIdx} for form ${form.formCode}`); } } catch (formEntryError) { console.error(`[CREATE TAG] Error updating formEntry for form ${form.formCode}:`, formEntryError); // 개별 formEntry 에러는 로그만 남기고 전체 트랜잭션은 계속 진행 } } // 7) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}-ENG`) revalidateTag("tags") // 생성된 각 form의 캐시도 무효화 createdOrExistingForms.forEach(form => { revalidateTag(`form-data-${form.formCode}-${selectedPackageId}`) }) // 8) 성공 시 반환 (tagIdx 추가) return { success: true, data: { forms: createdOrExistingForms, primaryFormId, tagNo: validated.data.tagNo, tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환 }, } }) } catch (err: any) { console.log("createTag error:", err) console.error("createTag error:", err) return { error: getErrorMessage(err) } } } export async function createTagInForm( formData: CreateTagSchema, selectedPackageId: number | null, formCode: string, packageCode: string ) { // 1. 초기 검증 if (!selectedPackageId) { console.error("[CREATE TAG] No selectedPackageId provided"); return { success: false, error: "No selectedPackageId provided" }; } // 2. FormData 검증 const validated = createTagSchema.safeParse(formData); if (!validated.success) { const errorMsg = validated.error.flatten().formErrors.join(", "); console.error("[CREATE TAG] Validation failed:", errorMsg); return { success: false, error: errorMsg }; } // 3. 캐시 무효화 설정 unstable_noStore(); try { // 4. 트랜잭션 시작 return await db.transaction(async (tx) => { // 5. Contract Item 정보 조회 const contractItemResult = await tx .select({ contractId: contractItems.contractId, projectId: contracts.projectId, vendorId: contracts.vendorId }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where(eq(contractItems.id, selectedPackageId)) .limit(1); if (contractItemResult.length === 0) { console.error("[CREATE TAG] Contract item not found"); return { success: false, error: "Contract item not found" }; } const { contractId, projectId, vendorId } = contractItemResult[0]; // 6. Vendor 정보 조회 const vendor = await tx.query.vendors.findFirst({ where: eq(vendors.id, vendorId) }); if (!vendor) { console.error("[CREATE TAG] Vendor not found"); return { success: false, error: "선택한 벤더를 찾을 수 없습니다." }; } // 7. 중복 태그 확인 const duplicateCheck = await tx .select({ count: sql`count(*)` }) .from(tags) .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where( and( eq(contracts.projectId, projectId), eq(tags.tagNo, validated.data.tagNo) ) ); if (duplicateCheck[0].count > 0) { console.error(`[CREATE TAG] Duplicate tag number: ${validated.data.tagNo}`); return { success: false, error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, }; } // 8. Form 조회 let form = await tx.query.forms.findFirst({ where: and( eq(forms.formCode, formCode), eq(forms.contractItemId, selectedPackageId) ) }); // 9. Form이 없으면 생성 if (!form) { console.log(`[CREATE TAG] Form ${formCode} not found, attempting to create...`); // Form Mappings 조회 const allFormMappings = await getFormMappingsByTagType( validated.data.tagType, projectId, validated.data.class ); // IMEP 폼만 필터링 const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); if (!targetFormMapping) { console.error(`[CREATE TAG] No IMEP form mapping found for formCode: ${formCode}`); return { success: false, error: `Form ${formCode} not found and no IMEP mapping available for tag type ${validated.data.tagType}` }; } // Form 생성 const insertResult = await tx .insert(forms) .values({ contractItemId: selectedPackageId, formCode: targetFormMapping.formCode, formName: targetFormMapping.formName, im: true, }) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); form = { id: insertResult[0].id, formCode: insertResult[0].formCode, formName: insertResult[0].formName, contractItemId: selectedPackageId, im: true, createdAt: new Date(), updatedAt: new Date() }; console.log(`[CREATE TAG] Successfully created form:`, insertResult[0]); } else { // 기존 form의 im 상태 업데이트 if (form.im !== true) { await tx .update(forms) .set({ im: true }) .where(eq(forms.id, form.id)); console.log(`[CREATE TAG] Form ${form.id} updated with im: true`); } } // 10. Form이 있는 경우에만 진행 if (!form?.id) { console.error("[CREATE TAG] Failed to create or find form"); return { success: false, error: "Failed to create or find form" }; } // 11. Tag Index 생성 const generatedTagIdx = generateTagIdx(); console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); // 12. 새 Tag 생성 const [newTag] = await insertTag(tx, { contractItemId: selectedPackageId, formId: form.id, tagIdx: generatedTagIdx, tagNo: validated.data.tagNo, class: validated.data.class, tagType: validated.data.tagType, description: validated.data.description ?? null, }); // 13. Tag Class 조회 const tagClass = await tx.query.tagClasses.findFirst({ where: and( eq(tagClasses.projectId, projectId), eq(tagClasses.label, validated.data.class) ) }); if (!tagClass) { console.warn("[CREATE TAG] Tag class not found, using default"); } // 14. FormEntry 처리 const entry = await tx.query.formEntries.findFirst({ where: and( eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, selectedPackageId), ) }); // 15. 새로운 태그 데이터 준비 const newTagData = { TAG_IDX: generatedTagIdx, TAG_NO: validated.data.tagNo, TAG_DESC: validated.data.description ?? null, CLS_ID: tagClass?.code || validated.data.class, // tagClass가 없을 경우 대비 VNDRCD: vendor.vendorCode, VNDRNM_1: vendor.vendorName, CM3003: packageCode, ME5074: packageCode, status: "New", // 수동으로 생성된 태그임을 표시 source: "EVCP" // 태그 출처 (불변) - eVCP에서 수동 생성 }; if (entry?.id) { // 16. 기존 FormEntry 업데이트 let existingData: Array = []; if (Array.isArray(entry.data)) { existingData = entry.data; } console.log(`[CREATE TAG] Existing data count: ${existingData.length}`); const updatedData = [...existingData, newTagData]; await tx .update(formEntries) .set({ data: updatedData, updatedAt: new Date() }) .where(eq(formEntries.id, entry.id)); console.log(`[CREATE TAG] Updated formEntry with new tag`); } else { // 17. 새 FormEntry 생성 console.log(`[CREATE TAG] Creating new formEntry`); await tx.insert(formEntries).values({ formCode: formCode, contractItemId: selectedPackageId, data: [newTagData], createdAt: new Date(), updatedAt: new Date(), }); console.log(`[CREATE TAG] Created new formEntry`); } // 18. 캐시 무효화 revalidateTag(`tags-${selectedPackageId}`); revalidateTag(`forms-${selectedPackageId}`); revalidateTag(`form-data-${formCode}-${selectedPackageId}`); revalidateTag("tags"); console.log(`[CREATE TAG] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`); // 19. 성공 응답 return { success: true, data: { formId: form.id, tagNo: validated.data.tagNo, tagIdx: generatedTagIdx, formCreated: !form } }; }); } catch (err: any) { // 20. 에러 처리 console.error("[CREATE TAG] Transaction error:", err); const errorMessage = getErrorMessage(err); return { success: false, error: errorMessage }; } } export async function updateTag( formData: UpdateTagSchema & { id: number }, selectedPackageId: number | null ) { if (!selectedPackageId) { return { error: "No selectedPackageId provided" } } if (!formData.id) { return { error: "No tag ID provided" } } // Validate formData const validated = updateTagSchema.safeParse(formData) if (!validated.success) { return { error: validated.error.flatten().formErrors.join(", ") } } // React 서버 액션에서 매 요청마다 실행 unstable_noStore() try { // 하나의 트랜잭션에서 모든 작업 수행 return await db.transaction(async (tx) => { // 1) 기존 태그 존재 여부 확인 const existingTag = await tx .select() .from(tags) .where(eq(tags.id, formData.id)) .limit(1) if (existingTag.length === 0) { return { error: "태그를 찾을 수 없습니다." } } const originalTag = existingTag[0] // 2) 선택된 contractItem의 contractId 가져오기 const contractItemResult = await tx .select({ contractId: contractItems.contractId, projectId: contracts.projectId // projectId 추가 }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 .where(eq(contractItems.id, selectedPackageId)) .limit(1) if (contractItemResult.length === 0) { return { error: "Contract item not found" } } const contractId = contractItemResult[0].contractId const projectId = contractItemResult[0].projectId // 3) 태그 번호가 변경되었고, 해당 계약 내에서 같은 tagNo를 가진 다른 태그가 있는지 확인 if (originalTag.tagNo !== validated.data.tagNo) { const duplicateCheck = await tx .select({ count: sql`count(*)` }) .from(tags) .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) .where( and( eq(contractItems.contractId, contractId), eq(tags.tagNo, validated.data.tagNo), ne(tags.id, formData.id) // 자기 자신은 제외 ) ) if (duplicateCheck[0].count > 0) { return { error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, } } } // 4) 태그 타입이나 클래스가 변경되었는지 확인 const isTagTypeOrClassChanged = originalTag.tagType !== validated.data.tagType || originalTag.class !== validated.data.class let primaryFormId = originalTag.formId // 태그 타입이나 클래스가 변경되었다면 연관된 폼 업데이트 if (isTagTypeOrClassChanged) { // 4-1) 태그 타입에 따른 폼 정보 가져오기 const formMappings = await getFormMappingsByTagType( validated.data.tagType, projectId, // projectId 전달 validated.data.class ) // 폼 매핑이 없으면 로그만 남기고 진행 if (!formMappings || formMappings.length === 0) { console.log( "No form mappings found for tag type:", validated.data.tagType, "in project:", projectId ) } // 4-2) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 const createdOrExistingForms: CreatedOrExistingForm[] = [] if (formMappings && formMappings.length > 0) { for (const formMapping of formMappings) { // 이미 존재하는 폼인지 확인 const existingForm = await tx .select({ id: forms.id }) .from(forms) .where( and( eq(forms.contractItemId, selectedPackageId), eq(forms.formCode, formMapping.formCode) ) ) .limit(1) let formId: number if (existingForm.length > 0) { // 이미 존재하면 해당 ID 사용 formId = existingForm[0].id createdOrExistingForms.push({ id: formId, formCode: formMapping.formCode, formName: formMapping.formName, isNewlyCreated: false, }) } else { // 존재하지 않으면 새로 생성 const insertResult = await tx .insert(forms) .values({ contractItemId: selectedPackageId, formCode: formMapping.formCode, formName: formMapping.formName, }) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) formId = insertResult[0].id createdOrExistingForms.push({ id: formId, formCode: insertResult[0].formCode, formName: insertResult[0].formName, isNewlyCreated: true, }) } // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 업데이트 시 사용 if (createdOrExistingForms.length === 1) { primaryFormId = formId } } } } // 5) 태그 업데이트 const [updatedTag] = await tx .update(tags) .set({ contractItemId: selectedPackageId, formId: primaryFormId, tagNo: validated.data.tagNo, class: validated.data.class, tagType: validated.data.tagType, description: validated.data.description ?? null, updatedAt: new Date(), }) .where(eq(tags.id, formData.id)) .returning() // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}`) revalidateTag("tags") // 7) 성공 시 반환 return { success: true, data: { tag: updatedTag, formUpdated: isTagTypeOrClassChanged }, } }) } catch (err: any) { console.error("updateTag error:", err) return { error: getErrorMessage(err) } } } export interface TagInputData { tagNo: string; class: string; tagType: string; description?: string | null; formId?: number | null; [key: string]: any; } // 새로운 서버 액션 export async function bulkCreateTags( tagsfromExcel: TagInputData[], selectedPackageId: number ) { unstable_noStore(); if (!tagsfromExcel.length) { return { error: "No tags provided" }; } try { // 단일 트랜잭션으로 모든 작업 처리 return await db.transaction(async (tx) => { // 1. 컨트랙트 ID 및 프로젝트 ID 조회 (한 번만) const contractItemResult = await tx .select({ contractId: contractItems.contractId, projectId: contracts.projectId // projectId 추가 }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 .where(eq(contractItems.id, selectedPackageId)) .limit(1); if (contractItemResult.length === 0) { return { error: "Contract item not found" }; } const contractId = contractItemResult[0].contractId; const projectId = contractItemResult[0].projectId; // projectId 추출 // 2. 모든 태그 번호 중복 검사 (한 번에) const tagNos = tagsfromExcel.map(tag => tag.tagNo); const duplicateCheck = await tx .select({ tagNo: tags.tagNo }) .from(tags) .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) .where(and( eq(contractItems.contractId, contractId), inArray(tags.tagNo, tagNos) )); if (duplicateCheck.length > 0) { return { error: `태그 번호 "${duplicateCheck.map(d => d.tagNo).join(', ')}"는 이미 존재합니다.` }; } // 3. 태그별 폼 정보 처리 및 태그 생성 const createdTags = []; const allFormsInfo = []; // 모든 태그에 대한 폼 정보 저장 // 태그 유형별 폼 매핑 캐싱 (성능 최적화) const formMappingsCache = new Map(); // formEntries 업데이트를 위한 맵 (formCode -> 태그 데이터 배열) const tagsByFormCode = new Map>(); for (const tagData of tagsfromExcel) { // 캐시 키 생성 (tagType + class) const cacheKey = `${tagData.tagType}|${tagData.class || 'NONE'}`; // 폼 매핑 가져오기 (캐시 사용) let formMappings; if (formMappingsCache.has(cacheKey)) { formMappings = formMappingsCache.get(cacheKey); } else { const tagTypeLabel = await tx .select({ description: tagTypes.description }) .from(tagTypes) .where( and( eq(tagTypes.projectId, projectId), eq(tagTypes.code, tagData.tagType), ) ) .limit(1) const tagTypeLabelText = tagTypeLabel[0].description // 각 태그 유형에 대한 폼 매핑 조회 (projectId 전달) const allFormMappings = await getFormMappingsByTagType( tagTypeLabelText, projectId, // projectId 전달 tagData.class ); // ep가 "IMEP"인 것만 필터링 formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; formMappingsCache.set(cacheKey, formMappings); } // 폼 처리 로직 let primaryFormId: number | null = null; const createdOrExistingForms: CreatedOrExistingForm[] = []; if (formMappings && formMappings.length > 0) { for (const formMapping of formMappings) { // 해당 폼이 이미 존재하는지 확인 const existingForm = await tx .select({ id: forms.id, im: forms.im }) .from(forms) .where( and( eq(forms.contractItemId, selectedPackageId), eq(forms.formCode, formMapping.formCode) ) ) .limit(1); let formId: number; if (existingForm.length > 0) { // 이미 존재하면 해당 ID 사용 formId = existingForm[0].id; // im 필드 업데이트 (필요한 경우) if (existingForm[0].im !== true) { await tx .update(forms) .set({ im: true }) .where(eq(forms.id, formId)); } createdOrExistingForms.push({ id: formId, formCode: formMapping.formCode, formName: formMapping.formName, isNewlyCreated: false, }); } else { // 존재하지 않으면 새로 생성 const insertResult = await tx .insert(forms) .values({ contractItemId: selectedPackageId, formCode: formMapping.formCode, formName: formMapping.formName, im: true }) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); formId = insertResult[0].id; createdOrExistingForms.push({ id: formId, formCode: insertResult[0].formCode, formName: insertResult[0].formName, isNewlyCreated: true, }); } // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용 if (primaryFormId === null) { primaryFormId = formId; } // formEntries 업데이트를 위한 데이터 수집 (tagsfromExcel의 원본 데이터 사용) const newTagEntry = { TAG_NO: tagData.tagNo, TAG_DESC: tagData.description || null, status: "New" // 벌크 생성도 수동 생성으로 분류 }; if (!tagsByFormCode.has(formMapping.formCode)) { tagsByFormCode.set(formMapping.formCode, []); } tagsByFormCode.get(formMapping.formCode)!.push(newTagEntry); } } else { console.log( "No IMEP form mappings found for tag type:", tagData.tagType, "class:", tagData.class || "NONE", "in project:", projectId ); } // 태그 생성 const [newTag] = await insertTag(tx, { contractItemId: selectedPackageId, formId: primaryFormId, tagNo: tagData.tagNo, class: tagData.class || "", tagType: tagData.tagType, description: tagData.description || null, }); createdTags.push(newTag); // 해당 태그의 폼 정보 저장 allFormsInfo.push({ tagNo: tagData.tagNo, forms: createdOrExistingForms, primaryFormId, }); } // 4. formEntries 업데이트 처리 for (const [formCode, newTagsData] of tagsByFormCode.entries()) { try { // 기존 formEntry 가져오기 const existingEntry = await tx.query.formEntries.findFirst({ where: and( eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, selectedPackageId) ) }); if (existingEntry && existingEntry.id) { // 기존 formEntry가 있는 경우 let existingData: Array<{ TAG_NO: string; TAG_DESC?: string | null; status?: string; [key: string]: any; }> = []; if (Array.isArray(existingEntry.data)) { existingData = existingEntry.data; } // 기존 TAG_NO들 추출 const existingTagNos = new Set(existingData.map(item => item.TAG_NO)); // 중복되지 않은 새 태그들만 필터링 const newUniqueTagsData = newTagsData.filter( tagData => !existingTagNos.has(tagData.TAG_NO) ); if (newUniqueTagsData.length > 0) { const updatedData = [...existingData, ...newUniqueTagsData]; await tx .update(formEntries) .set({ data: updatedData, updatedAt: new Date() }) .where(eq(formEntries.id, existingEntry.id)); console.log(`[BULK CREATE] Added ${newUniqueTagsData.length} tags to existing formEntry for form ${formCode}`); } else { console.log(`[BULK CREATE] All tags already exist in formEntry for form ${formCode}`); } } else { // formEntry가 없는 경우 새로 생성 await tx.insert(formEntries).values({ formCode: formCode, contractItemId: selectedPackageId, data: newTagsData, createdAt: new Date(), updatedAt: new Date(), }); console.log(`[BULK CREATE] Created new formEntry with ${newTagsData.length} tags for form ${formCode}`); } } catch (formEntryError) { console.error(`[BULK CREATE] Error updating formEntry for form ${formCode}:`, formEntryError); // 개별 formEntry 에러는 로그만 남기고 전체 트랜잭션은 계속 진행 } } // 5. 캐시 무효화 (한 번만) revalidateTag(`tags-${selectedPackageId}`); revalidateTag(`forms-${selectedPackageId}`); revalidateTag("tags"); // 업데이트된 모든 form의 캐시도 무효화 for (const formCode of tagsByFormCode.keys()) { revalidateTag(`form-data-${formCode}-${selectedPackageId}`); } return { success: true, data: { createdCount: createdTags.length, tags: createdTags, formsInfo: allFormsInfo, formEntriesUpdated: tagsByFormCode.size // 업데이트된 formEntry 수 } }; }); } catch (err: any) { console.error("bulkCreateTags error:", err); return { error: getErrorMessage(err) || "Failed to create tags" }; } } /** 복수 삭제 */ interface RemoveTagsInput { ids: number[]; selectedPackageId: number; } // formEntries의 data JSON에서 tagNo가 일치하는 객체를 제거해주는 예시 함수 function removeTagFromDataJson( dataJson: any, tagNo: string ): any { // data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다. // 예: data 배열 안에 { TAG_NO: string, ... } 형태로 여러 객체가 있다고 가정 if (!Array.isArray(dataJson)) return dataJson return dataJson.filter((entry) => entry.TAG_NO !== tagNo) } export async function removeTags(input: RemoveTagsInput) { unstable_noStore() // React 서버 액션 무상태 함수 const { ids, selectedPackageId } = input try { await db.transaction(async (tx) => { const packageInfo = await tx .select({ projectId: contracts.projectId }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where(eq(contractItems.id, selectedPackageId)) .limit(1); if (packageInfo.length === 0) { throw new Error(`Contract item with ID ${selectedPackageId} not found`); } const projectId = packageInfo[0].projectId; // 1) 삭제 대상 tag들을 미리 조회 const tagsToDelete = await tx .select({ id: tags.id, tagNo: tags.tagNo, tagType: tags.tagType, class: tags.class, }) .from(tags) .where(inArray(tags.id, ids)) // 2) 태그 타입과 클래스의 고유 조합 추출 const uniqueTypeClassCombinations = [...new Set( tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`) )].map(combo => { const [tagType, classValue] = combo.split('|'); return { tagType, class: classValue || undefined }; }); // 3) 각 태그 타입/클래스 조합에 대해 처리 for (const { tagType, class: classValue } of uniqueTypeClassCombinations) { // 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인 const otherTagsWithSameTypeClass = await tx .select({ count: count() }) .from(tags) .where( and( eq(tags.tagType, tagType), classValue ? eq(tags.class, classValue) : isNull(tags.class), not(inArray(tags.id, ids)), // 현재 삭제 중인 태그들은 제외 eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인 ) ) // 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기 const formMappings = await getFormMappingsByTagType(tagType, projectId, classValue); if (!formMappings.length) continue; // 3-3) 이 태그 타입/클래스와 관련된 태그 번호 추출 const relevantTagNos = tagsToDelete .filter(tag => tag.tagType === tagType && (classValue ? tag.class === classValue : !tag.class)) .map(tag => tag.tagNo); // 3-4) 각 폼 코드에 대해 처리 for (const formMapping of formMappings) { // 다른 태그가 없다면 폼 삭제 if (otherTagsWithSameTypeClass[0].count === 0) { // 폼 삭제 await tx .delete(forms) .where( and( eq(forms.contractItemId, selectedPackageId), eq(forms.formCode, formMapping.formCode) ) ) // formEntries 테이블에서도 해당 formCode 관련 데이터 삭제 await tx .delete(formEntries) .where( and( eq(formEntries.contractItemId, selectedPackageId), eq(formEntries.formCode, formMapping.formCode) ) ) } // 다른 태그가 있다면 formEntries 데이터에서 해당 태그 정보만 제거 else if (relevantTagNos.length > 0) { const formEntryRecords = await tx .select({ id: formEntries.id, data: formEntries.data, }) .from(formEntries) .where( and( eq(formEntries.contractItemId, selectedPackageId), eq(formEntries.formCode, formMapping.formCode) ) ) // 각 formEntry에 대해 처리 for (const entry of formEntryRecords) { let updatedJson = entry.data; // 각 tagNo에 대해 JSON 데이터에서 제거 for (const tagNo of relevantTagNos) { updatedJson = removeTagFromDataJson(updatedJson, tagNo); } // 변경이 있다면 업데이트 await tx .update(formEntries) .set({ data: updatedJson }) .where(eq(formEntries.id, entry.id)) } } } } // 4) 마지막으로 tags 테이블에서 태그들 삭제 await tx.delete(tags).where(inArray(tags.id, ids)) }) // 5) 캐시 무효화 revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}`) return { data: null, error: null } } catch (err) { return { data: null, error: getErrorMessage(err) } } } // Updated service functions to support the new schema // 업데이트된 ClassOption 타입 export interface ClassOption { code: string; label: string; tagTypeCode: string; // 클래스와 연결된 태그 타입 코드 tagTypeDescription?: string; // 태그 타입의 설명 (선택적) } /** * Class 옵션 목록을 가져오는 함수 * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함 */ export async function getClassOptions(selectedPackageId: number): Promise { try { // 1. 먼저 contractItems에서 projectId 조회 const packageInfo = await db .select({ projectId: contracts.projectId }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where(eq(contractItems.id, selectedPackageId)) .limit(1); if (packageInfo.length === 0) { throw new Error(`Contract item with ID ${selectedPackageId} not found`); } const projectId = packageInfo[0].projectId; // 2. 태그 클래스들을 서브클래스 정보와 함께 조회 const tagClassesWithSubclasses = await db .select({ id: tagClasses.id, code: tagClasses.code, label: tagClasses.label, tagTypeCode: tagClasses.tagTypeCode, subclasses: tagClasses.subclasses, subclassRemark: tagClasses.subclassRemark, }) .from(tagClasses) .where(eq(tagClasses.projectId, projectId)) .orderBy(tagClasses.code); // 3. 태그 타입 정보도 함께 조회 (description을 위해) const tagTypesMap = new Map(); const tagTypesList = await db .select({ code: tagTypes.code, description: tagTypes.description, }) .from(tagTypes) .where(eq(tagTypes.projectId, projectId)); tagTypesList.forEach(tagType => { tagTypesMap.set(tagType.code, tagType.description); }); // 4. 클래스 옵션으로 변환 const classOptions: UpdatedClassOption[] = tagClassesWithSubclasses.map(cls => ({ value: cls.code, label: cls.label, code: cls.code, description: cls.label, tagTypeCode: cls.tagTypeCode, tagTypeDescription: tagTypesMap.get(cls.tagTypeCode) || cls.tagTypeCode, subclasses: cls.subclasses || [], subclassRemark: cls.subclassRemark || {}, })); return classOptions; } catch (error) { console.error("Error fetching class options with subclasses:", error); throw new Error("Failed to fetch class options"); } } interface SubFieldDef { name: string label: string type: "select" | "text" options: { value: string; label: string }[] expression: string | null delimiter: string | null } export async function getSubfieldsByTagType( tagTypeCode: string, selectedPackageId: number, subclassRemark: string = "", subclass: string = "", ) { try { // 1. 먼저 contractItems에서 projectId 조회 const packageInfo = await db .select({ projectId: contracts.projectId }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where(eq(contractItems.id, selectedPackageId)) .limit(1); if (packageInfo.length === 0) { throw new Error(`Contract item with ID ${selectedPackageId} not found`); } const projectId = packageInfo[0].projectId; // 2. 올바른 projectId를 사용하여 tagSubfields 조회 const rows = await db .select() .from(tagSubfields) .where( and( eq(tagSubfields.tagTypeCode, tagTypeCode), eq(tagSubfields.projectId, projectId) ) ) .orderBy(asc(tagSubfields.sortOrder)); // 각 row -> SubFieldDef const formattedSubFields: SubFieldDef[] = []; for (const sf of rows) { // projectId가 필요한 경우 getSubfieldType과 getSubfieldOptions 함수에도 전달 const subfieldType = await getSubfieldType(sf.attributesId, projectId); const subclassMatched =subclassRemark.includes(sf.attributesId ) ? subclass: null const subfieldOptions = subfieldType === "select" ? await getSubfieldOptions(sf.attributesId, projectId, subclassMatched) // subclassRemark 파라미터 추가 : []; formattedSubFields.push({ name: sf.attributesId.toLowerCase(), label: sf.attributesDescription, type: subfieldType, options: subfieldOptions, expression: sf.expression, delimiter: sf.delimiter, }); } return { subFields: formattedSubFields }; } catch (error) { console.error("Error fetching subfields by tag type:", error); throw new Error("Failed to fetch subfields"); } } async function getSubfieldType(attributesId: string, projectId: number): Promise<"select" | "text"> { const optRows = await db .select() .from(tagSubfieldOptions) .where(and(eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId))) return optRows.length > 0 ? "select" : "text" } export interface SubfieldOption { /** * 옵션의 실제 값 (데이터베이스에 저장될 값) * 예: "PM", "AA", "VB", "01" 등 */ value: string; /** * 옵션의 표시 레이블 (사용자에게 보여질 텍스트) * 예: "Pump", "Pneumatic Motor", "Ball Valve" 등 */ label: string; } /** * SubField의 옵션 목록을 가져오는 보조 함수 */ async function getSubfieldOptions( attributesId: string, projectId: number, subclass: string = "" ): Promise { try { // 1. subclassRemark가 있는 경우 API에서 코드 리스트 가져와서 필터링 if (subclass && subclass.trim() !== "") { // 프로젝트 코드를 projectId로부터 조회 const projectInfo = await db .select({ code: projects.code }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); if (projectInfo.length === 0) { throw new Error(`Project with ID ${projectId} not found`); } const projectCode = projectInfo[0].code; // API에서 코드 리스트 가져오기 const codeListValues = await getCodeListsByID(projectCode); // 서브클래스 리마크 값들을 분리 (쉼표, 공백 등으로 구분) const remarkValues = subclass .split(/[,\s]+/) // 쉼표나 공백으로 분리 .map(val => val.trim()) .filter(val => val.length > 0); if (remarkValues.length > 0) { // REMARK 필드가 remarkValues 중 하나를 포함하고 있는 항목들 필터링 const filteredCodeValues = codeListValues.filter(codeValue => remarkValues.some(remarkValue => // 대소문자 구분 없이 포함 여부 확인 codeValue.VALUE.toLowerCase().includes(remarkValue.toLowerCase()) || remarkValue.toLowerCase().includes(codeValue.VALUE.toLowerCase()) ) ); // 필터링된 결과를 PRNT_VALUE -> value, DESC -> label로 변환 return filteredCodeValues.map((codeValue) => ({ value: codeValue.PRNT_VALUE, label: codeValue.DESC })); } } // 2. subclassRemark가 없는 경우 기존 방식으로 DB에서 조회 const allOptions = await db .select({ code: tagSubfieldOptions.code, label: tagSubfieldOptions.label }) .from(tagSubfieldOptions) .where( and( eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId), ) ); return allOptions.map((row) => ({ value: row.code, label: row.label })); } catch (error) { console.error(`Error fetching filtered options for attribute ${attributesId}:`, error); return []; } } export interface UpdatedClassOption extends ClassOption { tagTypeCode: string tagTypeDescription?: string subclasses: {id: string, desc: string}[] subclassRemark: Record } /** * Tag Type 목록을 가져오는 함수 * 이제 tagTypes 테이블에서 직접 데이터를 가져옴 */ export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> { return unstable_cache( async () => { console.log(`[Server] Fetching tag types from tagTypes table`) try { // 이제 tagSubfields가 아닌 tagTypes 테이블에서 직접 조회 const result = await db .select({ code: tagTypes.code, description: tagTypes.description, }) .from(tagTypes) .orderBy(tagTypes.description); // TagTypeOption 형식으로 변환 const tagTypeOptions: TagTypeOption[] = result.map(item => ({ id: item.code, // id 필드에 code 값 할당 label: item.description, // label 필드에 description 값 할당 })); console.log(`[Server] Found ${tagTypeOptions.length} tag types`) return { options: tagTypeOptions }; } catch (error) { console.error('[Server] Error fetching tag types:', error) return { options: [] } } }, ['tag-types-list'], { revalidate: 3600, // 1시간 캐시 tags: ['tag-types'] } )() } /** * TagTypeOption 인터페이스 정의 */ export interface TagTypeOption { id: string; // tagTypes.code 값 label: string; // tagTypes.description 값 } export async function getProjectIdFromContractItemId(contractItemId: number): Promise { try { // First get the contractId from contractItems const contractItem = await db.query.contractItems.findFirst({ where: eq(contractItems.id, contractItemId), columns: { contractId: true } }); if (!contractItem) return null; // Then get the projectId from contracts const contract = await db.query.contracts.findFirst({ where: eq(contracts.id, contractItem.contractId), columns: { projectId: true } }); return contract?.projectId || null; } catch (error) { console.error("Error fetching projectId:", error); return null; } }