diff options
Diffstat (limited to 'lib/forms/services.ts')
| -rw-r--r-- | lib/forms/services.ts | 645 |
1 files changed, 645 insertions, 0 deletions
diff --git a/lib/forms/services.ts b/lib/forms/services.ts new file mode 100644 index 00000000..e5fc8666 --- /dev/null +++ b/lib/forms/services.ts @@ -0,0 +1,645 @@ +// lib/forms/services.ts +"use server" + +import db from "@/db/db"; +import { formEntries, formMetas, forms, tags, tagTypeClassFormMappings } from "@/db/schema/vendorData" +import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm" +import { unstable_cache } from "next/cache" +import { revalidateTag } from "next/cache" +import { getErrorMessage } from "../handle-error"; +import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; + +export interface FormInfo { + id: number + formCode: string + formName: string + // tagType: string +} + +export async function getFormsByContractItemId(contractItemId: number | null) { + // 유효성 검사 + if (!contractItemId || contractItemId <= 0) { + console.warn(`Invalid contractItemId: ${contractItemId}`); + return { forms: [] }; + } + + // 고유 캐시 키 + const cacheKey = `forms-${contractItemId}`; + + try { + return unstable_cache( + async () => { + console.log(`[Forms Service] Fetching forms for contractItemId: ${contractItemId}`); + + try { + // 데이터베이스에서 폼 조회 + const formRecords = await db + .select({ + id: forms.id, + formCode: forms.formCode, + formName: forms.formName, + // tagType: forms.tagType, + }) + .from(forms) + .where(eq(forms.contractItemId, contractItemId)); + + console.log(`[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`); + + // 결과가 배열인지 확인 + if (!Array.isArray(formRecords)) { + getErrorMessage(`Unexpected result format for contractItemId ${contractItemId} ${formRecords}`); + return { forms: [] }; + } + + return { forms: formRecords }; + } catch (error) { + getErrorMessage(`Database error for contractItemId ${contractItemId}: ${error}`); + throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 + } + }, + [cacheKey], + { + // 캐시 시간 단축 + revalidate: 60, // 1분으로 줄임 + tags: [cacheKey] + } + )(); + } catch (error) { + getErrorMessage(`Cache operation failed for contractItemId ${contractItemId}: ${error}`); + + // 캐시 문제 시 직접 쿼리 시도 + try { + console.log(`[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`); + + const formRecords = await db + .select({ + id: forms.id, + formCode: forms.formCode, + formName: forms.formName, + // tagType: forms.tagType, + }) + .from(forms) + .where(eq(forms.contractItemId, contractItemId)); + + return { forms: formRecords }; + } catch (dbError) { + getErrorMessage(`Fallback query failed for contractItemId ${contractItemId}:${dbError}`); + return { forms: [] }; + } + } +} + +/** + * 폼 캐시를 갱신하는 서버 액션 + */ +export async function revalidateForms(contractItemId: number) { + if (!contractItemId) return; + + const cacheKey = `forms-${contractItemId}`; + console.log(`[Forms Service] Invalidating cache for ${cacheKey}`); + + try { + revalidateTag(cacheKey); + console.log(`[Forms Service] Cache invalidated for ${cacheKey}`); + } catch (error) { + getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`); + } +} + +/** + * "가장 최신 1개 row"를 가져오고, + * data가 배열이면 그 배열을 반환, + * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. + */ +export async function getFormData(formCode: string, contractItemId: number) { + // 고유 캐시 키 (formCode + contractItemId) + const cacheKey = `form-data-${formCode}-${contractItemId}` + + try { + // 1) unstable_cache로 전체 로직을 감싼다 + const result = await unstable_cache( + async () => { + // --- 기존 로직 시작 --- + // (1) form_metas 조회 (가정상 1개만 존재) + const metaRows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .orderBy(desc(formMetas.updatedAt)) + .limit(1) + + const meta = metaRows[0] ?? null + if (!meta) { + return { columns: null, data: [] } + } + // (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행 + const entryRows = await db + .select() + .from(formEntries) + .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId))) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + const entry = entryRows[0] ?? null + + // columns: DB에 저장된 JSON (DataTableColumnJSON[]) + const columns = meta.columns as DataTableColumnJSON[] + + columns.forEach((col) => { + // 이미 displayLabel이 있으면 그대로 두고, + // 없으면 uom이 있으면 "label (uom)" 형태, + // 둘 다 없으면 label만 쓴다. + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})` + } else { + col.displayLabel = col.label + } + } + }) + + // data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열 + let data: Array<Record<string, any>> = [] + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data + } else { + console.warn("formEntries data was not an array. Using empty array.") + } + } + + return { columns, data } + // --- 기존 로직 끝 --- + }, + [cacheKey], // 캐시 키 의존성 + { + revalidate: 60, // 1분 캐시 + tags: [cacheKey], // 캐시 태그 + } + )() + + return result + } catch (cacheError) { + console.error(`[getFormData] Cache operation failed:`, cacheError) + + // --- fallback: 캐시 문제 시 직접 쿼리 시도 --- + try { + console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`) + + // (1) form_metas + const metaRows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .orderBy(desc(formMetas.updatedAt)) + .limit(1) + + const meta = metaRows[0] ?? null + if (!meta) { + return { columns: null, data: [] } + } + + // (2) form_entries + const entryRows = await db + .select() + .from(formEntries) + .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId))) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + const entry = entryRows[0] ?? null + + const columns = meta.columns as DataTableColumnJSON[] + + columns.forEach((col) => { + // 이미 displayLabel이 있으면 그대로 두고, + // 없으면 uom이 있으면 "label (uom)" 형태, + // 둘 다 없으면 label만 쓴다. + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})` + } else { + col.displayLabel = col.label + } + } + }) + + let data: Array<Record<string, any>> = [] + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data + } else { + console.warn("formEntries data was not an array. Using empty array (fallback).") + } + } + + return { columns, data } + } catch (dbError) { + console.error(`[getFormData] Fallback DB query failed:`, dbError) + return { columns: null, data: [] } + } + } +} + +// export async function syncMissingTags(contractItemId: number, formCode: string) { + + +// // (1) forms 테이블에서 (contractItemId, formCode) 찾기 +// const [formRow] = await db +// .select() +// .from(forms) +// .where(and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode))) +// .limit(1) + +// if (!formRow) { +// throw new Error(`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`) +// } + +// const { tagType, class: className } = formRow + +// // (2) tags 테이블에서 (contractItemId, tagType, class)인 태그 찾기 +// const tagRows = await db +// .select() +// .from(tags) +// .where( +// and( +// eq(tags.contractItemId, contractItemId), +// eq(tags.tagType, tagType), +// eq(tags.class, className), +// ) +// ) + +// if (tagRows.length === 0) { +// console.log("No matching tags found.") +// return { createdCount: 0 } +// } + +// // (3) formEntries에서 (contractItemId, formCode)인 row 1개 조회 +// let [entry] = await db +// .select() +// .from(formEntries) +// .where( +// and( +// eq(formEntries.contractItemId, contractItemId), +// eq(formEntries.formCode, formCode) +// ) +// ) +// .limit(1) + +// // (4) 만약 없다면 새로 insert: data = [] +// if (!entry) { +// const [inserted] = await db.insert(formEntries).values({ +// contractItemId, +// formCode, +// data: [], // 초기 상태는 빈 배열 +// }).returning() +// entry = inserted +// } + +// // entry.data는 배열이라고 가정 +// // Drizzle에서 jsonb는 JS object로 파싱되어 들어오므로, 타입 캐스팅 +// const existingData = entry.data as Array<{ tagNumber: string }> +// let createdCount = 0 + +// // (5) tagRows 각각에 대해, 이미 배열에 존재하는지 확인 후 없으면 push +// const updatedArray = [...existingData] +// for (const tagRow of tagRows) { +// const tagNo = tagRow.tagNo +// const found = updatedArray.some(item => item.tagNumber === tagNo) +// if (!found) { +// updatedArray.push({ tagNumber: tagNo }) +// createdCount++ +// } +// } + +// // (6) 변경이 있으면 UPDATE +// if (createdCount > 0) { +// await db +// .update(formEntries) +// .set({ data: updatedArray }) +// .where(eq(formEntries.id, entry.id)) +// } + + +// revalidateTag(`form-data-${formCode}-${contractItemId}`); + +// return { createdCount } +// } + +export async function syncMissingTags(contractItemId: number, formCode: string) { + // (1) Ensure there's a row in `forms` matching (contractItemId, formCode). + const [formRow] = await db + .select() + .from(forms) + .where( + and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode)) + ) + .limit(1) + + if (!formRow) { + throw new Error( + `Form not found for contractItemId=${contractItemId}, formCode=${formCode}` + ) + } + + // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode. + const formMappings = await db + .select() + .from(tagTypeClassFormMappings) + .where(eq(tagTypeClassFormMappings.formCode, formCode)) + + // If no mappings are found, there's nothing to sync. + if (formMappings.length === 0) { + console.log(`No mappings found for formCode=${formCode}`) + return { createdCount: 0, updatedCount: 0, deletedCount: 0 } + } + + // Build a dynamic OR clause to match (tagType, class) pairs from the mappings. + const orConditions = formMappings.map((m) => + and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel)) + ) + + // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs. + const tagRows = await db + .select() + .from(tags) + .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions))) + + // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode). + let [entry] = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, contractItemId), + eq(formEntries.formCode, formCode) + ) + ) + .limit(1) + + if (!entry) { + const [inserted] = await db + .insert(formEntries) + .values({ + contractItemId, + formCode, + data: [], // Initialize with empty array + }) + .returning() + entry = inserted + } + + // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정 + const existingData = entry.data as Array<{ + tagNumber: string + tagDescription?: string + }> + + // Create a Set of valid tagNumbers from tagRows for efficient lookup + const validTagNumbers = new Set(tagRows.map(tag => tag.tagNo)) + + // Copy existing data to work with + let updatedData: Array<{ + tagNumber: string + tagDescription?: string + }> = [] + + let createdCount = 0 + let updatedCount = 0 + let deletedCount = 0 + + // First, filter out items that should be deleted (not in validTagNumbers) + for (const item of existingData) { + if (validTagNumbers.has(item.tagNumber)) { + updatedData.push(item) + } else { + deletedCount++ + } + } + + // (5) For each tagRow, if it's missing in updatedData, push it in. + // 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음. + for (const tagRow of tagRows) { + const { tagNo, description } = tagRow + + // 5-1. 기존 데이터에서 tagNumber 매칭 + const existingIndex = updatedData.findIndex( + (item) => item.tagNumber === tagNo + ) + + // 5-2. 없다면 새로 추가 + if (existingIndex === -1) { + updatedData.push({ + tagNumber: tagNo, + tagDescription: description ?? "", + }) + createdCount++ + } else { + // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항) + const existingItem = updatedData[existingIndex] + if (existingItem.tagDescription !== description) { + updatedData[existingIndex] = { + ...existingItem, + tagDescription: description ?? "", + } + updatedCount++ + } + } + } + + // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영 + if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) { + await db + .update(formEntries) + .set({ data: updatedData }) + .where(eq(formEntries.id, entry.id)) + } + + // 캐시 무효화 등 후처리 + revalidateTag(`form-data-${formCode}-${contractItemId}`) + + return { createdCount, updatedCount, deletedCount } +} + +/** + * updateFormDataInDB: + * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와, + * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트 + * 업데이트 후, revalidateTag()로 캐시 무효화. + */ +type UpdateResponse = { + success: boolean + message: string + data?: any +} + +export async function updateFormDataInDB( + formCode: string, + contractItemId: number, + newData: Record<string, any> +): Promise<UpdateResponse> { + try { + // 1) tagNumber로 식별 + const tagNumber = newData.tagNumber + if (!tagNumber) { + return { + success: false, + message: "tagNumber는 필수 항목입니다." + } + } + + // 2) row 찾기 (단 하나) + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1) + + if (!entries || entries.length === 0) { + return { + success: false, + message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})` + } + } + + const entry = entries[0] + + // 3) data가 배열인지 확인 + if (!entry.data) { + return { + success: false, + message: "폼 데이터가 없습니다." + } + } + + const dataArray = entry.data as Array<Record<string, any>> + if (!Array.isArray(dataArray)) { + return { + success: false, + message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다." + } + } + + // 4) tagNumber = newData.tagNumber 항목 찾기 + const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber) + if (idx < 0) { + return { + success: false, + message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.` + } + } + + // 5) 병합 + const oldItem = dataArray[idx] + const updatedItem = { + ...oldItem, + ...newData, + tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지 + } + + const updatedArray = [...dataArray] + updatedArray[idx] = updatedItem + + // 6) DB UPDATE + try { + await db + .update(formEntries) + .set({ + data: updatedArray, + updatedAt: new Date() // 업데이트 시간도 갱신 + }) + .where(eq(formEntries.id, entry.id)) + } catch (dbError) { + console.error("Database update error:", dbError) + + if (dbError instanceof DrizzleError) { + return { + success: false, + message: `데이터베이스 업데이트 오류: ${dbError.message}` + } + } + + return { + success: false, + message: "데이터베이스 업데이트 중 오류가 발생했습니다." + } + } + + // 7) Cache 무효화 + try { + // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 + const cacheTag = `form-data-${formCode}-${contractItemId}` + revalidateTag(cacheTag) + } catch (cacheError) { + console.warn("Cache revalidation warning:", cacheError) + // 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김 + } + + return { + success: true, + message: '데이터가 성공적으로 업데이트되었습니다.', + data: { + tagNumber, + updatedFields: Object.keys(newData).filter(key => key !== 'tagNumber') + } + } + } catch (error) { + // 예상치 못한 오류 처리 + console.error("Unexpected error in updateFormDataInDB:", error) + return { + success: false, + message: error instanceof Error + ? `예상치 못한 오류가 발생했습니다: ${error.message}` + : "알 수 없는 오류가 발생했습니다." + } + } +} + +// FormColumn Type (동일) +export interface FormColumn { + key: string + type: string + label: string + options?: string[] +} + +interface MetadataResult { + formName: string + formCode: string + columns: FormColumn[] +} + +/** + * 서버 액션: + * 주어진 formCode에 해당하는 form_metas 레코드 1개를 찾아서 + * { formName, formCode, columns } 형태로 반환. + * 없으면 null. + */ +export async function fetchFormMetadata(formCode: string): Promise<MetadataResult | null> { + try { + // 기존 방식: select().from().where() + const rows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .limit(1) + + // rows는 배열 + const metaData = rows[0] + if (!metaData) return null + + return { + formCode: metaData.formCode, + formName: metaData.formName, + columns: metaData.columns as FormColumn[] + } + } catch (err) { + console.error("Error in fetchFormMetadata:", err) + return null + } +}
\ No newline at end of file |
