summaryrefslogtreecommitdiff
path: root/lib/bidding/manage
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-12-04 09:04:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-12-04 09:04:09 +0000
commit25749225689c3934bc10ad1e8285e13020b61282 (patch)
tree0c96af8f89b08715589a7ec6d2b19e025215b98f /lib/bidding/manage
parent0f3954bf57e65caef7b7dd14ea5fccb63fdb2bef (diff)
(최겸)구매 입찰, 계약 수정
Diffstat (limited to 'lib/bidding/manage')
-rw-r--r--lib/bidding/manage/export-bidding-items-to-excel.ts161
-rw-r--r--lib/bidding/manage/import-bidding-items-from-excel.ts271
-rw-r--r--lib/bidding/manage/project-utils.ts87
3 files changed, 519 insertions, 0 deletions
diff --git a/lib/bidding/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts
new file mode 100644
index 00000000..814648a7
--- /dev/null
+++ b/lib/bidding/manage/export-bidding-items-to-excel.ts
@@ -0,0 +1,161 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectCodesByIds } from "./project-utils"
+
+/**
+ * 입찰품목 목록을 Excel로 내보내기
+ */
+export async function exportBiddingItemsToExcel(
+ items: PRItemInfo[],
+ {
+ filename = "입찰품목목록",
+ }: {
+ filename?: string
+ } = {}
+): Promise<void> {
+ // 프로젝트 ID 목록 수집
+ const projectIds = items
+ .map((item) => item.projectId)
+ .filter((id): id is number => id != null && id > 0)
+
+ // 프로젝트 코드 맵 조회
+ const projectCodeMap = await getProjectCodesByIds(projectIds)
+
+ // 헤더 정의
+ const headers = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 데이터 행 생성
+ const dataRows = items.map((item) => {
+ // 프로젝트 코드 조회
+ const projectCode = item.projectId
+ ? projectCodeMap.get(item.projectId) || ""
+ : ""
+
+ return [
+ projectCode,
+ item.projectInfo || "",
+ item.materialGroupNumber || "",
+ item.materialGroupInfo || "",
+ item.materialNumber || "",
+ item.materialInfo || "",
+ item.quantity || "",
+ item.quantityUnit || "",
+ item.totalWeight || "",
+ item.weightUnit || "",
+ item.requestedDeliveryDate || "",
+ item.priceUnit || "",
+ item.purchaseUnit || "",
+ item.materialWeight || "",
+ item.targetUnitPrice || "",
+ item.targetAmount || "",
+ item.targetCurrency || "KRW",
+ item.budgetAmount || "",
+ item.budgetCurrency || "KRW",
+ item.actualAmount || "",
+ item.actualCurrency || "KRW",
+ item.wbsCode || "",
+ item.wbsName || "",
+ item.costCenterCode || "",
+ item.costCenterName || "",
+ item.glAccountCode || "",
+ item.glAccountName || "",
+ item.prNumber || "",
+ ]
+ })
+
+ // 최종 sheetData
+ const sheetData = [headers, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, headers.length, filename)
+}
+
+/**
+ * Excel 파일 생성 및 다운로드
+ */
+async function createAndDownloadExcel(
+ sheetData: any[][],
+ columnCount: number,
+ filename: string
+): Promise<void> {
+ // ExcelJS 워크북/시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // 칼럼별 최대 길이 추적
+ const maxColumnLengths = Array(columnCount).fill(0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용 (첫 번째 행)
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ })
+
+ // 칼럼 너비 자동 조정
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // 최종 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts
new file mode 100644
index 00000000..2e0dfe33
--- /dev/null
+++ b/lib/bidding/manage/import-bidding-items-from-excel.ts
@@ -0,0 +1,271 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectIdByCodeAndName } from "./project-utils"
+
+export interface ImportBiddingItemsResult {
+ success: boolean
+ items: PRItemInfo[]
+ errors: string[]
+}
+
+/**
+ * Excel 파일에서 입찰품목 데이터 파싱
+ */
+export async function importBiddingItemsFromExcel(
+ file: File
+): Promise<ImportBiddingItemsResult> {
+ const errors: string[] = []
+ const items: PRItemInfo[] = []
+
+ try {
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ return {
+ success: false,
+ items: [],
+ errors: ["Excel 파일에 시트가 없습니다."],
+ }
+ }
+
+ // 헤더 행 읽기 (첫 번째 행)
+ const headerRow = worksheet.getRow(1)
+ const headerValues = headerRow.values as ExcelJS.CellValue[]
+
+ // 헤더 매핑 생성
+ const headerMap: Record<string, number> = {}
+ const expectedHeaders = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 헤더 인덱스 매핑
+ for (let i = 1; i < headerValues.length; i++) {
+ const headerValue = String(headerValues[i] || "").trim()
+ if (headerValue && expectedHeaders.includes(headerValue)) {
+ headerMap[headerValue] = i
+ }
+ }
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["자재그룹코드", "자재그룹명"]
+ const missingHeaders = requiredHeaders.filter(
+ (h) => !headerMap[h]
+ )
+ if (missingHeaders.length > 0) {
+ errors.push(
+ `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}`
+ )
+ }
+
+ // 데이터 행 읽기 (2번째 행부터)
+ for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) {
+ const row = worksheet.getRow(rowIndex)
+ const rowValues = row.values as ExcelJS.CellValue[]
+
+ // 빈 행 건너뛰기
+ if (rowValues.every((val) => !val || String(val).trim() === "")) {
+ continue
+ }
+
+ // 셀 값 추출 헬퍼
+ const getCellValue = (headerName: string): string => {
+ const colIndex = headerMap[headerName]
+ if (!colIndex) return ""
+ const value = rowValues[colIndex]
+ if (value == null) return ""
+
+ // ExcelJS 객체 처리
+ if (typeof value === "object" && "text" in value) {
+ return String((value as any).text || "")
+ }
+
+ // 날짜 처리
+ if (value instanceof Date) {
+ return value.toISOString().split("T")[0]
+ }
+
+ return String(value).trim()
+ }
+
+ // 필수값 검증
+ const materialGroupNumber = getCellValue("자재그룹코드")
+ const materialGroupInfo = getCellValue("자재그룹명")
+
+ if (!materialGroupNumber || !materialGroupInfo) {
+ errors.push(
+ `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.`
+ )
+ continue
+ }
+
+ // 수량 또는 중량 검증
+ const quantity = getCellValue("수량")
+ const totalWeight = getCellValue("중량")
+ const quantityUnit = getCellValue("수량단위")
+ const weightUnit = getCellValue("중량단위")
+
+ if (!quantity && !totalWeight) {
+ errors.push(
+ `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.`
+ )
+ continue
+ }
+
+ if (quantity && !quantityUnit) {
+ errors.push(
+ `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ if (totalWeight && !weightUnit) {
+ errors.push(
+ `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ // 납품요청일 검증
+ const requestedDeliveryDate = getCellValue("납품요청일")
+ if (!requestedDeliveryDate) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일은 필수입니다.`
+ )
+ continue
+ }
+
+ // 날짜 형식 검증
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/
+ if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)`
+ )
+ continue
+ }
+
+ // 내정단가 검증 (필수)
+ const targetUnitPrice = getCellValue("내정단가")
+ if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) {
+ errors.push(
+ `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.`
+ )
+ continue
+ }
+
+ // 숫자 값 정리 (콤마 제거)
+ const cleanNumber = (value: string): string => {
+ return value.replace(/,/g, "").trim()
+ }
+
+ // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로)
+ const projectCode = getCellValue("프로젝트코드")
+ const projectName = getCellValue("프로젝트명")
+ let projectId: number | null = null
+
+ if (projectCode && projectName) {
+ projectId = await getProjectIdByCodeAndName(projectCode, projectName)
+ if (!projectId) {
+ errors.push(
+ `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.`
+ )
+ // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시)
+ }
+ }
+
+ // PRItemInfo 객체 생성
+ const item: PRItemInfo = {
+ id: -(rowIndex - 1), // 임시 ID (음수)
+ prNumber: getCellValue("PR번호") || null,
+ projectId: projectId,
+ projectInfo: projectName || null,
+ shi: null,
+ quantity: quantity ? cleanNumber(quantity) : null,
+ quantityUnit: quantityUnit || null,
+ totalWeight: totalWeight ? cleanNumber(totalWeight) : null,
+ weightUnit: weightUnit || null,
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: requestedDeliveryDate || null,
+ isRepresentative: false,
+ annualUnitPrice: null,
+ currency: "KRW",
+ materialGroupNumber: materialGroupNumber || null,
+ materialGroupInfo: materialGroupInfo || null,
+ materialNumber: getCellValue("자재코드") || null,
+ materialInfo: getCellValue("자재명") || null,
+ priceUnit: getCellValue("가격단위") || "1",
+ purchaseUnit: getCellValue("구매단위") || "EA",
+ materialWeight: getCellValue("자재순중량") || null,
+ wbsCode: getCellValue("WBS코드") || null,
+ wbsName: getCellValue("WBS명") || null,
+ costCenterCode: getCellValue("코스트센터코드") || null,
+ costCenterName: getCellValue("코스트센터명") || null,
+ glAccountCode: getCellValue("GL계정코드") || null,
+ glAccountName: getCellValue("GL계정명") || null,
+ targetUnitPrice: cleanNumber(targetUnitPrice) || null,
+ targetAmount: getCellValue("내정금액")
+ ? cleanNumber(getCellValue("내정금액"))
+ : null,
+ targetCurrency: getCellValue("내정통화") || "KRW",
+ budgetAmount: getCellValue("예산금액")
+ ? cleanNumber(getCellValue("예산금액"))
+ : null,
+ budgetCurrency: getCellValue("예산통화") || "KRW",
+ actualAmount: getCellValue("실적금액")
+ ? cleanNumber(getCellValue("실적금액"))
+ : null,
+ actualCurrency: getCellValue("실적통화") || "KRW",
+ }
+
+ items.push(item)
+ }
+
+ return {
+ success: errors.length === 0,
+ items,
+ errors,
+ }
+ } catch (error) {
+ console.error("Excel import error:", error)
+ return {
+ success: false,
+ items: [],
+ errors: [
+ error instanceof Error
+ ? error.message
+ : "Excel 파일 파싱 중 오류가 발생했습니다.",
+ ],
+ }
+ }
+}
+
diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts
new file mode 100644
index 00000000..92744695
--- /dev/null
+++ b/lib/bidding/manage/project-utils.ts
@@ -0,0 +1,87 @@
+'use server'
+
+import db from '@/db/db'
+import { projects } from '@/db/schema'
+import { eq, and, inArray } from 'drizzle-orm'
+
+/**
+ * 프로젝트 ID로 프로젝트 코드 조회
+ */
+export async function getProjectCodeById(projectId: number): Promise<string | null> {
+ try {
+ const result = await db
+ .select({ code: projects.code })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1)
+
+ return result[0]?.code || null
+ } catch (error) {
+ console.error('Failed to get project code by id:', error)
+ return null
+ }
+}
+
+/**
+ * 프로젝트 코드와 이름으로 프로젝트 ID 조회
+ */
+export async function getProjectIdByCodeAndName(
+ projectCode: string,
+ projectName: string
+): Promise<number | null> {
+ try {
+ if (!projectCode || !projectName) {
+ return null
+ }
+
+ const result = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(
+ and(
+ eq(projects.code, projectCode.trim()),
+ eq(projects.name, projectName.trim())
+ )
+ )
+ .limit(1)
+
+ return result[0]?.id || null
+ } catch (error) {
+ console.error('Failed to get project id by code and name:', error)
+ return null
+ }
+}
+
+/**
+ * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화)
+ */
+export async function getProjectCodesByIds(
+ projectIds: number[]
+): Promise<Map<number, string>> {
+ try {
+ if (projectIds.length === 0) {
+ return new Map()
+ }
+
+ const uniqueIds = [...new Set(projectIds.filter(id => id != null))]
+ if (uniqueIds.length === 0) {
+ return new Map()
+ }
+
+ const result = await db
+ .select({ id: projects.id, code: projects.code })
+ .from(projects)
+ .where(inArray(projects.id, uniqueIds))
+
+ const map = new Map<number, string>()
+ result.forEach((project) => {
+ map.set(project.id, project.code)
+ })
+
+ return map
+ } catch (error) {
+ console.error('Failed to get project codes by ids:', error)
+ return new Map()
+ }
+}
+