diff options
Diffstat (limited to 'lib/forms/vendor-completion-stats.ts')
| -rw-r--r-- | lib/forms/vendor-completion-stats.ts | 755 |
1 files changed, 755 insertions, 0 deletions
diff --git a/lib/forms/vendor-completion-stats.ts b/lib/forms/vendor-completion-stats.ts new file mode 100644 index 00000000..db2d376d --- /dev/null +++ b/lib/forms/vendor-completion-stats.ts @@ -0,0 +1,755 @@ +"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<string[]> { + 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<VendorFormCompletionStats | null> { + 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<Record<string, unknown>>; + + 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<ProjectVendorCompletionSummary | null> { + 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<VendorContractCompletionStats | null> { + 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<VendorAllContractsCompletionSummary | null> { + 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<number, { + projectId: number; + projectCode: string; + projectName: string; + contracts: VendorContractCompletionStats[]; + }>(); + + 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: [] + }; + } +} |
