개발관리>PART 도면 다중 업로드 — M1·M2 [도면 다중 업로드] 버튼 + 파일명↔품번 자동 매칭

wace partMngTempList.jsp btnDrawingUpload + PartMngController.uploadDrawingFilesForPartList +
partMng.xml partMngListByPartNos 1:1 이식.

- 백엔드 (devPartService.drawingMultiUpload):
  · 확장자 → doc_type 매핑 (STP/STEP=3D_CAD, DWG/DXF=2D_DRAWING_CAD, PDF=2D_PDF_CAD)
  · 파일명에서 알려진 확장자 반복 제거 (.idw .dwg .dxf .stp .step .pdf .chg)
  · PART_NO 매칭: 정확 일치 우선 → 안 되면 longest prefix
  · partNoList 지정 → 그 목록 IN 절로 후보 제한 (M1, 현재 그리드 기반)
  · partNoList 미지정 → IS_LAST='1' 전체 part_mng 매칭 (M2, 페이지 밖도 허용)
    (wace partMngListByPartNos <if PART_NO_LIST != null> 분기 1:1)
  · 매칭 성공 → attach_file_info INSERT (target_objid = part_mng.objid)
  · 매칭 실패 → notFoundCount + 임시 파일 삭제
  · 결과 details[] 반환 (파일별 상태/매칭품번/사유)

- 엔드포인트: POST /api/development/part/drawing-multi-upload
  · multer 파일당 200MB · 최대 500개 · 임시 디스크 저장 후 회사/날짜 폴더 이동

- 프론트 PartDrawingMultiUploadButton (개발관리 공용):
  · 버튼 클릭 → 숨김 input(multiple, accept=.stp,.step,.dwg,.dxf,.pdf)
  · 확장자별 분류 + "총 N개 업로드?" confirm (wace 1:1 텍스트)
  · 결과 다이얼로그 — 총합/성공/품번 미존재/실패 + 파일별 상세표

- M1(part-regist): partNoList = 현재 그리드 rows.part_no 전달
- M2(part-search): partNoList 미전달 → 전체 part_mng 매칭

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 14:36:24 +09:00
parent 119f0f3f2e
commit 7218edc500
7 changed files with 597 additions and 0 deletions
@@ -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) {
+17
View File
@@ -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);
+253
View File
@@ -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<number> {
);
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>
): 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<DrawingMultiUploadResult> {
const pool = getPool();
// 1) 매칭 후보 PART 조회
// partNoList 지정 → 그 목록 IN 절로 제한 (M1 등록 화면, 현재 그리드 기반)
// partNoList 없음 → IS_LAST='1' 전체 (M2 조회 화면 — 페이지 밖 파트도 매칭 허용)
// (wace partMng.xml partMngListByPartNos: `<if PART_NO_LIST != null>` 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<string, string>();
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;
}