summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 10:46:19 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 10:46:19 +0000
commit13dc007de652ce3da2a5e85d2cdccafe2288dea9 (patch)
tree37ed49bbb531adcb27aab93125efc249b2ce38be
parentb67e36df49f067cbd5ba899f9fbcc755f38d4b4f (diff)
(임수민) EDP 벤더별 진척도 페이지 구현
- menu 작업 - 오류수정
-rw-r--r--app/[lng]/evcp/(evcp)/edp-progress/page.tsx50
-rw-r--r--config/menuConfig.ts6
-rw-r--r--i18n/locales/en/menu.json4
-rw-r--r--i18n/locales/ko/menu.json4
-rw-r--r--lib/edp-progress/service.ts82
-rw-r--r--lib/edp-progress/table/edp-progress-table-columns.tsx108
-rw-r--r--lib/edp-progress/table/edp-progress-table-toolbar-actions.tsx24
-rw-r--r--lib/edp-progress/table/edp-progress-table.tsx61
-rw-r--r--lib/edp-progress/validations.ts28
-rw-r--r--lib/edp-progress/vendor-completion-stats.ts1004
10 files changed, 1369 insertions, 2 deletions
diff --git a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
new file mode 100644
index 00000000..12e14b98
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
@@ -0,0 +1,50 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { EDPProgressTable } from "@/lib/edp-progress/table/edp-progress-table"
+import { getEDPProgressLists } from "@/lib/edp-progress/service"
+import { Shell } from "@/components/shell"
+import { InformationButton } from "@/components/information/information-button"
+import { searchParamsCache } from "@/lib/edp-progress/validations"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getEDPProgressLists({ filters: validFilters, sort: search.sort, search: search.search, joinOperator: search.joinOperator as any }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">벤더 진척도 현황</h2>
+ <InformationButton pagePath="evcp/edp-progress" />
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={0}
+ cellWidths={["16rem", "8rem", "8rem", "8rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <EDPProgressTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 605941a5..0f6a7fd7 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -354,6 +354,12 @@ export const mainNav: MenuSection[] = [
descriptionKey: "menu.engineering_management.vendor_data_desc",
groupKey: "groups.engineering_management"
},
+ {
+ titleKey: "menu.engineering_management.vendor_progress",
+ href: "/evcp/edp-progress",
+ descriptionKey: "menu.engineering_management.vendor_progress_desc",
+ groupKey: "groups.engineering_management"
+ },
],
},
{
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index 33ade381..01a6cbbf 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -232,7 +232,9 @@
"document_management": "Document/Drawing List Management",
"document_management_desc": "Manage vendor submission document/drawing lists",
"document_submission": "Document/Drawing Submission",
- "document_submission_desc": "Submit vendor documents/drawings"
+ "document_submission_desc": "Submit vendor documents/drawings",
+ "vendor_progress": "Vendor Progress",
+ "vendor_progress_desc": "View vendor EDP input progress"
}
},
"additional": {
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index bb8e4c00..c904dba8 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -81,7 +81,9 @@
"document_list_ship": "문서/도서 리스트 관리(조선)",
"document_list_ship_desc": "벤더의 제출 도서/문서의 리스트를 관리",
"vendor_data": "협력업체 데이터 관리",
- "vendor_data_desc": "협력업체 설계 데이터 입력 및 관리"
+ "vendor_data_desc": "협력업체 설계 데이터 입력 및 관리",
+ "vendor_progress": "협력업체 진척도 현황",
+ "vendor_progress_desc": "협력업체 EDP 입력 진척도 현황"
},
"vendor_management": {
"title": "협력업체 관리",
diff --git a/lib/edp-progress/service.ts b/lib/edp-progress/service.ts
new file mode 100644
index 00000000..7044cc76
--- /dev/null
+++ b/lib/edp-progress/service.ts
@@ -0,0 +1,82 @@
+"use server";
+
+import type { Filter, ExtendedSortingState, JoinOperator } from "@/types/table";
+import { getAllVendorsContractsCompletionSummary } from "@/lib/edp-progress/vendor-completion-stats";
+import type { EDPVendorRow } from "./table/edp-progress-table-columns";
+
+type GetListsArgs = {
+ filters?: Filter<EDPVendorRow>[]
+ sort?: ExtendedSortingState<EDPVendorRow>
+ joinOperator?: JoinOperator
+ search?: string
+}
+
+function applyFilters(rows: EDPVendorRow[], filters?: Filter<EDPVendorRow>[], _joinOperator?: JoinOperator): EDPVendorRow[] {
+ if (!filters || filters.length === 0) return rows
+ return rows.filter((row) => {
+ return filters.every((f) => {
+ const id = f.id as keyof EDPVendorRow
+ const value = (row as any)[id]
+ const v = f.value
+ switch (f.type) {
+ case "text":
+ return typeof value === "string" && String(value).toLowerCase().includes(String(v).toLowerCase())
+ case "number":
+ if (typeof value !== "number") return false
+ if (f.operator === "gte") return value >= Number(v)
+ if (f.operator === "lte") return value <= Number(v)
+ if (f.operator === "gt") return value > Number(v)
+ if (f.operator === "lt") return value < Number(v)
+ return value === Number(v)
+ case "select":
+ return String(value) === String(v)
+ default:
+ return true
+ }
+ })
+ })
+}
+
+function applyGlobalSearch(rows: EDPVendorRow[], search?: string): EDPVendorRow[] {
+ if (!search) return rows
+ const s = search.toLowerCase()
+ return rows.filter((r) =>
+ (r.vendorName || "").toLowerCase().includes(s)
+ )
+}
+
+function applySorting(rows: EDPVendorRow[], sort?: ExtendedSortingState<EDPVendorRow>): EDPVendorRow[] {
+ if (!sort || sort.length === 0) return rows
+ const copy = [...rows]
+ copy.sort((a, b) => {
+ for (const s of sort) {
+ const aVal = (a as any)[s.id]
+ const bVal = (b as any)[s.id]
+ if (aVal === bVal) continue
+ if (aVal == null) return s.desc ? 1 : -1
+ if (bVal == null) return s.desc ? -1 : 1
+ if (aVal < bVal) return s.desc ? 1 : -1
+ if (aVal > bVal) return s.desc ? -1 : 1
+ }
+ return 0
+ })
+ return copy
+}
+
+export async function getEDPProgressLists(args: GetListsArgs = {}): Promise<{ data: EDPVendorRow[]; pageCount: number }> {
+ const res = await getAllVendorsContractsCompletionSummary();
+ const rows: EDPVendorRow[] = (res?.vendors || []).map((v) => ({
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ totalForms: v.totalForms,
+ totalTags: v.totalTags,
+ totalRequiredFields: v.totalRequiredFields,
+ totalFilledFields: v.totalFilledFields,
+ completionPercentage: v.overallCompletionPercentage,
+ }));
+
+ const searched = applyGlobalSearch(rows, args.search)
+ const filtered = applyFilters(searched, args.filters, args.joinOperator)
+ const sorted = applySorting(filtered, args.sort)
+ return { data: sorted, pageCount: 1 };
+}
diff --git a/lib/edp-progress/table/edp-progress-table-columns.tsx b/lib/edp-progress/table/edp-progress-table-columns.tsx
new file mode 100644
index 00000000..dc0e87e2
--- /dev/null
+++ b/lib/edp-progress/table/edp-progress-table-columns.tsx
@@ -0,0 +1,108 @@
+"use client";
+import * as React from "react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { Badge } from "@/components/ui/badge";
+
+export type EDPVendorRow = {
+ vendorId: number;
+ vendorName: string;
+ totalForms: number;
+ totalTags: number;
+ totalRequiredFields: number;
+ totalFilledFields: number;
+ completionPercentage: number;
+};
+
+function renderCompletionBadge(value: number | string | undefined) {
+ const num = typeof value === "string" ? Number(value) : (value ?? 0);
+
+ // Show '-' for vendors with no EDP data (0% completion)
+ if (num === 0) {
+ return (
+ <Badge variant="outline">
+ -
+ </Badge>
+ );
+ }
+
+ const variant: "success" | "secondary" | "destructive" =
+ num >= 80 ? "success" : num >= 50 ? "secondary" : "destructive";
+ return (
+ <Badge variant={variant}>{num}%</Badge>
+ );
+}
+
+export function getColumns(): ColumnDef<EDPVendorRow>[] {
+ const cols: ColumnDef<EDPVendorRow>[] = [
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={"벤더명"} />
+ ),
+ cell: ({ row }) => row.getValue("vendorName") as string,
+ meta: { excelHeader: "Vendor Name" },
+ enableResizing: true,
+ size: 80,
+ minSize: 50,
+ maxSize: 150,
+ },
+ {
+ accessorKey: "totalForms",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={"폼 개수"} />
+ ),
+ cell: ({ row }) => String(row.getValue("totalForms") ?? 0),
+ meta: { excelHeader: "Total Forms" },
+ size: 30,
+ minSize: 30,
+ maxSize: 120,
+ },
+ {
+ accessorKey: "totalTags",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={"태그 개수"} />
+ ),
+ cell: ({ row }) => String(row.getValue("totalTags") ?? 0),
+ meta: { excelHeader: "Total Tags" },
+ size: 30,
+ minSize: 30,
+ maxSize: 120,
+ },
+ {
+ accessorKey: "totalRequiredFields",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={"전체 필드"} />
+ ),
+ cell: ({ row }) => String(row.getValue("totalRequiredFields") ?? 0),
+ meta: { excelHeader: "Total Required Fields" },
+ size: 30,
+ minSize: 30,
+ maxSize: 120,
+ },
+ {
+ accessorKey: "totalFilledFields",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={"입력 필드"} />
+ ),
+ cell: ({ row }) => String(row.getValue("totalFilledFields") ?? 0),
+ meta: { excelHeader: "Total Filled Fields" },
+ size: 30,
+ minSize: 30,
+ maxSize: 120,
+ },
+ {
+ accessorKey: "completionPercentage",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={"완성도(%)"} />
+ ),
+ cell: ({ row }) => renderCompletionBadge(row.getValue("completionPercentage") as number | string),
+ meta: { excelHeader: "Completion %" },
+ size: 30,
+ minSize: 30,
+ maxSize: 120,
+ },
+ ];
+
+ return cols;
+}
diff --git a/lib/edp-progress/table/edp-progress-table-toolbar-actions.tsx b/lib/edp-progress/table/edp-progress-table-toolbar-actions.tsx
new file mode 100644
index 00000000..55c56bab
--- /dev/null
+++ b/lib/edp-progress/table/edp-progress-table-toolbar-actions.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { RefreshCw } from "lucide-react"
+
+interface Props {
+ onRefresh?: () => void
+}
+
+export function EDPProgressTableToolbarActions({ onRefresh }: Props) {
+ const handleRefresh = React.useCallback(() => {
+ if (onRefresh) return onRefresh()
+ if (typeof window !== "undefined") window.location.reload()
+ }, [onRefresh])
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button variant="samsung" size="sm" className="inline-flex items-center gap-2" onClick={handleRefresh}>
+ <RefreshCw className="h-4 w-4" /> 새로고침
+ </Button>
+ </div>
+ )
+}
diff --git a/lib/edp-progress/table/edp-progress-table.tsx b/lib/edp-progress/table/edp-progress-table.tsx
new file mode 100644
index 00000000..dc3073a4
--- /dev/null
+++ b/lib/edp-progress/table/edp-progress-table.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns, type EDPVendorRow } from "./edp-progress-table-columns"
+import { EDPProgressTableToolbarActions } from "./edp-progress-table-toolbar-actions"
+
+interface Props {
+ promises: Promise<[
+ { data: EDPVendorRow[]; pageCount: number },
+ ]>
+}
+
+export function EDPProgressTable({ promises }: Props) {
+ const columns = React.useMemo(() => getColumns(), [])
+
+ const [{ data, pageCount }] = React.use(promises)
+
+ const filterFields: DataTableFilterField<EDPVendorRow>[] = []
+ const advancedFilterFields: DataTableAdvancedFilterField<EDPVendorRow>[] = [
+ { id: "vendorName", label: "Vendor Name", type: "text" },
+ { id: "totalTags", label: "Tags", type: "number" },
+ { id: "totalForms", label: "Forms", type: "number" },
+ { id: "completionPercentage", label: "Completion %", type: "number" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "completionPercentage", desc: true }],
+ columnPinning: { left: ["select"], right: [] },
+ },
+ getRowId: (row) => String(row.vendorId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <EDPProgressTableToolbarActions />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ )
+}
diff --git a/lib/edp-progress/validations.ts b/lib/edp-progress/validations.ts
new file mode 100644
index 00000000..f2e9326d
--- /dev/null
+++ b/lib/edp-progress/validations.ts
@@ -0,0 +1,28 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import type { EDPVendorRow } from "./table/edp-progress-table-columns"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<EDPVendorRow>().withDefault([
+ { id: "completionPercentage", desc: true },
+ ]),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+export type GetEDPProgressSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/edp-progress/vendor-completion-stats.ts b/lib/edp-progress/vendor-completion-stats.ts
new file mode 100644
index 00000000..003106cd
--- /dev/null
+++ b/lib/edp-progress/vendor-completion-stats.ts
@@ -0,0 +1,1004 @@
+"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;
+ totalTags: 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 {
+ const shi = column.shi?.toString().toUpperCase();
+ const isEditable = shi === "BOTH" || shi === "IN";
+ console.log(`isFieldEditableByVendor - Key: ${column.key}, shi: ${column.shi}, upperShi: ${shi}, isEditable: ${isEditable}`);
+ return isEditable;
+}
+
+/**
+ * 특정 태그에 대해 편집 가능한 필드 목록을 가져옴
+ */
+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) {
+ console.log(`getEditableFieldsForTag - No tag found for tagNo: ${tagNo}, contractItemId: ${contractItemId}`);
+ return [];
+ }
+
+ console.log(`getEditableFieldsForTag - Found tag for tagNo: ${tagNo}, class: ${tagResult[0].tagClass}`);
+
+ // 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) {
+ console.log(`getEditableFieldsForTag - No tag class found for class: ${tagResult[0].tagClass}, projectId: ${projectId}`);
+ return [];
+ }
+
+ console.log(`getEditableFieldsForTag - Found tag class: ${tagClassResult[0].id} for class: ${tagResult[0].tagClass}`);
+
+ // 3. tagClassAttributes에서 편집 가능한 필드 목록 조회
+ const editableAttributes = await db
+ .select({ attId: tagClassAttributes.attId })
+ .from(tagClassAttributes)
+ .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id))
+ .orderBy(tagClassAttributes.seq);
+
+ console.log(`getEditableFieldsForTag - Found ${editableAttributes.length} editable attributes for tag class ${tagClassResult[0].id}:`, editableAttributes.map(attr => attr.attId));
+
+ 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(eq(formMetas.formCode, formCode))
+ .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;
+ }
+
+ console.log(`calculateVendorFormCompletion - Found form meta for formCode: ${formCode}, projectId: ${projectId}, columns type: ${typeof meta.columns}, isArray: ${Array.isArray(meta.columns)}`);
+
+ // 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'];
+ const editableColumns = columns.filter(col =>
+ !excludeKeys.includes(col.key) && isFieldEditableByVendor(col)
+ );
+
+ console.log(`calculateVendorFormCompletion - Total columns: ${columns.length}, Editable columns: ${editableColumns.length}`);
+ console.log(`calculateVendorFormCompletion - Editable column keys:`, editableColumns.map(col => col.key));
+ console.log(`calculateVendorFormCompletion - All column keys:`, columns.map(col => col.key));
+ console.log(`calculateVendorFormCompletion - All column shi values:`, columns.map(col => col.shi));
+
+ // 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;
+
+ // Debug 페이지와 동일하게 직접 editableColumns 사용 (getEditableFieldsForTag 대신)
+ const actualEditableFields = editableColumns;
+
+ 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)
+ : 0; // Changed from 100 to 0 for no EDP data case
+
+ 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 = totalRequiredFields > 0
+ ? Math.round((totalFilledFields / totalRequiredFields) * 100)
+ : 0; // Changed to 0 for no EDP data case
+
+ 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) {
+ console.log(`getVendorAllContractsCompletionSummary - Processing contract item: ${item.contractItemId} for vendor: ${vendorId}`);
+ const contractCompletion = await calculateVendorContractCompletion(
+ vendorId,
+ item.contractItemId
+ );
+
+ if (contractCompletion) {
+ console.log(`getVendorAllContractsCompletionSummary - Contract completion for item ${item.contractItemId}:`, {
+ totalRequiredFields: contractCompletion.totalRequiredFields,
+ totalFilledFields: contractCompletion.totalFilledFields,
+ totalForms: contractCompletion.totalForms
+ });
+ contractStats.push(contractCompletion);
+ } else {
+ console.log(`getVendorAllContractsCompletionSummary - No contract completion for item: ${item.contractItemId}`);
+ }
+ }
+
+ // 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 totalTags = contractStats.reduce((sum, stat) =>
+ sum + stat.forms.reduce((formSum, form) => formSum + form.tagCount, 0), 0
+ );
+
+ const overallCompletionPercentage = totalRequiredFields > 0
+ ? Math.round((totalFilledFields / totalRequiredFields) * 100)
+ : 0; // Changed from 100 to 0 for no EDP data case
+
+ // 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)
+ : 0; // Changed from 100 to 0 for no EDP data case
+
+ 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,
+ totalTags,
+ 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 debugVendorFieldCalculation(vendorId: number): Promise<{
+ vendorId: number;
+ vendorName: string;
+ debugInfo: {
+ contracts: Array<{
+ contractId: number;
+ contractItemId: number;
+ projectName: string;
+ forms: Array<{
+ formCode: string;
+ formName: string;
+ tags: Array<{
+ tagNo: string;
+ editableFields: string[];
+ requiredFieldsCount: number;
+ filledFieldsCount: number;
+ fieldDetails: Array<{
+ fieldKey: string;
+ fieldValue: unknown;
+ isEmpty: boolean;
+ }>;
+ }>;
+ totalRequiredFields: number;
+ totalFilledFields: number;
+ }>;
+ totalRequiredFields: number;
+ totalFilledFields: number;
+ }>;
+ grandTotal: {
+ totalRequiredFields: number;
+ totalFilledFields: number;
+ totalEmptyFields: number;
+ completionPercentage: number;
+ };
+ };
+} | 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));
+
+ const debugContracts = [];
+
+ for (const item of contractItemsInfo) {
+ // 3. 해당 contract item과 연관된 모든 form codes 조회
+ const formCodes = await db
+ .selectDistinct({
+ formCode: formEntries.formCode
+ })
+ .from(formEntries)
+ .where(eq(formEntries.contractItemId, item.contractItemId));
+
+ const debugForms = [];
+ let contractTotalRequired = 0;
+ let contractTotalFilled = 0;
+
+ for (const { formCode } of formCodes) {
+ // 4. Form 메타데이터 조회
+ const metaRows = await db
+ .select()
+ .from(formMetas)
+ .where(eq(formMetas.formCode, formCode))
+ .orderBy(desc(formMetas.updatedAt))
+ .limit(1);
+
+ const meta = metaRows[0];
+ if (!meta) {
+ console.log(`No form meta found for formCode: ${formCode}, projectId: ${item.projectId}`);
+ continue;
+ }
+
+ console.log(`Found form meta for formCode: ${formCode}, projectId: ${item.projectId}, columns type: ${typeof meta.columns}, isArray: ${Array.isArray(meta.columns)}`);
+
+ // 5. Form 실제 데이터 조회
+ const entryRows = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, item.contractItemId)
+ )
+ )
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1);
+
+ const entry = entryRows[0];
+ if (!entry || !Array.isArray(entry.data)) continue;
+
+ // 6. 컬럼 정의에서 벤더가 편집 가능한 필드 필터링
+ const columns = meta.columns as DataTableColumnJSON[];
+ const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO', 'status'];
+ const editableColumns = columns.filter(col =>
+ !excludeKeys.includes(col.key) && isFieldEditableByVendor(col)
+ );
+
+ const debugTags = [];
+ let formTotalRequired = 0;
+ let formTotalFilled = 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;
+
+ // 직접 editableColumns 사용 (getEditableFieldsForTag 대신)
+ const actualEditableFields = editableColumns;
+
+ const requiredFieldsCount = actualEditableFields.length;
+ let filledFieldsCount = 0;
+
+ const fieldDetails = [];
+ // 각 편집 가능한 필드의 값 확인
+ for (const column of actualEditableFields) {
+ const value = rowData[column.key];
+ const isEmpty = isEmptyValue(value);
+ if (!isEmpty) {
+ filledFieldsCount++;
+ }
+ fieldDetails.push({
+ fieldKey: column.key,
+ fieldValue: value,
+ isEmpty
+ });
+ }
+
+ debugTags.push({
+ tagNo,
+ editableFields: actualEditableFields.map(col => col.key),
+ requiredFieldsCount,
+ filledFieldsCount,
+ fieldDetails
+ });
+
+ formTotalRequired += requiredFieldsCount;
+ formTotalFilled += filledFieldsCount;
+ }
+
+ debugForms.push({
+ formCode,
+ formName: meta.formName,
+ tags: debugTags,
+ totalRequiredFields: formTotalRequired,
+ totalFilledFields: formTotalFilled
+ });
+
+ contractTotalRequired += formTotalRequired;
+ contractTotalFilled += formTotalFilled;
+ }
+
+ debugContracts.push({
+ contractId: item.contractId,
+ contractItemId: item.contractItemId,
+ projectName: item.projectName,
+ forms: debugForms,
+ totalRequiredFields: contractTotalRequired,
+ totalFilledFields: contractTotalFilled
+ });
+ }
+
+ // 전체 합계 계산
+ const grandTotalRequired = debugContracts.reduce((sum, contract) => sum + contract.totalRequiredFields, 0);
+ const grandTotalFilled = debugContracts.reduce((sum, contract) => sum + contract.totalFilledFields, 0);
+ const grandTotalEmpty = grandTotalRequired - grandTotalFilled;
+ const grandCompletionPercentage = grandTotalRequired > 0
+ ? Math.round((grandTotalFilled / grandTotalRequired) * 100)
+ : 0; // Changed from 100 to 0 for no EDP data case
+
+ return {
+ vendorId: vendor.id,
+ vendorName: vendor.vendorName,
+ debugInfo: {
+ contracts: debugContracts,
+ grandTotal: {
+ totalRequiredFields: grandTotalRequired,
+ totalFilledFields: grandTotalFilled,
+ totalEmptyFields: grandTotalEmpty,
+ completionPercentage: grandCompletionPercentage
+ }
+ }
+ };
+
+ } catch (error) {
+ console.error(`Error debugging vendor field calculation:`, error);
+ return null;
+ }
+}
+
+/**
+ * 모든 벤더들의 전체 계약 완성도 요약 (관리자용)
+ */
+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) {
+ console.log(`getAllVendorsContractsCompletionSummary - Processing vendor: ${vendor.vendorId} (${vendor.vendorName})`);
+ const summary = await getVendorAllContractsCompletionSummary(vendor.vendorId);
+ if (summary && summary.totalRequiredFields > 0) { // Only include vendors with EDP data
+ console.log(`getAllVendorsContractsCompletionSummary - Vendor ${vendor.vendorId} summary:`, {
+ totalRequiredFields: summary.totalRequiredFields,
+ totalFilledFields: summary.totalFilledFields,
+ totalTags: summary.totalTags,
+ totalForms: summary.totalForms
+ });
+ vendorSummaries.push(summary);
+ } else {
+ console.log(`getAllVendorsContractsCompletionSummary - No EDP data for vendor: ${vendor.vendorId}`);
+ }
+ }
+
+ // 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: []
+ };
+ }
+}