diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts index 8dbc4a2b..ff4307ff 100644 --- a/backend-node/src/controllers/devPartController.ts +++ b/backend-node/src/controllers/devPartController.ts @@ -168,6 +168,43 @@ export async function excelSave(req: AuthenticatedRequest, res: Response) { } } +// ─── 도면 다중 업로드 (wace btnDrawingUpload 1:1) ─────────── + +export async function drawingMultiUpload(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req.user as any)?.userId ?? "system"; + const companyCode = (req.user as any)?.companyCode ?? "COMPANY_16"; + const files = (req.files as Express.Multer.File[]) ?? []; + if (files.length === 0) { + return res.status(400).json({ success: false, message: "업로드할 파일이 없습니다." }); + } + // multipart body — partNoList 는 JSON 문자열로 전달됨. + // 지정 → 그 목록만 매칭 후보 (M1 현재 그리드 한정) + // 미지정/빈 배열 → IS_LAST='1' 전체 매칭 (M2 조회 — 페이지 밖도 허용) + let partNoList: string[] | null = null; + const raw = req.body?.partNoList; + if (typeof raw === "string" && raw.trim() !== "") { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) partNoList = parsed.map(String).filter(Boolean); + } catch { + return res.status(400).json({ success: false, message: "partNoList JSON 파싱 실패" }); + } + } else if (Array.isArray(raw)) { + partNoList = raw.map(String).filter(Boolean); + } + const result = await svc.drawingMultiUpload(userId, companyCode, files, partNoList); + return res.json({ + success: true, + data: result, + message: `업로드 완료 — 성공 ${result.successCount} / 미매칭 ${result.notFoundCount} / 실패 ${result.failCount}`, + }); + } catch (e: any) { + logger.error("도면 다중 업로드 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // ─── 다중 삭제 ────────────────────────────────────────────── export async function removeMany(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts index f72130db..6e37527b 100644 --- a/backend-node/src/routes/devPartRoutes.ts +++ b/backend-node/src/routes/devPartRoutes.ts @@ -5,6 +5,8 @@ import { Router } from "express"; import multer from "multer"; +import path from "path"; +import fs from "fs"; import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/devPartController"; @@ -16,6 +18,14 @@ const excelUpload = multer({ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB }); +// 도면 다중 업로드 — 임시 디스크 저장. 매칭 성공 시 서비스에서 최종 위치로 이동. +const drawingTempDir = path.join(process.cwd(), "uploads", "temp"); +if (!fs.existsSync(drawingTempDir)) fs.mkdirSync(drawingTempDir, { recursive: true }); +const drawingUpload = multer({ + dest: drawingTempDir, + limits: { fileSize: 200 * 1024 * 1024 }, // 파일당 200MB +}); + // M1 — 임시(등록) 그리드 router.get("/part-temp/list", ctrl.getTempList); router.post("/part-temp/deploy", ctrl.deploy); @@ -27,6 +37,13 @@ router.get("/part/list", ctrl.getList); router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse); router.post("/part/excel-save", ctrl.excelSave); +// 도면 다중 업로드 (M1·M2 공용) — /:objid 보다 위 +router.post( + "/part/drawing-multi-upload", + drawingUpload.array("files", 500), + ctrl.drawingMultiUpload +); + // PART 자동완성 옵션 (select2-part 1:1) — /:objid 보다 위 router.get("/part/options", ctrl.partOptions); diff --git a/backend-node/src/services/devPartService.ts b/backend-node/src/services/devPartService.ts index 1a9acb62..c8b18ebf 100644 --- a/backend-node/src/services/devPartService.ts +++ b/backend-node/src/services/devPartService.ts @@ -19,9 +19,12 @@ // ============================================================ import { PoolClient } from "pg"; +import path from "path"; +import fs from "fs"; import { getPool, transaction } from "../database/db"; import { logger } from "../utils/logger"; import { createObjId } from "../utils/objidUtil"; +import { generateUUID } from "../utils/generateId"; import { PART_BASE_SIMPLE } from "./devPartSqlFragments"; // ─── 필터/바디 타입 ────────────────────────────────────────── @@ -511,3 +514,253 @@ export async function removeMany(objids: string[]): Promise { ); return r.rowCount ?? 0; } + +// ─── 도면 다중 업로드 ───────────────────────────────────────── +// wace partMngTempList.jsp btnDrawingUpload + PartMngController.uploadDrawingFilesForPartList 1:1. +// +// 흐름: +// 1) IS_LAST='1' part_mng 전체 조회 → PART_NO → OBJID 맵 +// 2) 각 파일별: +// a) 확장자(STP/STEP/DWG/DXF/PDF) → doc_type 결정 +// (STP/STEP=3D_CAD, DWG/DXF=2D_DRAWING_CAD, PDF=2D_PDF_CAD) +// b) 파일명에서 알려진 확장자 반복 제거 (.idw .dwg .dxf .stp .step .pdf .chg) +// c) PART_NO 매칭: 정확 일치 우선 → 안 되면 startsWith (가장 긴 prefix) +// d) 매칭 성공 → attach_file_info INSERT (target_objid = part_mng.objid) +// e) 매칭 실패 → notFoundCount++ 파일 삭제 + +const DRAWING_KNOWN_EXTS = [".idw", ".dwg", ".dxf", ".stp", ".step", ".pdf", ".chg"]; + +function removeDrawingExtensions(fileName: string): string { + let result = fileName; + let removed = true; + while (removed) { + removed = false; + const lastDot = result.lastIndexOf("."); + if (lastDot > 0) { + const ext = result.substring(lastDot).toLowerCase(); + if (DRAWING_KNOWN_EXTS.includes(ext)) { + result = result.substring(0, lastDot); + removed = true; + } + } + } + return result; +} + +function findMatchingPartNo( + fileNameWithoutExt: string, + partNoSet: Set +): string | null { + if (!fileNameWithoutExt || partNoSet.size === 0) return null; + if (partNoSet.has(fileNameWithoutExt)) return fileNameWithoutExt; + let best: string | null = null; + for (const pn of partNoSet) { + if (fileNameWithoutExt.startsWith(pn)) { + if (!best || pn.length > best.length) best = pn; + } + } + return best; +} + +export interface DrawingMultiUploadDetail { + fileName: string; + partNo?: string; + docType?: string; + status: "success" | "fail" | "notFound" | "unsupported"; + reason?: string; +} + +export interface DrawingMultiUploadResult { + successCount: number; + failCount: number; + notFoundCount: number; + details: DrawingMultiUploadDetail[]; +} + +export async function drawingMultiUpload( + userId: string, + companyCode: string, + files: Express.Multer.File[], + partNoList: string[] | null | undefined +): Promise { + const pool = getPool(); + + // 1) 매칭 후보 PART 조회 + // partNoList 지정 → 그 목록 IN 절로 제한 (M1 등록 화면, 현재 그리드 기반) + // partNoList 없음 → IS_LAST='1' 전체 (M2 조회 화면 — 페이지 밖 파트도 매칭 허용) + // (wace partMng.xml partMngListByPartNos: `` 1:1) + const hasList = Array.isArray(partNoList) && partNoList.length > 0; + const partRes = hasList + ? await pool.query<{ objid: string; part_no: string }>( + `SELECT objid::text AS objid, part_no + FROM part_mng + WHERE is_last = '1' + AND part_no = ANY($1::text[])`, + [partNoList] + ) + : await pool.query<{ objid: string; part_no: string }>( + `SELECT objid::text AS objid, part_no + FROM part_mng + WHERE is_last = '1' AND part_no IS NOT NULL` + ); + const partNoMap = new Map(); + for (const r of partRes.rows) { + if (r.part_no) partNoMap.set(r.part_no, r.objid); + } + const partNoSet = new Set(partNoMap.keys()); + logger.info("도면 다중 업로드 시작", { + files: files.length, + scope: hasList ? "visible" : "all", + requestedPartNos: hasList ? partNoList!.length : null, + partCandidates: partNoMap.size, + }); + + // 2) 회사/날짜 폴더 준비 (fileController.ts 와 동일 경로 규약) + const baseUploadDir = path.join(process.cwd(), "uploads"); + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const dateFolder = `${year}/${month}/${day}`; + const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; + const finalUploadDir = path.join(baseUploadDir, actualCompanyCode, dateFolder); + if (!fs.existsSync(finalUploadDir)) { + fs.mkdirSync(finalUploadDir, { recursive: true }); + } + + const result: DrawingMultiUploadResult = { + successCount: 0, + failCount: 0, + notFoundCount: 0, + details: [], + }; + + for (const file of files) { + // 파일명 UTF-8 디코딩 + let originalName: string; + try { + originalName = Buffer.from(file.originalname, "latin1").toString("utf8"); + } catch { + originalName = file.originalname; + } + + // 확장자 → doc_type + const ext = path + .extname(originalName) + .toLowerCase() + .replace(".", "") + .toUpperCase(); + let docType = ""; + let docTypeName = ""; + if (ext === "STP" || ext === "STEP") { + docType = "3D_CAD"; + docTypeName = "3D CAD 첨부파일"; + } else if (ext === "DWG" || ext === "DXF") { + docType = "2D_DRAWING_CAD"; + docTypeName = "2D(Drawing) CAD 첨부파일"; + } else if (ext === "PDF") { + docType = "2D_PDF_CAD"; + docTypeName = "2D(PDF) CAD 첨부파일"; + } else { + result.failCount++; + result.details.push({ + fileName: originalName, + status: "unsupported", + reason: `지원하지 않는 확장자: ${ext}`, + }); + try { fs.unlinkSync(file.path); } catch {} + continue; + } + + // 파일명 ↔ part_no 매칭 + const nameWithoutExt = removeDrawingExtensions(originalName); + const matchedPartNo = findMatchingPartNo(nameWithoutExt, partNoSet); + if (!matchedPartNo) { + result.notFoundCount++; + result.details.push({ + fileName: originalName, + status: "notFound", + reason: `품번 매칭 실패 (${nameWithoutExt})`, + }); + try { fs.unlinkSync(file.path); } catch {} + continue; + } + const targetObjid = partNoMap.get(matchedPartNo)!; + + // 임시 → 최종 위치 이동 + const sanitizedName = originalName + .replace(/[\/\\:*?"<>|]/g, "_") + .replace(/\s+/g, "_") + .replace(/_{2,}/g, "_"); + const savedFileName = `${Date.now()}_${sanitizedName}`; + const finalFilePath = path.join(finalUploadDir, savedFileName); + try { + fs.renameSync(file.path, finalFilePath); + } catch (e: any) { + logger.error("도면 파일 저장 실패", { error: e.message, file: originalName }); + result.failCount++; + result.details.push({ + fileName: originalName, + partNo: matchedPartNo, + docType, + status: "fail", + reason: "파일 저장 실패", + }); + continue; + } + + const relativePath = `/${actualCompanyCode}/${dateFolder}/${savedFileName}`; + const fullFilePath = `/uploads${relativePath}`; + + // attach_file_info INSERT + const objidValue = parseInt( + generateUUID().replace(/-/g, "").substring(0, 15), + 16 + ); + try { + await pool.query( + `INSERT INTO attach_file_info ( + objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name, + file_size, file_ext, file_path, company_code, writer, regdate, status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), 'ACTIVE')`, + [ + objidValue, + targetObjid, + savedFileName, + originalName, + docType, + docTypeName, + file.size, + ext.toLowerCase(), + fullFilePath, + companyCode, + userId, + ] + ); + result.successCount++; + result.details.push({ + fileName: originalName, + partNo: matchedPartNo, + docType, + status: "success", + }); + } catch (e: any) { + logger.error("attach_file_info INSERT 실패", { + error: e.message, + file: originalName, + }); + result.failCount++; + result.details.push({ + fileName: originalName, + partNo: matchedPartNo, + docType, + status: "fail", + reason: e.message, + }); + try { fs.unlinkSync(finalFilePath); } catch {} + } + } + + logger.info("도면 다중 업로드 완료", result); + return result; +} diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx index 47f976f3..ca80b6d6 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -18,6 +18,7 @@ import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart"; import { PartFormDialog } from "@/components/development/PartFormDialog"; import { PartDetailDialog } from "@/components/development/PartDetailDialog"; import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog"; +import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton"; import { DevPartSelect } from "@/components/development/DevPartSelect"; // wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY) @@ -202,6 +203,10 @@ export default function PartRegistPage() { + r.part_no).filter(Boolean) as string[]} + onUploaded={() => fetchList()} + /> + {/* M2 조회 — partNoList 미전달: IS_LAST='1' 전체 part_mng 매칭 (페이지 밖도 허용) */} + fetchList()} />
diff --git a/frontend/components/development/PartDrawingMultiUploadButton.tsx b/frontend/components/development/PartDrawingMultiUploadButton.tsx new file mode 100644 index 00000000..489aee8d --- /dev/null +++ b/frontend/components/development/PartDrawingMultiUploadButton.tsx @@ -0,0 +1,243 @@ +"use client"; + +// 개발관리 > PART 도면 다중 업로드 버튼 — wace partMngTempList.jsp btnDrawingUpload 1:1. +// +// 동작: +// 1) 버튼 클릭 → 숨김 클릭 +// 2) onChange → 확장자 분류 (3D/2D/PDF) + 0개 거부 + 분류표 confirm +// 3) 확인 → POST /api/development/part/drawing-multi-upload +// 4) 응답 받아 결과 다이얼로그 표시 + 그리드 새로고침 + +import React, { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { FileImage, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + devPartApi, + DrawingMultiUploadResult, + DrawingMultiUploadDetail, +} from "@/lib/api/devPart"; + +interface Props { + /** 매칭 후보 PART_NO 범위. + * 배열 지정 → 그 목록만 매칭 후보 (M1 등록 화면 — 현재 그리드 기반). + * null/undefined/빈 배열 → IS_LAST='1' 전체 part_mng 매칭 (M2 조회 화면 — 페이지 밖도 허용). + * (wace partMng.xml `` 분기 1:1) */ + partNoList?: string[] | null; + onUploaded?: () => void; // 업로드 완료 후 그리드 새로고침 + className?: string; +} + +const ACCEPT = ".stp,.step,.dwg,.dxf,.pdf"; + +export function PartDrawingMultiUploadButton({ partNoList, onUploaded, className }: Props) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + const [resultOpen, setResultOpen] = useState(false); + + const onClick = () => { + if (uploading) return; + inputRef.current?.click(); + }; + + const onChange = async (e: React.ChangeEvent) => { + const picked = e.target.files; + if (!picked || picked.length === 0) return; + + // wace fn_uploadDrawingFiles 1:1 — 확장자별 분류 + const filesByType: Record<"3D" | "2D" | "PDF", File[]> = { + "3D": [], + "2D": [], + PDF: [], + }; + for (let i = 0; i < picked.length; i++) { + const f = picked[i]; + const dot = f.name.lastIndexOf("."); + if (dot < 0) continue; + const ext = f.name.substring(dot + 1).toLowerCase(); + if (ext === "stp" || ext === "step") filesByType["3D"].push(f); + else if (ext === "dwg" || ext === "dxf") filesByType["2D"].push(f); + else if (ext === "pdf") filesByType.PDF.push(f); + } + const valid = [...filesByType["3D"], ...filesByType["2D"], ...filesByType.PDF]; + + // input 초기화 (같은 파일 재선택 가능) + if (inputRef.current) inputRef.current.value = ""; + + if (valid.length === 0) { + window.alert("업로드 가능한 파일 형식이 없습니다. (stp, dwg, dxf, pdf만 가능)"); + return; + } + + const msg = + `총 ${valid.length}개의 파일을 업로드하시겠습니까?\n` + + `- 3D (STP): ${filesByType["3D"].length}개\n` + + `- 2D (DWG/DXF): ${filesByType["2D"].length}개\n` + + `- PDF: ${filesByType.PDF.length}개`; + if (!window.confirm(msg)) return; + + setUploading(true); + try { + const res = await devPartApi.drawingMultiUpload(valid, partNoList); + setResult(res); + setResultOpen(true); + if (onUploaded) onUploaded(); + } catch (err: any) { + toast.error( + err?.response?.data?.message ?? err?.message ?? "도면 업로드 실패" + ); + } finally { + setUploading(false); + } + }; + + return ( + <> + + + + + ); +} + +// ─── 결과 다이얼로그 ──────────────────────────────────────── + +function DrawingResultDialog({ + open, + onOpenChange, + result, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + result: DrawingMultiUploadResult | null; +}) { + if (!result) return null; + const total = + result.successCount + result.failCount + result.notFoundCount; + return ( + + + + 도면 다중 업로드 결과 + + +
+
+ + + + +
+ + + + + + + + + + + + + {result.details.map((d, i) => ( + + + + + + + + ))} + +
파일명매칭 품번문서구분상태사유
{d.fileName}{d.partNo ?? "—"}{d.docType ?? "—"} + + + {d.reason ?? ""} +
+
+ + + + +
+
+ ); +} + +function Stat({ + label, + value, + tone, +}: { + label: string; + value: number; + tone?: "success" | "warn" | "error"; +}) { + const cls = + tone === "success" + ? "text-emerald-700 bg-emerald-50" + : tone === "warn" + ? "text-amber-700 bg-amber-50" + : tone === "error" + ? "text-red-700 bg-red-50" + : "text-foreground bg-muted/40"; + return ( +
+
{label}
+
{value.toLocaleString()}
+
+ ); +} + +function StatusBadge({ status }: { status: DrawingMultiUploadDetail["status"] }) { + const map: Record< + DrawingMultiUploadDetail["status"], + { label: string; cls: string } + > = { + success: { label: "성공", cls: "bg-emerald-100 text-emerald-700" }, + notFound: { label: "품번 미존재", cls: "bg-amber-100 text-amber-700" }, + unsupported: { label: "확장자 미지원", cls: "bg-amber-100 text-amber-700" }, + fail: { label: "실패", cls: "bg-red-100 text-red-700" }, + }; + const v = map[status]; + return ( + + {v.label} + + ); +} diff --git a/frontend/lib/api/devPart.ts b/frontend/lib/api/devPart.ts index caea8991..1eb2ba28 100644 --- a/frontend/lib/api/devPart.ts +++ b/frontend/lib/api/devPart.ts @@ -306,4 +306,43 @@ export const devPartApi = { const res = await apiClient.post("/development/part/excel-save", { rows }); return res.data?.data as ExcelSaveResponse; }, + + // 도면 다중 업로드 (wace btnDrawingUpload 1:1) + // 확장자 stp/step → 3D_CAD, dwg/dxf → 2D_DRAWING_CAD, pdf → 2D_PDF_CAD + // 파일명 ↔ part_no 자동 매칭 (정확 일치 → longest prefix) + // partNoList 지정 → 그 목록만 매칭 후보 (M1) + // partNoList null/undefined → IS_LAST='1' 전체 매칭 (M2) + async drawingMultiUpload( + files: File[], + partNoList?: string[] | null + ): Promise { + const fd = new FormData(); + for (const f of files) fd.append("files", f); + if (Array.isArray(partNoList) && partNoList.length > 0) { + fd.append("partNoList", JSON.stringify(partNoList)); + } + const res = await apiClient.post( + "/development/part/drawing-multi-upload", + fd, + { headers: { "Content-Type": "multipart/form-data" } } + ); + return res.data?.data as DrawingMultiUploadResult; + }, }; + +// ─── 도면 다중 업로드 결과 타입 ────────────────────────────── + +export interface DrawingMultiUploadDetail { + fileName: string; + partNo?: string; + docType?: string; + status: "success" | "fail" | "notFound" | "unsupported"; + reason?: string; +} + +export interface DrawingMultiUploadResult { + successCount: number; + failCount: number; + notFoundCount: number; + details: DrawingMultiUploadDetail[]; +}