"use server"; import db from "@/db/db"; import { formMetas, formEntries, tags, tagClasses, tagClassAttributes } from "@/db/schema/vendorData"; import { contractItems } from "@/db/schema/contract"; import { contracts } from "@/db/schema/contract"; import { projects } from "@/db/schema/projects"; import { vendors } from "@/db/schema/vendors"; import { eq, and, desc } from "drizzle-orm"; import type { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; export interface VendorFormCompletionStats { vendorId: number; vendorName: string; contractItemId: number; formCode: string; formName: string; totalRequiredFields: number; totalFilledFields: number; totalEmptyFields: number; completionPercentage: number; tagCount: number; detailsByTag: Array<{ tagNo: string; requiredFields: number; filledFields: number; emptyFields: number; completionPercentage: number; }>; } export interface ProjectVendorCompletionSummary { projectId: number; projectCode: string; projectName: string; vendors: VendorFormCompletionStats[]; totalVendors: number; averageCompletionPercentage: number; } export interface VendorContractCompletionStats { contractId: number; contractItemId: number; projectId: number; projectCode: string; projectName: string; itemCode: string; itemName: string; forms: VendorFormCompletionStats[]; totalForms: number; totalRequiredFields: number; totalFilledFields: number; totalEmptyFields: number; averageCompletionPercentage: number; } export interface VendorAllContractsCompletionSummary { vendorId: number; vendorName: string; contracts: VendorContractCompletionStats[]; totalContracts: number; totalForms: number; totalRequiredFields: number; totalFilledFields: number; totalEmptyFields: number; overallCompletionPercentage: number; projectBreakdown: Array<{ projectId: number; projectCode: string; projectName: string; contractsCount: number; formsCount: number; completionPercentage: number; }>; } /** * 필드가 벤더에 의해 편집 가능한지 확인 * SHI 값이 "BOTH" 또는 "IN"인 경우만 벤더가 편집 가능 */ function isFieldEditableByVendor(column: DataTableColumnJSON): boolean { return column.shi === "BOTH" || column.shi === "IN"; } /** * 특정 태그에 대해 편집 가능한 필드 목록을 가져옴 */ async function getEditableFieldsForTag( tagNo: string, contractItemId: number, projectId: number ): Promise { try { // 1. 해당 태그 정보 조회 const tagResult = await db .select({ tagClass: tags.class }) .from(tags) .where( and( eq(tags.tagNo, tagNo), eq(tags.contractItemId, contractItemId) ) ) .limit(1); if (tagResult.length === 0) { return []; } // 2. tagClasses에서 해당 class와 projectId로 tagClass 찾기 const tagClassResult = await db .select({ id: tagClasses.id }) .from(tagClasses) .where( and( eq(tagClasses.label, tagResult[0].tagClass), eq(tagClasses.projectId, projectId) ) ) .limit(1); if (tagClassResult.length === 0) { return []; } // 3. tagClassAttributes에서 편집 가능한 필드 목록 조회 const editableAttributes = await db .select({ attId: tagClassAttributes.attId }) .from(tagClassAttributes) .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id)) .orderBy(tagClassAttributes.seq); return editableAttributes.map(attr => attr.attId); } catch (error) { console.error(`Error getting editable fields for tag ${tagNo}:`, error); return []; } } /** * 값이 "빈" 값인지 확인 */ function isEmptyValue(value: unknown): boolean { if (value === null || value === undefined) return true; if (typeof value === 'string') return value.trim() === ''; if (typeof value === 'number') return isNaN(value); return false; } /** * 특정 contract item의 form에 대한 벤더 입력 완성도 계산 */ export async function calculateVendorFormCompletion( contractItemId: number, formCode: string ): Promise { try { // 1. Contract Item 정보 및 Vendor 정보 조회 const contractInfo = await db .select({ projectId: projects.id, projectCode: projects.code, projectName: projects.name, vendorId: vendors.id, vendorName: vendors.vendorName, contractId: contracts.id }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .innerJoin(projects, eq(contracts.projectId, projects.id)) .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) .where(eq(contractItems.id, contractItemId)) .limit(1); if (contractInfo.length === 0) { console.warn(`No contract item found with ID: ${contractItemId}`); return null; } const { projectId, vendorId, vendorName } = contractInfo[0]; // 2. Form 메타데이터 조회 (컬럼 정의) const metaRows = await db .select() .from(formMetas) .where( and( eq(formMetas.formCode, formCode), eq(formMetas.projectId, projectId) ) ) .orderBy(desc(formMetas.updatedAt)) .limit(1); const meta = metaRows[0]; if (!meta) { console.warn(`No form meta found for formCode: ${formCode} and projectId: ${projectId}`); return null; } // 3. Form 실제 데이터 조회 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]; if (!entry || !Array.isArray(entry.data)) { console.warn(`No form data found for formCode: ${formCode} and contractItemId: ${contractItemId}`); return null; } // 4. 컬럼 정의에서 벤더가 편집 가능한 필드 필터링 const columns = meta.columns as DataTableColumnJSON[]; const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO', 'status', 'TAG_NO', 'TAG_DESC']; const editableColumns = columns.filter(col => !excludeKeys.includes(col.key) && isFieldEditableByVendor(col) ); // 5. 각 태그별로 완성도 계산 const detailsByTag: VendorFormCompletionStats['detailsByTag'] = []; let totalRequiredFields = 0; let totalFilledFields = 0; const formData = entry.data as Array>; for (const rowData of formData) { const tagNo = rowData.TAG_NO as string; if (!tagNo) continue; // 이 태그에 대해 실제로 편집 가능한 필드 목록 가져오기 const tagEditableFields = await getEditableFieldsForTag(tagNo, contractItemId, projectId); // 컬럼 정의와 태그별 편집 가능 필드를 교집합으로 구해서 실제 편집 가능한 필드 확정 const actualEditableFields = editableColumns.filter(col => tagEditableFields.includes(col.key) ); const requiredFieldsCount = actualEditableFields.length; let filledFieldsCount = 0; // 각 편집 가능한 필드의 값 확인 for (const column of actualEditableFields) { const value = rowData[column.key]; if (!isEmptyValue(value)) { filledFieldsCount++; } } const emptyFieldsCount = requiredFieldsCount - filledFieldsCount; const completionPercentage = requiredFieldsCount > 0 ? Math.round((filledFieldsCount / requiredFieldsCount) * 100) : 100; detailsByTag.push({ tagNo: tagNo as string, requiredFields: requiredFieldsCount, filledFields: filledFieldsCount, emptyFields: emptyFieldsCount, completionPercentage }); totalRequiredFields += requiredFieldsCount; totalFilledFields += filledFieldsCount; } const totalEmptyFields = totalRequiredFields - totalFilledFields; const overallCompletionPercentage = totalRequiredFields > 0 ? Math.round((totalFilledFields / totalRequiredFields) * 100) : 100; return { vendorId, vendorName, contractItemId, formCode, formName: meta.formName, totalRequiredFields, totalFilledFields, totalEmptyFields, completionPercentage: overallCompletionPercentage, tagCount: formData.length, detailsByTag }; } catch (error) { console.error(`Error calculating vendor form completion:`, error); return null; } } /** * 프로젝트의 모든 벤더들에 대한 특정 form의 입력 완성도 요약 */ export async function getProjectVendorCompletionSummary( projectId: number, formCode: string ): Promise { try { // 1. 프로젝트 정보 조회 const projectInfo = await db .select({ id: projects.id, code: projects.code, name: projects.name }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); if (projectInfo.length === 0) { console.warn(`No project found with ID: ${projectId}`); return null; } const project = projectInfo[0]; // 2. 해당 프로젝트의 모든 contract items 조회 (formCode와 연관된) const contractItemsInfo = await db .select({ contractItemId: contractItems.id, vendorId: vendors.id, vendorName: vendors.vendorName }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) .innerJoin(formEntries, and( eq(formEntries.contractItemId, contractItems.id), eq(formEntries.formCode, formCode) )) .where(eq(contracts.projectId, projectId)); // 3. 각 contract item별로 완성도 계산 const vendorStats: VendorFormCompletionStats[] = []; for (const item of contractItemsInfo) { const stats = await calculateVendorFormCompletion( item.contractItemId, formCode ); if (stats) { vendorStats.push(stats); } } // 4. 전체 평균 완성도 계산 const averageCompletionPercentage = vendorStats.length > 0 ? Math.round( vendorStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / vendorStats.length ) : 0; return { projectId: project.id, projectCode: project.code, projectName: project.name, vendors: vendorStats, totalVendors: vendorStats.length, averageCompletionPercentage }; } catch (error) { console.error(`Error getting project vendor completion summary:`, error); return null; } } /** * 특정 벤더의 특정 계약(contract item)에 대한 모든 form 완성도 계산 */ export async function calculateVendorContractCompletion( vendorId: number, contractItemId: number ): Promise { try { // 1. Contract Item 정보 조회 const contractItemInfo = await db .select({ contractId: contracts.id, contractItemId: contractItems.id, projectId: projects.id, projectCode: projects.code, projectName: projects.name, itemId: contractItems.itemId, description: contractItems.description, vendorId: vendors.id, vendorName: vendors.vendorName }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .innerJoin(projects, eq(contracts.projectId, projects.id)) .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) .where( and( eq(contractItems.id, contractItemId), eq(vendors.id, vendorId) ) ) .limit(1); if (contractItemInfo.length === 0) { console.warn(`No contract item found for vendorId: ${vendorId}, contractItemId: ${contractItemId}`); return null; } const contractInfo = contractItemInfo[0]; // 2. 해당 contract item과 연관된 모든 form codes 조회 const formCodes = await db .selectDistinct({ formCode: formEntries.formCode }) .from(formEntries) .where(eq(formEntries.contractItemId, contractItemId)); // 3. 각 form에 대한 완성도 계산 const formStats: VendorFormCompletionStats[] = []; let totalRequiredFields = 0; let totalFilledFields = 0; for (const { formCode } of formCodes) { const formCompletion = await calculateVendorFormCompletion(contractItemId, formCode); if (formCompletion) { formStats.push(formCompletion); totalRequiredFields += formCompletion.totalRequiredFields; totalFilledFields += formCompletion.totalFilledFields; } } const totalEmptyFields = totalRequiredFields - totalFilledFields; const averageCompletionPercentage = formStats.length > 0 ? Math.round( formStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / formStats.length ) : 0; return { contractId: contractInfo.contractId, contractItemId: contractInfo.contractItemId, projectId: contractInfo.projectId, projectCode: contractInfo.projectCode, projectName: contractInfo.projectName, itemCode: contractInfo.itemId?.toString() || '', itemName: contractInfo.description || '', forms: formStats, totalForms: formStats.length, totalRequiredFields, totalFilledFields, totalEmptyFields, averageCompletionPercentage }; } catch (error) { console.error(`Error calculating vendor contract completion:`, error); return null; } } /** * 특정 벤더가 보유한 모든 계약에 대한 입력 완성도 요약 */ export async function getVendorAllContractsCompletionSummary( vendorId: number ): Promise { try { // 1. 벤더 정보 조회 const vendorInfo = await db .select({ id: vendors.id, vendorName: vendors.vendorName }) .from(vendors) .where(eq(vendors.id, vendorId)) .limit(1); if (vendorInfo.length === 0) { console.warn(`No vendor found with ID: ${vendorId}`); return null; } const vendor = vendorInfo[0]; // 2. 해당 벤더의 모든 contract items 조회 const contractItemsInfo = await db .select({ contractId: contracts.id, contractItemId: contractItems.id, projectId: projects.id, projectCode: projects.code, projectName: projects.name, itemId: contractItems.itemId, description: contractItems.description }) .from(contractItems) .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .innerJoin(projects, eq(contracts.projectId, projects.id)) .where(eq(contracts.vendorId, vendorId)); // 3. 각 contract item별로 완성도 계산 const contractStats: VendorContractCompletionStats[] = []; for (const item of contractItemsInfo) { const contractCompletion = await calculateVendorContractCompletion( vendorId, item.contractItemId ); if (contractCompletion) { contractStats.push(contractCompletion); } } // 4. 전체 통계 계산 const totalRequiredFields = contractStats.reduce((sum, stat) => sum + stat.totalRequiredFields, 0); const totalFilledFields = contractStats.reduce((sum, stat) => sum + stat.totalFilledFields, 0); const totalEmptyFields = totalRequiredFields - totalFilledFields; const totalForms = contractStats.reduce((sum, stat) => sum + stat.totalForms, 0); const overallCompletionPercentage = totalRequiredFields > 0 ? Math.round((totalFilledFields / totalRequiredFields) * 100) : 100; // 5. 프로젝트별 요약 계산 const projectMap = new Map(); contractStats.forEach(contract => { const key = contract.projectId; if (!projectMap.has(key)) { projectMap.set(key, { projectId: contract.projectId, projectCode: contract.projectCode, projectName: contract.projectName, contracts: [] }); } projectMap.get(key)!.contracts.push(contract); }); const projectBreakdown = Array.from(projectMap.values()).map(project => { const projectTotalRequired = project.contracts.reduce((sum, c) => sum + c.totalRequiredFields, 0); const projectTotalFilled = project.contracts.reduce((sum, c) => sum + c.totalFilledFields, 0); const projectCompletionPercentage = projectTotalRequired > 0 ? Math.round((projectTotalFilled / projectTotalRequired) * 100) : 100; return { projectId: project.projectId, projectCode: project.projectCode, projectName: project.projectName, contractsCount: project.contracts.length, formsCount: project.contracts.reduce((sum, c) => sum + c.totalForms, 0), completionPercentage: projectCompletionPercentage }; }); return { vendorId: vendor.id, vendorName: vendor.vendorName, contracts: contractStats, totalContracts: contractStats.length, totalForms, totalRequiredFields, totalFilledFields, totalEmptyFields, overallCompletionPercentage, projectBreakdown }; } catch (error) { console.error(`Error getting vendor all contracts completion summary:`, error); return null; } } /** * 모든 프로젝트의 모든 form에 대한 벤더 완성도 요약 (관리자용) */ export async function getAllProjectsVendorCompletionSummary(): Promise<{ projects: ProjectVendorCompletionSummary[]; totalProjects: number; overallAverageCompletion: number; }> { try { // 1. 모든 프로젝트 조회 const allProjects = await db .select({ id: projects.id, code: projects.code, name: projects.name }) .from(projects); // 2. 각 프로젝트별로 form들의 완성도 조회 const projectSummaries: ProjectVendorCompletionSummary[] = []; for (const project of allProjects) { // 해당 프로젝트의 모든 form codes 조회 const formCodes = await db .selectDistinct({ formCode: formMetas.formCode }) .from(formMetas) .where(eq(formMetas.projectId, project.id)); // 각 form에 대한 완성도 조회 후 통합 const allVendorStats: VendorFormCompletionStats[] = []; for (const { formCode } of formCodes) { const summary = await getProjectVendorCompletionSummary(project.id, formCode); if (summary) { allVendorStats.push(...summary.vendors); } } if (allVendorStats.length > 0) { const averageCompletion = Math.round( allVendorStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / allVendorStats.length ); projectSummaries.push({ projectId: project.id, projectCode: project.code, projectName: project.name, vendors: allVendorStats, totalVendors: allVendorStats.length, averageCompletionPercentage: averageCompletion }); } } // 3. 전체 평균 계산 const overallAverageCompletion = projectSummaries.length > 0 ? Math.round( projectSummaries.reduce((sum, proj) => sum + proj.averageCompletionPercentage, 0) / projectSummaries.length ) : 0; return { projects: projectSummaries, totalProjects: projectSummaries.length, overallAverageCompletion }; } catch (error) { console.error(`Error getting all projects vendor completion summary:`, error); return { projects: [], totalProjects: 0, overallAverageCompletion: 0 }; } } /** * 모든 벤더들의 전체 계약 완성도 요약 (관리자용) */ export async function getAllVendorsContractsCompletionSummary(): Promise<{ vendors: VendorAllContractsCompletionSummary[]; totalVendors: number; overallAverageCompletion: number; topPerformingVendors: Array<{ vendorId: number; vendorName: string; completionPercentage: number; }>; lowPerformingVendors: Array<{ vendorId: number; vendorName: string; completionPercentage: number; }>; }> { try { // 1. 계약이 있는 모든 벤더 조회 const vendorsWithContracts = await db .selectDistinct({ vendorId: vendors.id, vendorName: vendors.vendorName }) .from(vendors) .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)); // 2. 각 벤더별로 완성도 계산 const vendorSummaries: VendorAllContractsCompletionSummary[] = []; for (const vendor of vendorsWithContracts) { const summary = await getVendorAllContractsCompletionSummary(vendor.vendorId); if (summary) { vendorSummaries.push(summary); } } // 3. 전체 평균 계산 const overallAverageCompletion = vendorSummaries.length > 0 ? Math.round( vendorSummaries.reduce((sum, vendor) => sum + vendor.overallCompletionPercentage, 0) / vendorSummaries.length ) : 0; // 4. 상위/하위 성과 벤더 추출 (상위 5개, 하위 5개) const sortedVendors = [...vendorSummaries].sort((a, b) => b.overallCompletionPercentage - a.overallCompletionPercentage); const topPerformingVendors = sortedVendors.slice(0, 5).map(vendor => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, completionPercentage: vendor.overallCompletionPercentage })); const lowPerformingVendors = sortedVendors.slice(-5).reverse().map(vendor => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, completionPercentage: vendor.overallCompletionPercentage })); return { vendors: vendorSummaries, totalVendors: vendorSummaries.length, overallAverageCompletion, topPerformingVendors, lowPerformingVendors }; } catch (error) { console.error(`Error getting all vendors contracts completion summary:`, error); return { vendors: [], totalVendors: 0, overallAverageCompletion: 0, topPerformingVendors: [], lowPerformingVendors: [] }; } }