summaryrefslogtreecommitdiff
path: root/lib/forms
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /lib/forms
initial commit
Diffstat (limited to 'lib/forms')
-rw-r--r--lib/forms/services.ts645
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