From 20a429eecb97c500b842df17693bfaec45a7bbad Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 11:43:29 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BOM?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=9A=B4=EC=98=81=ED=8C=90=201:1=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20=EB=8B=A4=EC=88=98=20=E2=80=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=ED=8F=BC=C2=B7=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=C2=B7=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=C2=B7STATUS=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 검증으로 발견된 5가지 함정 일괄 정정. (1) ebom-search 검색폼 운영판 1:1 — wace structureAscendingList.jsp 노출 필드만: - 제거: 프로젝트 OBJID (raw input), UNIT_CODE (raw input) 운영판도 고객사/프로젝트번호/유닛명 모두 주석 처리되어 노출 안 됨 - 유지: 품번 / 품명 / 표시 레벨 (1~5 select) - BomTreeFilter.search_level 추가 + ascending/descending CTE 에 T.lev <= $search_level::int (2) 품번/품명 자동완성 (wace select2-part 1:1): - 영업관리 PartSelect 는 item_info 마스터 기반 → 개발관리(part_mng)용 별도 컴포넌트 신설 - backend GET /api/development/part/options : IS_LAST='1' part_mng 전체 (영업관리 sales/parts 패턴) - frontend DevPartSelect.tsx : SmartSelect 캐시 + mode partNo/partName 분리 - ebom-search 페이지 단순 Input → DevPartSelect 교체 - 품번 선택 시 품명 자동 채움 / 품명 선택 시 품번 자동 채움 (운영판 select2-part 1:1) (3) BomReportStatusDialog 운영판 1:1 재작성 — wace structureStatusChangePopup.jsp: - 잘못된 점: read-only 박스 + 상태 select(create/changeDesign/deploy 3옵션) - 정정: 5필드 모두 편집 가능 (CommCodeSelect 제품구분 / 품번 input / 품명 input / Version input / 상태 Y/N 라디오) — 운영 매퍼 updateStructureStatus 5컬럼 UPDATE 1:1 - 헤더 파란 바 + 4컬럼 테이블(25%/75%) + 저장/닫기 중앙 배치 (운영판 스타일 1:1) (4) DataGrid id 매핑 — 체크박스 ID 키 불일치 함정: - DataGrid 는 row.id 로 체크박스 ID 관리, 백엔드 응답은 row.objid (postgres lowercase) - 결과: checkedIds[0] 가 undefined → 상태변경/수정/삭제 다이얼로그가 objid=undefined 로 열려 detail 호출 안 됨 → 빈 폼 표시 (사용자 지적 "기본 정보 표시 안됨") - 일괄 수정 (3 페이지) : ebom-regist / part-regist / part-search gridRows = useMemo(() => rows.map(r => ({ ...r, id: r.objid })), [rows]) 영업관리 페이지 동일 패턴 1:1 (5) STATUS_TITLE 매핑 운영판 1:1 — 운영 그리드는 'Y'/'N' 글자 그대로 표시: CASE UPPER(T.STATUS) WHEN 'CREATE' THEN '등록중' WHEN 'CHANGEDESIGN' THEN '설계변경미배포' WHEN 'DEPLOY' THEN '배포완료' ELSE COALESCE(T.STATUS, '') END AS STATUS_TITLE - 운영 매퍼는 ELSE '' 이지만 RPS 는 raw fallback (사용자 화면에서 식별 가능) - 'Y'/'N' 매핑 라벨 추가 → 운영 스크린샷 확인 후 제거 (운영판은 raw) 미해결 (별 작업): - 확정일 (DEPLOY_DATE) 표시 — 운영판은 별도 "배포" 액션 (deployBomReport 매퍼) 으로 채움. RPS ebom-regist 에 배포 버튼 미구현 → 신규 BOM 확정일 빈값. 별 PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/devPartController.ts | 20 +++ backend-node/src/routes/devPartRoutes.ts | 3 + backend-node/src/services/devBomService.ts | 17 +- .../development/ebom-regist/page.tsx | 5 +- .../development/ebom-search/page.tsx | 56 ++++--- .../development/part-regist/page.tsx | 5 +- .../development/part-search/page.tsx | 5 +- .../development/BomReportStatusDialog.tsx | 151 ++++++++++++------ .../components/development/DevPartSelect.tsx | 102 ++++++++++++ frontend/lib/api/devBom.ts | 1 + 10 files changed, 295 insertions(+), 70 deletions(-) create mode 100644 frontend/components/development/DevPartSelect.tsx diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts index affa0977..8dbc4a2b 100644 --- a/backend-node/src/controllers/devPartController.ts +++ b/backend-node/src/controllers/devPartController.ts @@ -117,6 +117,26 @@ export async function deploy(req: AuthenticatedRequest, res: Response) { // 운영판 wace: openPartExcelImportPopUp.jsp → partParsingExcelFile.do + partUploadSave.do // 본 RPS 구현: 파일을 메모리 파싱 → 검증 결과(NOTE 포함) 반환 / 저장 시 신규 part_no 만 INSERT. +// PART 자동완성 옵션 (IS_LAST='1' 전체) — wace select2-part 1:1 +// GET /api/development/part/options +// response: { rows: [{ objid, part_no, part_name }] } +export async function partOptions(req: AuthenticatedRequest, res: Response) { + try { + const pool = (await import("../database/db")).getPool(); + const r = await pool.query( + `SELECT OBJID::varchar AS objid, PART_NO AS part_no, PART_NAME AS part_name + FROM PART_MNG + WHERE COALESCE(IS_LAST,'') = '1' + AND PART_NO IS NOT NULL AND PART_NO <> '' + ORDER BY PART_NO` + ); + return res.json({ success: true, data: { rows: r.rows } }); + } catch (e: any) { + logger.error("PART options 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + export async function excelParse(req: AuthenticatedRequest, res: Response) { try { const file = (req as any).file as Express.Multer.File | undefined; diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts index d7dbd428..f72130db 100644 --- a/backend-node/src/routes/devPartRoutes.ts +++ b/backend-node/src/routes/devPartRoutes.ts @@ -27,6 +27,9 @@ router.get("/part/list", ctrl.getList); router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse); router.post("/part/excel-save", ctrl.excelSave); +// PART 자동완성 옵션 (select2-part 1:1) — /:objid 보다 위 +router.get("/part/options", ctrl.partOptions); + // 다중 삭제 (body: { objids: string[] }) — /:objid 보다 위 router.delete("/part", ctrl.removeMany); diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts index dfcf0d20..747ef180 100644 --- a/backend-node/src/services/devBomService.ts +++ b/backend-node/src/services/devBomService.ts @@ -50,6 +50,7 @@ export interface BomTreeFilter { unit_code?: string; search_part_no?: string; search_part_name?: string; + search_level?: string | number; // wace 1:1 — 1~5 표시 레벨 (lev <= search_level) } // ─── 공용 파라미터 빌더 ──────────────────────────────────── @@ -99,11 +100,12 @@ export async function list(filter: BomReportListFilter) { T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO, T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME, T.STATUS, + -- 운영판 wace 매퍼 1:1 (CREATE/CHANGEDESIGN/DEPLOY 만 라벨, 그 외 'Y'/'N' 등은 raw 표시) CASE UPPER(T.STATUS) WHEN 'CREATE' THEN '등록중' WHEN 'CHANGEDESIGN' THEN '설계변경미배포' WHEN 'DEPLOY' THEN '배포완료' - ELSE '' END AS STATUS_TITLE, + ELSE COALESCE(T.STATUS, '') END AS STATUS_TITLE, T.WRITER, UI.dept_name AS DEPT_NAME, UI.user_name AS USER_NAME, COALESCE(UI.dept_name || '/' || UI.user_name, '') AS DEPT_USER_NAME, T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE, @@ -236,6 +238,10 @@ export async function ascending(filter: BomTreeFilter) { finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_name}%`); } + if (filter.search_level) { + finalConds.push(`T.lev <= $${idx++}::int`); + params.push(filter.search_level); + } const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; const sql = ` @@ -445,6 +451,14 @@ export async function descending(filter: BomTreeFilter) { } const anchorWhere = anchorConds.join(" AND "); + // 표시 레벨 필터 (wace search_level 1:1) + const levelWhereParts: string[] = []; + if (filter.search_level) { + levelWhereParts.push(`T.lev <= $${idx++}::int`); + params.push(filter.search_level); + } + const levelWhere = levelWhereParts.length ? `WHERE ${levelWhereParts.join(" AND ")}` : ""; + const sql = ` WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS ( SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, @@ -472,6 +486,7 @@ export async function descending(filter: BomTreeFilter) { (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level FROM TREE T LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ${levelWhere} ORDER BY T.path `; const r = await pool.query(sql, params); diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index 2b8340f9..df1cf2a9 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -110,6 +110,9 @@ export default function EbomRegistPage() { [openTree], ); + // DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid (lowercase) 이므로 매핑 + const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]); + return (
@@ -177,7 +180,7 @@ export default function EbomRegistPage() {
-
- - setFilter({ ...filter, project_name: e.target.value })} - placeholder="project_mgmt.objid" /> - - - setFilter({ ...filter, unit_code: e.target.value })} - placeholder="pms_wbs_task.objid" /> - + {/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개 + (고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */} +
- setFilter({ ...filter, search_part_no: e.target.value })} - placeholder="part_no LIKE" /> + setFilter((prev) => ({ + ...prev, + search_part_no: v, + // 품번 선택 시 품명 자동 채움 (wace select2-part 1:1) + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> - setFilter({ ...filter, search_part_name: e.target.value })} - placeholder="part_name LIKE" /> + setFilter((prev) => ({ + ...prev, + search_part_name: v, + // 품명 선택 시 품번 자동 채움 + search_part_no: row?.part_no ?? prev.search_part_no, + }))} /> + + +
@@ -163,7 +179,7 @@ export default function EbomSearchPage() {
{direction === "descending" && (
- 역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다. + 역전개는 품번 또는 품명 검색 조건이 필요합니다.
)}
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 4717b1ed..a5e3be52 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -100,6 +100,9 @@ export default function PartRegistPage() { [], ); + // DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid 이므로 매핑 + const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]); + // 등록 const handleCreate = () => { setFormMode("create"); @@ -208,7 +211,7 @@ export default function PartRegistPage() {
rows.map((r) => ({ ...r, id: r.objid })), [rows]); + const handleCreate = () => { setFormMode("create"); setFormObjid(null); setFormOpen(true); }; @@ -171,7 +174,7 @@ export default function PartSearchPage() {
E-BOM 상태 변경 다이얼로그. -// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드. +// 개발관리 > E-BOM 상태 변경 다이얼로그 — wace structureStatusChangePopup.jsp 1:1 +// +// 운영 매퍼 updateStructureStatus 5필드 UPDATE : +// PRODUCT_CD / PART_NO / PART_NAME / REVISION / STATUS (모두 편집 가능) +// 상태는 Y / N 라디오 (운영판 그대로). +// +// 이전 구현은 read-only 요약 박스 + 상태 select(create/changeDesign/deploy) 였으나 +// 운영판과 완전히 달라 정정. import React, { useEffect, useState } from "react"; import { @@ -12,13 +18,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loader2, Save } from "lucide-react"; import { toast } from "sonner"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devBomApi, BomReportRow } from "@/lib/api/devBom"; -const STATUS_OPTIONS = [ - { code: "create", label: "등록중" }, - { code: "changeDesign", label: "설계변경미배포" }, - { code: "deploy", label: "배포완료" }, -]; +const PRODUCT_GROUP = "0000001"; // 제품구분 comm_code interface Props { open: boolean; @@ -28,9 +31,11 @@ interface Props { } export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) { - const [row, setRow] = useState(null); - const [status, setStatus] = useState(""); + const [productCd, setProductCd] = useState(""); + const [partNo, setPartNo] = useState(""); + const [partName, setPartName] = useState(""); const [version, setVersion] = useState(""); + const [status, setStatus] = useState(""); // 운영판 Y / N const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -39,16 +44,18 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr let alive = true; setLoading(true); devBomApi.detail(objid) - .then((data) => { + .then((data: BomReportRow | null) => { if (!alive) return; if (!data) { toast.error("E-BOM 보고서를 찾을 수 없습니다."); onOpenChange(false); return; } - setRow(data); - setStatus(data.status ?? ""); + setProductCd(data.product_cd ?? ""); + setPartNo(data.part_no ?? ""); + setPartName(data.part_name ?? ""); setVersion(data.revision ?? ""); + setStatus(data.status ?? ""); }) .catch((e: any) => { toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); @@ -58,14 +65,26 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr return () => { alive = false; }; }, [open, objid, onOpenChange]); + // wace fn_check 1:1 — 제품구분 / 품번 필수, 상태 필수 + const validate = (): string | null => { + if (!productCd) return "제품구분은 필수입니다."; + if (!partNo.trim()) return "품번은 필수입니다."; + if (!status) return "상태를 선택하세요."; + return null; + }; + const handleSave = async () => { if (!objid) return; - if (!status) return toast.error("상태를 선택하세요."); + const err = validate(); + if (err) return toast.error(err); setSaving(true); try { await devBomApi.updateStatus(objid, { - status, + product_cd: productCd, + part_no: partNo, + part_name: partName, version: version || undefined, + status, }); toast.success("상태가 변경되었습니다."); onSaved(); @@ -79,52 +98,92 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr return ( - - - E-BOM 상태 변경 + + + E-BOM 상태 변경 - {loading || !row ? ( -
+ {loading ? ( +
) : ( -
-
-
제품구분: {row.product_name ?? row.product_cd ?? "—"}
-
품번: {row.part_no ?? "—"}
-
품명: {row.part_name ?? "—"}
-
현재상태: {row.status_title ?? row.status ?? "—"}
-
-
- - -
-
- - setVersion(e.target.value)} placeholder="예: RE, A, B..." /> -
+
+ {/* 운영판 colgroup 1:1 (25% / 75%) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
제품구분 + +
품번 setPartNo(e.target.value)} />
품명 setPartName(e.target.value)} />
Version setVersion(e.target.value)} />
상태 +
+ + +
+
)} - - + +
); } + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +function Td({ children }: { children: React.ReactNode }) { + return {children}; +} +function Req() { + return *; +} diff --git a/frontend/components/development/DevPartSelect.tsx b/frontend/components/development/DevPartSelect.tsx new file mode 100644 index 00000000..d2d4944e --- /dev/null +++ b/frontend/components/development/DevPartSelect.tsx @@ -0,0 +1,102 @@ +"use client"; + +// 개발관리 PART 자동완성 셀렉트 — wace select2-part 1:1 (영업관리 PartSelect 패턴 동일). +// +// 영업관리 PartSelect 는 item_info(영업 마스터) 기반. +// 개발관리는 part_mng 기반이므로 별도 컴포넌트. +// +// - part_mng IS_LAST='1' 전체를 한 번 캐시 (objid 기준 단일 소스) +// - mode='partNo' : 라벨로 part_no 표시 +// - mode='partName': 라벨로 part_name 표시 +// - 선택 시 onValueChange(part_no/part_name 텍스트, 원본 row) — ebom-search 가 LIKE 가 아닌 +// 완전일치 검색을 하므로 value 는 텍스트 그대로 전달 + +import React, { useEffect, useState } from "react"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { apiClient } from "@/lib/api/client"; + +interface DevPartRow { + objid: string; + part_no: string; + part_name: string; +} + +interface DevPartSelectProps { + mode: "partNo" | "partName"; + /** 현재 선택값 — part_no 또는 part_name 텍스트 */ + value: string; + /** 선택 시 텍스트 + 원본 row */ + onValueChange: (value: string, row?: DevPartRow) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +let cachedRows: DevPartRow[] | null = null; +let inflight: Promise | null = null; + +const fetchParts = async (): Promise => { + if (cachedRows) return cachedRows; + if (inflight) return inflight; + inflight = (async () => { + const res = await apiClient.get("/development/part/options"); + const rows = (res.data?.data?.rows ?? []) as any[]; + cachedRows = rows + .filter((r) => r.objid != null) + .map((r) => ({ + objid: String(r.objid), + part_no: r.part_no ?? "", + part_name: r.part_name ?? "", + })); + return cachedRows!; + })(); + try { + return await inflight; + } finally { + inflight = null; + } +}; + +const toOptions = (rows: DevPartRow[], mode: DevPartSelectProps["mode"]): SmartSelectOption[] => { + const seen = new Set(); + const result: SmartSelectOption[] = []; + for (const r of rows) { + const label = mode === "partNo" ? r.part_no : r.part_name; + if (!label || seen.has(label)) continue; + seen.add(label); + result.push({ code: label, label }); + } + return result; +}; + +export function DevPartSelect({ + mode, value, onValueChange, + placeholder = mode === "partNo" ? "품번 입력하여 검색..." : "품명 입력하여 검색...", + disabled, className, +}: DevPartSelectProps) { + const [options, setOptions] = useState( + cachedRows ? toOptions(cachedRows, mode) : [], + ); + + useEffect(() => { + let alive = true; + fetchParts() + .then((rows) => { if (alive) setOptions(toOptions(rows, mode)); }) + .catch(() => {}); + return () => { alive = false; }; + }, [mode]); + + return ( + { + const row = cachedRows?.find((r) => (mode === "partNo" ? r.part_no : r.part_name) === v); + onValueChange(v, row); + }} + placeholder={placeholder} + disabled={disabled} + className={className} + /> + ); +} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 6da36456..865d0740 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -90,6 +90,7 @@ export interface BomTreeFilter { unit_code?: string; search_part_no?: string; search_part_name?: string; + search_level?: string | number; // wace 1:1 — 1~5 표시 레벨 } export interface BomTreeRow { From 68d2dcb32ea879bc075453e22527dbf1d925b108 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 12:11:08 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BOM?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=E2=80=94=20=EC=9A=B4=EC=98=81=ED=8C=90?= =?UTF-8?q?=201:1=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20+=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=20+=20=ED=92=88=EB=B2=88=20=EC=83=81=EC=84=B8=20+=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20anchor=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) 정전개 트리 화면 운영판 wace structureAscendingList.jsp 1:1 정정: - L1..LMaxLevel 컬럼 — row.lev 와 일치하는 컬럼에만 "*" 표시 (이전엔 품번 표시) - 별도 품번 컬럼 1개 (모든 행 part_no) - 3D/2D/PDF — renderType: "folder" (wace fnc_getFolderIcon 1:1) - 컬럼 운영판 1:1 : 품번/품명/수량/항목수량/3D/2D/PDF/재료/열처리경도/열처리방법/표면처리/메이커/범주이름/비고 - 제거 : 변경일/REV/규격/중량 (운영판 미사용) (2) 토글 -/+ 버튼 추가 (wace 트리 1:1): - 첫 컬럼 __toggle — 자식 있는 행만 − / + 표시, 클릭 시 자식 숨김/표시 - collapsedChildIds Set 상태로 접힘 관리 - ancestor 체인: parent_objid → 부모 행 child_objid 추적 (cycle guard) - 가시 행 필터: ancestor 중 하나라도 collapsed Set 에 있으면 hide → 자손 전체 숨김 - 새 조회 시 collapsed Set 초기화 (모두 펼침) (3) 품번 셀 클릭 → PartDetailDialog (wace partMngDetailPopUp 1:1): - row.part_no = part_mng.objid::varchar 이므로 그대로 detail dialog 의 objid 로 전달 - ebom-search 페이지에 PartDetailDialog 임포트 + state (4) 검색 필터 anchor 정정 (사용자 검증: 1행만 나오고 자식 안 풀림): - 이전: search_part_no/search_part_name 을 결과 단계 WHERE PM.part_no LIKE ... 로 적용 → 매칭 행 1개만 살아남고 자식 잘림 - 정정: anchor 단계에서 매칭된 PART 가 들어있는 bom_report_objid 전체를 startWhere 로 → 재귀 CTE 가 자식 모두 풀어냄 (운영판 1:1) - search_level (1~5) 은 결과 단계 유지 (트리 깊이 제한) - ascending / ascendingForExcel 양쪽 동일 패턴 (5) ascending SELECT 풀 컬럼 보강: - 추가 : item_qty(p_qty), heat_treatment_hardness/method, surface_treatment, maker, part_type, part_type_title (comm_code.code_name) - TREE CTE 컬럼에 item_qty 추가 - BomTreeRow 타입 동기 (lib/api/devBom.ts) (6) 상태변경 시 확정일(DEPLOY_DATE) 처리 — 사용자 요청: - status = 'Y' 변경 시 DEPLOY_DATE = TO_CHAR(NOW(), 'YYYY-MM-DD') 채움 (varchar) - 'N' 변경 시 기존 DEPLOY_DATE 보존 - $5 prepared statement 타입 추론 충돌 (varchar vs unknown) → $5::varchar 명시 캐스팅 - STATUS_TITLE 매핑은 운영판 1:1 — CREATE/CHANGEDESIGN/DEPLOY 만 라벨, Y/N 등은 raw 표시 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/services/devBomService.ts | 87 ++++++++---- .../development/ebom-search/page.tsx | 133 ++++++++++++++---- frontend/lib/api/devBom.ts | 8 ++ 3 files changed, 177 insertions(+), 51 deletions(-) diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts index 747ef180..98d3e7ee 100644 --- a/backend-node/src/services/devBomService.ts +++ b/backend-node/src/services/devBomService.ts @@ -163,15 +163,18 @@ export async function getByObjid(objid: string) { export async function updateStatus(userId: string, objid: string, body: BomReportStatusBody) { if (!body.status) throw new Error("status는 필수입니다."); + // RPS 정책: 상태를 'Y' 로 변경한 시점을 확정일(DEPLOY_DATE) 로 기록. + // 'N' 으로 변경 시는 기존 DEPLOY_DATE 보존 (마지막 확정 기록 유지). const sql = ` UPDATE PART_BOM_REPORT - SET PRODUCT_CD = COALESCE($1, PRODUCT_CD), - PART_NO = COALESCE($2, PART_NO), - PART_NAME = COALESCE($3, PART_NAME), - REVISION = COALESCE($4, REVISION), - STATUS = $5, - editer = $6, - edit_date = NOW() + SET PRODUCT_CD = COALESCE($1, PRODUCT_CD), + PART_NO = COALESCE($2, PART_NO), + PART_NAME = COALESCE($3, PART_NAME), + REVISION = COALESCE($4, REVISION), + STATUS = $5::varchar, + DEPLOY_DATE = CASE WHEN UPPER($5::varchar) = 'Y' THEN TO_CHAR(NOW(), 'YYYY-MM-DD') ELSE DEPLOY_DATE END, + editer = $6, + edit_date = NOW() WHERE OBJID = $7 `; const r = await getPool().query(sql, [ @@ -216,7 +219,9 @@ export async function ascending(filter: BomTreeFilter) { const conds: string[] = []; let idx = 1; - // 시작점 필터: 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘 + // 시작점 필터 (anchor): 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘. + // 품번/품명 검색은 결과 필터가 아니라 매칭된 PART 가 들어있는 BOM_REPORT 전체를 anchor 로 + // 잡아야 트리 자식들이 같이 풀림 (wace 운영판 동작 1:1). if (filter.bom_report_objid) { conds.push(`BP.bom_report_objid = $${idx++}`); params.push(filter.bom_report_objid); @@ -226,18 +231,32 @@ export async function ascending(filter: BomTreeFilter) { if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); } - const startWhere = conds.length ? conds.join(" AND ") : "1=1"; - - // PART 검색 필터는 결과 단계 적용 - const finalConds: string[] = []; if (filter.search_part_no) { - finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`); + conds.push(`BP.bom_report_objid IN ( + SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ + WHERE EXISTS ( + SELECT 1 FROM part_mng PMS + WHERE PMS.objid::varchar = BQ.part_no + AND UPPER(PMS.part_no) LIKE UPPER($${idx++}) + ) + )`); params.push(`%${filter.search_part_no}%`); } if (filter.search_part_name) { - finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); + conds.push(`BP.bom_report_objid IN ( + SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ + WHERE EXISTS ( + SELECT 1 FROM part_mng PMS + WHERE PMS.objid::varchar = BQ.part_no + AND UPPER(PMS.part_name) LIKE UPPER($${idx++}) + ) + )`); params.push(`%${filter.search_part_name}%`); } + const startWhere = conds.length ? conds.join(" AND ") : "1=1"; + + // 결과 단계 필터 — search_level 만 (트리 깊이 제한) + const finalConds: string[] = []; if (filter.search_level) { finalConds.push(`T.lev <= $${idx++}::int`); params.push(filter.search_level); @@ -245,25 +264,29 @@ export async function ascending(filter: BomTreeFilter) { const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; const sql = ` - WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS ( + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, item_qty, seq, status, lev, path, cycle) AS ( SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, - BP.part_no, BP.qty, BP.seq, BP.status, + BP.part_no, BP.qty, BP.item_qty, BP.seq, BP.status, 1, ARRAY[BP.objid::varchar], FALSE FROM bom_part_qty BP WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') AND ${startWhere} UNION ALL SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, - B.part_no, B.qty, B.seq, B.status, + B.part_no, B.qty, B.item_qty, B.seq, B.status, T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path) FROM bom_part_qty B JOIN TREE T ON B.parent_objid = T.child_objid AND NOT T.cycle ) SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status, T.lev, T.path, + T.item_qty AS p_qty, PM.part_no AS pm_part_no, PM.part_name AS pm_part_name, PM.spec, PM.material, PM.weight, PM.remark, + PM.heat_treatment_hardness, PM.heat_treatment_method, PM.surface_treatment, + PM.maker, PM.part_type, + CC.code_name AS part_type_title, PM.edit_date, PM.eo_no, PM.revision, (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, @@ -271,7 +294,8 @@ export async function ascending(filter: BomTreeFilter) { (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level FROM TREE T - LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + LEFT JOIN comm_code CC ON CC.code_id = PM.part_type ${finalWhere} ORDER BY T.path `; @@ -302,18 +326,33 @@ function buildAscendingExcelSql(filter: BomTreeFilter, startIdx: number) { if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); } - const startWhere = conds.length ? conds.join(" AND ") : "1=1"; - - const finalConds: string[] = []; + // 품번/품명 검색 — anchor 단계로 (매칭 PART 가 들어있는 BOM 전체 트리 표시) if (filter.search_part_no) { - finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`); + conds.push(`BP.bom_report_objid IN ( + SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ + WHERE EXISTS ( + SELECT 1 FROM part_mng PMS + WHERE PMS.objid::varchar = BQ.part_no + AND UPPER(PMS.part_no) LIKE UPPER($${idx++}) + ) + )`); params.push(`%${filter.search_part_no}%`); } if (filter.search_part_name) { - finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); + conds.push(`BP.bom_report_objid IN ( + SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ + WHERE EXISTS ( + SELECT 1 FROM part_mng PMS + WHERE PMS.objid::varchar = BQ.part_no + AND UPPER(PMS.part_name) LIKE UPPER($${idx++}) + ) + )`); params.push(`%${filter.search_part_name}%`); } - const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; + const startWhere = conds.length ? conds.join(" AND ") : "1=1"; + + // 엑셀은 search_level 적용 안 함 (전체 트리 다운로드가 자연스러움). 필요 시 추가. + const finalWhere = ""; return { params, startWhere, finalWhere }; } diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx index 3acd016f..567f27a6 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom"; import { DevPartSelect } from "@/components/development/DevPartSelect"; +import { PartDetailDialog } from "@/components/development/PartDetailDialog"; type Direction = "ascending" | "descending"; @@ -29,6 +30,11 @@ export default function EbomSearchPage() { const [maxLevel, setMaxLevel] = useState(0); const [loading, setLoading] = useState(false); const [exporting, setExporting] = useState(false); + // 토글 접힘 상태: 접힌 부모 행의 child_objid 집합 + const [collapsedChildIds, setCollapsedChildIds] = useState>(new Set()); + // PART 상세 다이얼로그 + const [partDetailOpen, setPartDetailOpen] = useState(false); + const [partDetailObjid, setPartDetailObjid] = useState(null); const runQuery = useCallback(async (dir: Direction) => { setLoading(true); @@ -38,6 +44,7 @@ export default function EbomSearchPage() { setRows(res.rows ?? []); setMaxLevel(Number(res.max_level) || 0); setDirection(dir); + setCollapsedChildIds(new Set()); // 새 조회 시 모두 펼침 } catch (e: any) { toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); } finally { @@ -64,44 +71,110 @@ export default function EbomSearchPage() { } }, [filter]); - // 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시 + // 자식 보유 행 식별 (다른 행이 parent_objid 로 참조하는 child_objid 집합) + const hasChildSet = useMemo(() => { + const s = new Set(); + for (const r of rows) if (r.parent_objid) s.add(String(r.parent_objid)); + return s; + }, [rows]); + + // 각 행의 ancestor child_objid 체인 (collapsed 검사용) + const ancestorsByChildId = useMemo(() => { + const byChild = new Map(); + for (const r of rows) if (r.child_objid) byChild.set(String(r.child_objid), r); + const result = new Map(); + for (const r of rows) { + if (!r.child_objid) continue; + const list: string[] = []; + let cur: string | null = r.parent_objid ? String(r.parent_objid) : null; + const guard = new Set(); + while (cur && !guard.has(cur)) { + list.push(cur); + guard.add(cur); + const p = byChild.get(cur); + cur = p?.parent_objid ? String(p.parent_objid) : null; + } + result.set(String(r.child_objid), list); + } + return result; + }, [rows]); + + // 토글 클릭 핸들러 — 부모 행의 child_objid 를 collapsed Set 에 toggle + const toggleCollapse = useCallback((row: any) => { + const childId = row.child_objid ? String(row.child_objid) : ""; + if (!childId || !hasChildSet.has(childId)) return; + setCollapsedChildIds((prev) => { + const next = new Set(prev); + if (next.has(childId)) next.delete(childId); + else next.add(childId); + return next; + }); + }, [hasChildSet]); + + // 운영판 structureAscendingList.jsp 1:1 + // - 첫 컬럼: -/+ 토글 (자식 있는 행만) + // - L1..LMaxLevel 컬럼은 해당 레벨에만 "*" 표시 (품번 표시 X) + // - 별도 품번 컬럼에 모든 행 part_no + // - 3D/2D/PDF 폴더 아이콘 (renderType: "folder") const columns: DataGridColumn[] = useMemo(() => { const levelCols: DataGridColumn[] = []; for (let i = 1; i <= Math.max(1, maxLevel); i++) { levelCols.push({ key: `__lev_${i}`, - label: `L${i}`, - width: "w-[140px]", + label: String(i), + width: "w-[36px]", + align: "center", }); } return [ + { key: "__toggle", label: "", width: "w-[36px]", align: "center", + onClick: (row: any) => toggleCollapse(row) }, ...levelCols, - { key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false }, - { key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" }, - { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, - { key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" }, - { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, - { key: "spec", label: "규격", width: "w-[120px]" }, - { key: "material", label: "재질", width: "w-[100px]" }, - { key: "weight", label: "중량", width: "w-[80px]", align: "right" }, - { key: "remark", label: "비고", minWidth: "min-w-[140px]" }, + // 품번 셀 클릭 → PART 상세 (wace partMngDetailPopUp 1:1). row.part_no = part_mng.objid 임. + { key: "pm_part_no", label: "품번", width: "w-[160px]", + onClick: (row: any) => { + if (row.part_no) { + setPartDetailObjid(String(row.part_no)); + setPartDetailOpen(true); + } + } }, + { key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" }, + { key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true }, + { key: "p_qty", label: "항목수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "center", renderType: "folder" }, + { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "center", renderType: "folder" }, + { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "center", renderType: "folder" }, + { key: "material", label: "재료", width: "w-[100px]" }, + { key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" }, + { key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" }, + { key: "surface_treatment", label: "표면처리", width: "w-[100px]" }, + { key: "maker", label: "메이커", width: "w-[110px]" }, + { key: "part_type_title", label: "범주 이름", width: "w-[100px]", align: "center" }, + { key: "remark", label: "비고", minWidth: "min-w-[140px]" }, ]; - }, [maxLevel]); + }, [maxLevel, toggleCollapse]); - // 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움 - const gridData = useMemo( - () => rows.map((r) => { - const expanded: any = { ...r }; - for (let i = 1; i <= Math.max(1, maxLevel); i++) { - expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : ""; - } - return expanded; - }), - [rows, maxLevel], - ); + // 가시 행: collapsed 부모를 ancestor 로 가진 행은 hide + // 행 데이터: __toggle 셀 + __lev_{i} "*" 표시 + const gridData = useMemo(() => { + return rows + .filter((r) => { + if (!r.child_objid) return true; + const ancestors = ancestorsByChildId.get(String(r.child_objid)) ?? []; + return !ancestors.some((a) => collapsedChildIds.has(a)); + }) + .map((r) => { + const expanded: any = { ...r }; + const lev = Number(r.lev ?? 0); + for (let i = 1; i <= Math.max(1, maxLevel); i++) { + expanded[`__lev_${i}`] = lev === i ? "*" : ""; + } + const childId = r.child_objid ? String(r.child_objid) : ""; + const hasChild = childId && hasChildSet.has(childId); + expanded.__toggle = hasChild ? (collapsedChildIds.has(childId) ? "+" : "−") : ""; + return expanded; + }); + }, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]); return (
@@ -194,6 +267,12 @@ export default function EbomSearchPage() { gridId={`development-ebom-search-${direction}`} />
+ +
); } diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 865d0740..d37c20e0 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -118,6 +118,14 @@ export interface BomTreeRow { cu02_cnt: number | string | null; cu03_cnt: number | string | null; max_level: number | string | null; + // 풀 컬럼 (운영판 1:1) + p_qty: string | number | null; + heat_treatment_hardness: string | null; + heat_treatment_method: string | null; + surface_treatment: string | null; + maker: string | null; + part_type: string | null; + part_type_title: string | null; } export interface BomTreeResponse { From b5bc7f36301eb0a9fbfce453ef9c3686b202d8c3 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 12:14:07 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>data-s?= =?UTF-8?q?ync=20=E2=80=94=20=EA=B8=B4=20BOM=20=EC=83=98=ED=94=8C=201?= =?UTF-8?q?=EA=B1=B4=20=EC=9A=B4=EC=98=81DB=20=E2=86=92=20RPS=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E-BOM 조회 트리 화면 검증용 긴 BOM 1건을 운영DB 에서 RPS 로 복사 (1회성). 선정 기준 : bom_part_qty 행 수 + 트리 깊이 큼. 대상 BOM : part_bom_report.objid = '1038014721' part_no = '21008-0109' / part_name = 'BS030-120H4A11-EN' 구조 : 126 행 / 4 레벨 (L1=1 / L2=37 / L3=62 / L4=26) 파일: - 03_long_bom_sample.sql : TEMP staging + ON CONFLICT DO NOTHING INSERT · pbr_stage / bpq_stage 두 TEMP 테이블에 \copy 로 적재 후 INSERT FROM SELECT · 재실행 안전 (ON CONFLICT 시 skip) - pbr_long.csv : part_bom_report 1행 (운영DB export, CSV HEADER) - bpq_long.csv : bom_part_qty 126행 (운영DB export, CSV HEADER, seq ORDER BY) - README.md : 02_sequences.sql / 03_long_bom_sample.sql 섹션 추가 용도: - 동적 LEVEL 컬럼 (L1..L4) "*" 표시 검증 - 토글 -/+ 버튼 동작 검증 (자식 보유 행 식별 + 자손 hide 체인) - search_level 1~5 필터 검증 - 정전개 엑셀 다운로드 검증 운영DB OBJID 그대로 사용 — RPS part_mng 가 운영DB와 동일 OBJID 보유 (이전 part_mng_sync 로 보장) 라서 PART 정보 매핑 정상. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../data-sync/03_long_bom_sample.sql | 76 +++++++++++ .../migration/development/data-sync/README.md | 33 +++++ .../development/data-sync/bpq_long.csv | 127 ++++++++++++++++++ .../development/data-sync/pbr_long.csv | 2 + 4 files changed, 238 insertions(+) create mode 100644 docs/migration/development/data-sync/03_long_bom_sample.sql create mode 100644 docs/migration/development/data-sync/bpq_long.csv create mode 100644 docs/migration/development/data-sync/pbr_long.csv diff --git a/docs/migration/development/data-sync/03_long_bom_sample.sql b/docs/migration/development/data-sync/03_long_bom_sample.sql new file mode 100644 index 00000000..f6cafe2c --- /dev/null +++ b/docs/migration/development/data-sync/03_long_bom_sample.sql @@ -0,0 +1,76 @@ +BEGIN; + +-- ─── PART_BOM_REPORT staging + INSERT ── +CREATE TEMP TABLE pbr_stage ( + objid varchar, + customer_objid varchar, + contract_objid varchar, + unit_code varchar, + status varchar, + writer varchar, + regdate timestamp, + multi_yn varchar, + multi_master_yn varchar, + multi_break_yn varchar, + multi_master_objid varchar, + product_cd varchar, + part_no varchar, + part_name varchar, + revision varchar, + note varchar, + editer varchar, + edit_date timestamp, + deploy_date varchar +); +\copy pbr_stage FROM '/tmp/pbr_long.csv' WITH CSV HEADER + +INSERT INTO part_bom_report ( + objid, customer_objid, contract_objid, unit_code, status, writer, regdate, + multi_yn, multi_master_yn, multi_break_yn, multi_master_objid, + product_cd, part_no, part_name, revision, note, editer, edit_date, deploy_date +) +SELECT + objid, customer_objid, contract_objid, unit_code, status, writer, regdate, + multi_yn, multi_master_yn, multi_break_yn, multi_master_objid, + product_cd, part_no, part_name, revision, note, editer, edit_date, deploy_date +FROM pbr_stage +ON CONFLICT (objid) DO NOTHING; + +-- ─── BOM_PART_QTY staging + INSERT ── +CREATE TEMP TABLE bpq_stage ( + bom_report_objid varchar, + objid varchar, + parent_objid varchar, + child_objid varchar, + parent_part_no varchar, + part_no varchar, + qty numeric, + item_qty numeric, + qty_temp numeric, + regdate timestamp, + writer varchar, + seq bigint, + status varchar, + last_part_objid varchar, + deploy_user_id varchar, + deploy_date varchar +); +\copy bpq_stage FROM '/tmp/bpq_long.csv' WITH CSV HEADER + +INSERT INTO bom_part_qty ( + bom_report_objid, objid, parent_objid, child_objid, parent_part_no, part_no, + qty, item_qty, qty_temp, regdate, writer, seq, status, last_part_objid, + deploy_user_id, deploy_date +) +SELECT + bom_report_objid, objid, parent_objid, child_objid, parent_part_no, part_no, + qty, item_qty, qty_temp, regdate, writer, seq, status, last_part_objid, + deploy_user_id, deploy_date +FROM bpq_stage +ON CONFLICT (objid) DO NOTHING; + +COMMIT; + +SELECT 'PART_BOM_REPORT:' AS label, COUNT(*) FROM part_bom_report WHERE objid = '1038014721' +UNION ALL +SELECT 'BOM_PART_QTY:', COUNT(*) FROM bom_part_qty WHERE bom_report_objid = '1038014721'; diff --git a/docs/migration/development/data-sync/README.md b/docs/migration/development/data-sync/README.md index 6d400fd7..0cf19a06 100644 --- a/docs/migration/development/data-sync/README.md +++ b/docs/migration/development/data-sync/README.md @@ -13,6 +13,7 @@ **왜 필요했나**: 2026-05-12 PART 상세 다이얼로그 검증 중 발견. 품번/품명만 표시되고 재료/규격/계정구분/조달구분/재고단위/관리단위/환산수량/LOT구분/사용여부/검사여부/SET품여부/의뢰여부 등 거의 모든 컬럼이 NULL. 운영DB 같은 part_no는 정상적으로 채워져 있어서 마이그레이션 누락이 원인. **동기화 대상 컬럼 20개**: + - 재료/형상: `material` / `heat_treatment_hardness` / `heat_treatment_method` / `surface_treatment` - 기본: `maker` / `part_type` / `spec` - ERP 분류: `acctfg` / `odrfg` / `unit_dc` / `unitmang_dc` / `unitchng_nb` @@ -67,3 +68,35 @@ SELECT part_no, material, spec, part_type, acctfg, odrfg, unit_dc, unitmang_dc, **1:1 정합성**: 운영DB의 컬럼 값을 그대로 복사. NULL 인 운영 컬럼은 RPS 도 NULL 유지 (덮어쓰기 안 함). `unitchng_nb` 만 numeric 캐스팅. **재실행 안전**: idempotent — 같은 데이터로 다시 UPDATE 만 일어남. + +--- + +## 02_sequences.sql + +wace 매퍼에서 쓰는 시퀀스 5종 중 RPS 에 없던 4종 신규 생성 + 운영 last_value 보다 큰 값으로 setval (PK 충돌 방지). 자세한 내용은 파일 상단 주석 참조. + +| 시퀀스 | last_value (RPS) | 매퍼 사용처 | +| --- | ---: | --- | +| `seq_bom_qty` | 200,000 | `partMng.relatePartInfo` (BOM_PART_QTY.SEQ) | +| `seq_as_no` | 1,000 | 영업관리 AS 번호 | +| `seq_comm_code` | 10,000 | comm_code 신규 | +| `seq_eo_no` | 1,000 | EO_NO 일부 매퍼 | +| `seq_ecr_no` | 33 (기존) | 설계변경 ECR 번호 | + +--- + +## 03_long_bom_sample.sql + +**대상**: BOM 트리 화면 검증용 긴 BOM 1건을 운영DB → RPS 복사. + +**선정**: `part_bom_report.objid = '1038014721'` / `21008-0109` / "BS030-120H4A11-EN" +· 126 행 / 4 레벨 (L1=1 / L2=37 / L3=62 / L4=26) + +**파일**: + +- `03_long_bom_sample.sql` — TEMP staging + ON CONFLICT DO NOTHING INSERT +- `pbr_long.csv` — part_bom_report 1행 +- `bpq_long.csv` — bom_part_qty 126행 + +**용도**: E-BOM 조회(M4) 트리 그리드의 동적 LEVEL 컬럼 / 토글 / search_level 등 검증. + diff --git a/docs/migration/development/data-sync/bpq_long.csv b/docs/migration/development/data-sync/bpq_long.csv new file mode 100644 index 00000000..d55eb093 --- /dev/null +++ b/docs/migration/development/data-sync/bpq_long.csv @@ -0,0 +1,127 @@ +bom_report_objid,objid,parent_objid,child_objid,parent_part_no,part_no,qty,item_qty,qty_temp,regdate,writer,seq,status,last_part_objid,deploy_user_id,deploy_date +1038014721,1559666144,"",-1521588791,"",1868257241,0,0,0,2026-05-08 08:14:28.083737,ljh0920,178720,deploy,,, +1038014721,-79232032,-1521588791,-548673548,1868257241,1868257116,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178721,deploy,,, +1038014721,-1208707322,-548673548,479232996,1868257116,1868256711,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178722,deploy,,, +1038014721,-554492118,-548673548,-1062058696,1868257116,1868253980,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178723,deploy,,, +1038014721,-1681690346,-548673548,-313600830,1868257116,1868254016,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178724,deploy,,, +1038014721,182473835,-548673548,1062103299,1868257116,1868258246,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178725,deploy,,, +1038014721,1088506840,-548673548,-1469793642,1868257116,1459128296,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178726,deploy,,, +1038014721,-1849356384,-1521588791,-101504493,1868257241,1868255034,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178727,deploy,,, +1038014721,-496874208,-1521588791,-525842843,1868257241,-1497736809,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178728,deploy,,, +1038014721,-1226877300,-1521588791,523888233,1868257241,1868255027,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178729,deploy,,, +1038014721,-1173153797,-1521588791,730726408,1868257241,1868255028,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178730,deploy,,, +1038014721,-1224950647,-1521588791,1465483573,1868257241,1868258184,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178731,deploy,,, +1038014721,-612476995,1465483573,-770903803,1868258184,1868257575,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178732,deploy,,, +1038014721,-1776822234,1465483573,-1875730121,1868258184,1868253981,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178733,deploy,,, +1038014721,-1885221953,1465483573,-457061488,1868258184,69781814,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178734,deploy,,, +1038014721,11848010,-1521588791,693690566,1868257241,1868254590,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178735,deploy,,, +1038014721,-1291563629,693690566,1086471646,1868254590,789989579,10,10,10,2026-05-08 08:14:28.083737,ljh0920,178736,deploy,,, +1038014721,55839955,693690566,-660175297,1868254590,-866631301,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178737,deploy,,, +1038014721,-1140742311,693690566,-1351446113,1868254590,1883006179,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178738,deploy,,, +1038014721,-1799177146,-1521588791,412565572,1868257241,1868254591,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178739,deploy,,, +1038014721,1247986216,412565572,-83067400,1868254591,1301285042,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178740,deploy,,, +1038014721,-305828681,412565572,-84477277,1868254591,69781814,13,13,13,2026-05-08 08:14:28.083737,ljh0920,178741,deploy,,, +1038014721,-674691834,412565572,-1490280298,1868254591,1883006179,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178742,deploy,,, +1038014721,-2131654934,-1521588791,1348902922,1868257241,1868254279,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178743,deploy,,, +1038014721,-272710303,1348902922,-2062553432,1868254279,789989579,10,10,10,2026-05-08 08:14:28.083737,ljh0920,178744,deploy,,, +1038014721,-563106651,1348902922,-610910948,1868254279,69781814,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178745,deploy,,, +1038014721,-2122512954,-1521588791,1610889892,1868257241,1868254592,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178746,deploy,,, +1038014721,561501355,1610889892,-1073466603,1868254592,-892520081,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178747,deploy,,, +1038014721,-1292299878,1610889892,-1307819463,1868254592,69781814,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178748,deploy,,, +1038014721,-1535862889,1610889892,1396125994,1868254592,1883006179,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178749,deploy,,, +1038014721,1180308429,1610889892,635632516,1868254592,852766930,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178750,deploy,,, +1038014721,-908382777,1610889892,2066530264,1868254592,2016821980,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178751,deploy,,, +1038014721,-420196801,-1521588791,1761199292,1868257241,1868255756,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178752,deploy,,, +1038014721,-195126666,1761199292,-1547748587,1868255756,1868254593,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178753,deploy,,, +1038014721,-188031930,1761199292,1507544955,1868255756,-543393122,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178754,deploy,,, +1038014721,-288068797,1761199292,-502406798,1868255756,1868258356,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178755,deploy,,, +1038014721,1495115457,-1521588791,-282885416,1868257241,1868254594,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178756,deploy,,, +1038014721,1654449278,-282885416,808062574,1868254594,2027026726,12,12,12,2026-05-08 08:14:28.083737,ljh0920,178757,deploy,,, +1038014721,2059483223,-282885416,-1010734071,1868254594,69781814,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178758,deploy,,, +1038014721,2124466730,-282885416,405044485,1868254594,1883006179,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178759,deploy,,, +1038014721,452340927,-282885416,392996411,1868254594,852766930,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178760,deploy,,, +1038014721,-13512735,-1521588791,-503094338,1868257241,1868254595,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178761,deploy,,, +1038014721,259035794,-503094338,1052438050,1868254595,789989579,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178762,deploy,,, +1038014721,-1619664303,-503094338,446284827,1868254595,-1463876694,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178763,deploy,,, +1038014721,1675597636,-1521588791,-1426334519,1868257241,1868255035,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178764,deploy,,, +1038014721,-99374109,-1521588791,632211520,1868257241,1868255029,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178765,deploy,,, +1038014721,-16914337,632211520,-1085590517,1868255029,-1936805828,25,25,25,2026-05-08 08:14:28.083737,ljh0920,178766,deploy,,, +1038014721,1790722312,-1521588791,504572091,1868257241,815268504,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178767,deploy,,, +1038014721,171615785,-1521588791,540571401,1868257241,1868255030,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178768,deploy,,, +1038014721,-1272025569,-1521588791,1821127350,1868257241,1868255031,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178769,deploy,,, +1038014721,850232760,-1521588791,2099233225,1868257241,1868258185,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178770,deploy,,, +1038014721,1391795032,2099233225,-1108878040,1868258185,1868257576,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178771,deploy,,, +1038014721,625393882,2099233225,311982288,1868258185,1868253981,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178772,deploy,,, +1038014721,997015823,2099233225,-44343182,1868258185,69781814,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178773,deploy,,, +1038014721,-724753189,-1521588791,2146471013,1868257241,1868256976,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178774,deploy,,, +1038014721,1032690744,2146471013,-220520918,1868256976,1868256471,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178775,deploy,,, +1038014721,-1979776989,2146471013,-1569918870,1868256976,1868257788,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178776,deploy,,, +1038014721,-1153608261,2146471013,-1916530498,1868256976,1868255032,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178777,deploy,,, +1038014721,1177586890,2146471013,659859734,1868256976,1868255033,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178778,deploy,,, +1038014721,21795217,2146471013,1147084321,1868256976,1868257577,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178779,deploy,,, +1038014721,1627361803,2146471013,-120916919,1868256976,1868255422,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178780,deploy,,, +1038014721,-254610461,2146471013,-774178023,1868256976,-1679287681,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178781,deploy,,, +1038014721,1740882428,2146471013,747417918,1868256976,1431955518,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178782,deploy,,, +1038014721,1522928195,-1521588791,2106356904,1868257241,1868254596,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178783,deploy,,, +1038014721,1959301400,2106356904,-711768632,1868254596,-1917057884,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178784,deploy,,, +1038014721,-1501923404,2106356904,1091067136,1868254596,1693163279,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178785,deploy,,, +1038014721,-341793378,2106356904,1866146965,1868254596,1883006179,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178786,deploy,,, +1038014721,176040139,-1521588791,-44825979,1868257241,1868258466,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178787,deploy,,, +1038014721,90638917,-1521588791,33570926,1868257241,1868258465,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178788,deploy,,, +1038014721,1188002408,-1521588791,-193198154,1868257241,1868253840,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178789,deploy,,, +1038014721,-1552304770,-193198154,1969390081,1868253840,309497336,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178790,deploy,,, +1038014721,644618032,-193198154,-1616400233,1868253840,199236446,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178791,deploy,,, +1038014721,1304651170,-1521588791,-1967028335,1868257241,126705965,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178792,deploy,,, +1038014721,1820838639,-1521588791,-1197754488,1868257241,1868254282,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178793,deploy,,, +1038014721,1834775819,-1197754488,-1824278144,1868254282,-273722357,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178794,deploy,,, +1038014721,300159773,-1521588791,-948899415,1868257241,1868255519,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178795,deploy,,, +1038014721,1622885044,-948899415,957913261,1868255519,-1880419582,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178796,deploy,,, +1038014721,-997859223,-948899415,1605228598,1868255519,69781814,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178797,deploy,,, +1038014721,12853128,-948899415,1081264382,1868255519,1883006179,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178798,deploy,,, +1038014721,1428133853,-948899415,2097824408,1868255519,518957565,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178799,deploy,,, +1038014721,548988722,-948899415,863414000,1868255519,-1467403985,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178800,deploy,,, +1038014721,1384966071,-1521588791,-399991343,1868257241,1868255151,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178801,deploy,,, +1038014721,-1554129280,-1521588791,1301275439,1868257241,1868256934,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178802,deploy,,, +1038014721,1067181965,1301275439,869948546,1868256934,1868253780,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178803,deploy,,, +1038014721,-1312260816,869948546,1709441427,1868253780,-1917057884,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178804,deploy,,, +1038014721,-394794243,869948546,1662860288,1868253780,69781814,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178805,deploy,,, +1038014721,-1543445738,869948546,653961274,1868253780,1883006179,5,5,5,2026-05-08 08:14:28.083737,ljh0920,178806,deploy,,, +1038014721,-135109401,869948546,-1402647890,1868253780,-1915838519,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178807,deploy,,, +1038014721,-2081401333,1301275439,627204356,1868256934,-801178400,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178808,deploy,,, +1038014721,-556732609,1301275439,-1073642891,1868256934,1868256519,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178809,deploy,,, +1038014721,-133221497,-1073642891,1112808004,1868256519,-69590343,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178810,deploy,,, +1038014721,-997575795,-1073642891,1529319178,1868256519,-911071404,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178811,deploy,,, +1038014721,387036841,-1073642891,780389519,1868256519,1351400071,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178812,deploy,,, +1038014721,-1068853409,1301275439,-1615328870,1868256934,1868254919,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178813,deploy,,, +1038014721,1854804733,1301275439,1187508190,1868256934,1868256427,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178814,deploy,,, +1038014721,1942539416,1187508190,-1165929390,1868256427,518957565,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178815,deploy,,, +1038014721,529469714,1187508190,-969879530,1868256427,-1467403985,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178816,deploy,,, +1038014721,-1897164427,1301275439,-1206121403,1868256934,1868256520,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178817,deploy,,, +1038014721,1275814859,-1206121403,728464068,1868256520,-911071404,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178818,deploy,,, +1038014721,-774727537,-1206121403,-1192984331,1868256520,1351400071,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178819,deploy,,, +1038014721,-1330895751,-1206121403,-859636087,1868256520,-385745086,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178820,deploy,,, +1038014721,866417402,-1206121403,-1103711664,1868256520,-1205468047,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178821,deploy,,, +1038014721,-1843274643,1301275439,-1259166155,1868256934,1868254283,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178822,deploy,,, +1038014721,1526486834,-1259166155,-880598421,1868254283,-879076832,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178823,deploy,,, +1038014721,-872086698,-1259166155,-92171525,1868254283,829507948,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178824,deploy,,, +1038014721,-620317895,-1259166155,1538215605,1868254283,5879525,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178825,deploy,,, +1038014721,-1676334475,-1259166155,393652962,1868254283,-157025406,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178826,deploy,,, +1038014721,-1295208669,-1259166155,-405141898,1868254283,-1161193983,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178827,deploy,,, +1038014721,-1930039767,-1259166155,-1539194842,1868254283,-1784471891,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178828,deploy,,, +1038014721,-429167191,-1259166155,-445371738,1868254283,1413264321,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178829,deploy,,, +1038014721,1373454102,-1259166155,1605103585,1868254283,93992113,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178830,deploy,,, +1038014721,243655681,-1259166155,1550855806,1868254283,-489485631,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178831,deploy,,, +1038014721,213493115,-1259166155,667467989,1868254283,-332507320,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178832,deploy,,, +1038014721,1301720059,-1259166155,549000728,1868254283,-2016119642,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178833,deploy,,, +1038014721,-1245314635,-1259166155,-1713555241,1868254283,-1305054978,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178834,deploy,,, +1038014721,-1640275935,-1259166155,-1365622202,1868254283,1724129637,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178835,deploy,,, +1038014721,1986920933,-1521588791,331838391,1868257241,1868254058,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178836,deploy,,, +1038014721,-1949165911,331838391,-449212195,1868254058,-37088311,10,10,10,2026-05-08 08:14:28.083737,ljh0920,178837,deploy,,, +1038014721,-916173285,-1521588791,2106704546,1868257241,1868254059,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178838,deploy,,, +1038014721,-480521680,2106704546,1446984359,1868254059,-37088311,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178839,deploy,,, +1038014721,595768906,-1521588791,-1287071095,1868257241,-317009000,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178840,deploy,,, +1038014721,361554923,-1521588791,-477898316,1868257241,875354438,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178841,deploy,,, +1038014721,-2131354745,-1521588791,1283584576,1868257241,-1379081967,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178842,deploy,,, +1038014721,2101454196,-1521588791,-5547296,1868257241,617104847,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178843,deploy,,, +1038014721,-298365494,-1521588791,655872743,1868257241,1672174622,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178844,deploy,,, +1038014721,1176323251,-1521588791,528759015,1868257241,127065992,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178845,deploy,,, diff --git a/docs/migration/development/data-sync/pbr_long.csv b/docs/migration/development/data-sync/pbr_long.csv new file mode 100644 index 00000000..72caa1d5 --- /dev/null +++ b/docs/migration/development/data-sync/pbr_long.csv @@ -0,0 +1,2 @@ +objid,customer_objid,contract_objid,unit_code,status,writer,regdate,multi_yn,multi_master_yn,multi_break_yn,multi_master_objid,product_cd,part_no,part_name,revision,note,editer,edit_date,deploy_date +1038014721,"","","",N,ljh0920,2026-05-07 09:02:04.887236,N,N,,,0001539,21008-0109,BS030-120H4A11-EN,"",,,, From 7d67a5ab1d706c994360422d0a48608b7da694a2 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 12:28:38 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>PART?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=C2=B7=EC=A1=B0=ED=9A=8C=20+=20E-BOM=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B2=80=EC=83=89=20=ED=8F=BC=20select2-p?= =?UTF-8?q?art=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20(=EC=9A=B4?= =?UTF-8?q?=EC=98=81=ED=8C=90=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 페이지 검색 폼이 단순 Input + 품번/품명 LIKE 였음. 운영판 wace 는 class="select2-part" 자동완성 (initPartSelect2Ajax) + 한쪽 선택 시 다른 쪽 자동 채움. 수정 페이지 (모두 DevPartSelect 적용): - part-regist/page.tsx (M1 PART 등록 — wace partMngTempList.jsp) - part-search/page.tsx (M2 PART 조회 — wace partMngList.jsp) - ebom-regist/page.tsx (M3 E-BOM 등록 — wace structureList.jsp) 동작: - 품번/품명 양쪽 DevPartSelect (part_mng IS_LAST='1' 캐시) - 품번 선택 → 품명 자동 채움 (row.part_name) - 품명 선택 → 품번 자동 채움 (row.part_no) - setFilter 함수형 업데이트로 동시 setState 충돌 방지 ebom-search 와 동일 패턴 통일 — DevPartSelect 1개 컴포넌트로 4 페이지 공유. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../development/ebom-regist/page.tsx | 26 +++++++++------- .../development/part-regist/page.tsx | 30 +++++++++++-------- .../development/part-search/page.tsx | 30 +++++++++++-------- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index df1cf2a9..a5a76a02 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -17,6 +17,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom"; import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog"; +import { DevPartSelect } from "@/components/development/DevPartSelect"; import { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog"; import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialog"; @@ -135,19 +136,24 @@ export default function EbomRegistPage() { )} + {/* wace structureList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */} - setFilter({ ...filter, search_part_no: e.target.value })} - placeholder="품번 LIKE" - /> + setFilter((prev) => ({ + ...prev, + search_part_no: v, + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> - setFilter({ ...filter, search_part_name: e.target.value })} - placeholder="품명 LIKE" - /> + setFilter((prev) => ({ + ...prev, + search_part_name: v, + search_part_no: row?.part_no ?? prev.search_part_no, + }))} />
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 a5e3be52..47f976f3 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 { DevPartSelect } from "@/components/development/DevPartSelect"; // wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY) const GRID_COLUMNS: DataGridColumn[] = [ @@ -157,21 +158,26 @@ export default function PartRegistPage() { {/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */}
-
+ {/* wace partMngTempList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */} +
- setFilter({ ...filter, search_part_no: e.target.value })} - placeholder="품번 LIKE" - /> + setFilter((prev) => ({ + ...prev, + search_part_no: v, + search_part_name: row?.part_name ?? prev.search_part_name, + }))} />
-
+
- setFilter({ ...filter, search_part_name: e.target.value })} - placeholder="품명 LIKE" - /> + setFilter((prev) => ({ + ...prev, + search_part_name: v, + search_part_no: row?.part_no ?? prev.search_part_no, + }))} />
+ )} + + ))} + + ) : ( + showEmptyHint && ( +
+ 등록된 파일이 없습니다. +
+ ) + )} +
+ ); +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let v = bytes; + let u = 0; + while (v >= 1024 && u < units.length - 1) { + v /= 1024; + u++; + } + return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`; +} diff --git a/frontend/components/development/PartDetailDialog.tsx b/frontend/components/development/PartDetailDialog.tsx index 720d83af..0e8313a4 100644 --- a/frontend/components/development/PartDetailDialog.tsx +++ b/frontend/components/development/PartDetailDialog.tsx @@ -5,7 +5,7 @@ // 운영판은 form 과 동일 화면을 disabled 로 표시 후 "수정" 클릭 시 활성화. // RPS 에서는 PartFormDialog 와 분리 유지 (호환). 본 다이얼로그는 Form 레이아웃 readonly + // 부속 정보 행 추가 (EO_NO / EO_DATE / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION)) + -// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) 표시. +// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) — AttachFileDropZone readonly (목록·다운로드). // // "수정" 버튼: 부모가 본 다이얼로그를 닫고 PartFormDialog(mode='edit') 오픈하도록 onEdit 콜백 호출. @@ -14,10 +14,11 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Loader2, Pencil, FileText } from "lucide-react"; +import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; import { devPartApi, PartRow } from "@/lib/api/devPart"; import { cn } from "@/lib/utils"; +import { AttachFileDropZone } from "@/components/common/AttachFileDropZone"; const LABEL_ODRFG: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; const LABEL_LOT_FG: Record = { "0": "미사용", "1": "사용" }; @@ -134,34 +135,45 @@ export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) { EO사유{row.change_option_name ?? row.change_option} - {/* CAD Data */} + {/* CAD Data — readonly (목록·다운로드만) */} CAD Data 3D - + 2D(Drawing) - + 2D(PDF) - + - -
- CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. -
)} @@ -208,18 +220,3 @@ function Ro({ children, align }: { children: React.ReactNode; align?: "left" | " ); } -function CadCount({ label, count }: { label: string; count: number }) { - if (count > 0) { - return ( -
- - {label} 첨부 {count.toLocaleString()}건 -
- ); - } - return ( -
- 첨부된 {label} 파일 없음 -
- ); -} diff --git a/frontend/components/development/PartFormDialog.tsx b/frontend/components/development/PartFormDialog.tsx index d7110842..2b3cab54 100644 --- a/frontend/components/development/PartFormDialog.tsx +++ b/frontend/components/development/PartFormDialog.tsx @@ -15,9 +15,10 @@ // ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여) // ⑪ 개당길이 | 개당소요량 // ⑫ 비고 (1행) -// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder +// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — wace fnc_setFileDropZone 3종 1:1 (DEV-7) // -// 신규: POST /api/development/part (운영 폼 22컬럼) +// 신규: POST /api/development/part (운영 폼 22컬럼) — part_objid 선채번해서 전달 +// (도면이 PART INSERT 전에 attach_file_info 로 먼저 들어갈 수 있으므로 wace resultMap.OBJID 패턴) // 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1) import React, { useCallback, useEffect, useState } from "react"; @@ -27,11 +28,13 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Loader2, Save, Upload } from "lucide-react"; +import { Loader2, Save } from "lucide-react"; import { toast } from "sonner"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart"; import { cn } from "@/lib/utils"; +import { createObjId } from "@/lib/utils/objidUtil"; +import { AttachFileDropZone } from "@/components/common/AttachFileDropZone"; // comm_code group ids (vexplor_rps DB) const GROUP_PART_TYPE = "0000062"; @@ -92,6 +95,10 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + // CAD Data 업로드 target_objid: + // - 수정 모드: editObjid 그대로 + // - 신규 모드: 다이얼로그 열릴 때 createObjId() 로 선채번 (wace partMngFormPopUp resultMap.OBJID 패턴) + const [partObjid, setPartObjid] = useState(null); const setField = useCallback( (key: K, value: FormState[K]) => @@ -100,9 +107,17 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: ); useEffect(() => { - if (!open) return; - if (isEdit && editObjid) loadDetail(editObjid); - else setForm(EMPTY_FORM); + if (!open) { + setPartObjid(null); + return; + } + if (isEdit && editObjid) { + setPartObjid(editObjid); + loadDetail(editObjid); + } else { + setForm(EMPTY_FORM); + setPartObjid(createObjId()); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -176,6 +191,8 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: toast.success("PART가 수정되었습니다."); } else { const body: PartCreateBody = { + // CAD Data 도면이 선업로드 되었을 수 있으므로 선채번된 objid 전달 (wace 1:1) + part_objid: partObjid ?? undefined, part_no: form.part_no, part_name: form.part_name, part_type: form.part_type, @@ -366,34 +383,43 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: onChange={(e) => setField("remark", e.target.value)} /> - {/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */} + {/* ⑬ CAD Data — wace fnc_setFileDropZone 3종 1:1 */} CAD Data 3D - + 2D(Drawing) - + 2D(PDF) - + - -
- CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. -
)} @@ -447,15 +473,6 @@ function BasicSelect({ ); } -function DropPlaceholder({ label }: { label: string }) { - return ( -
- - Drag & Drop Files Here ({label}) -
- ); -} - // ─── PartRow → FormState ──────────────────────────────────── function rowToForm(r: PartRow): FormState { diff --git a/frontend/lib/utils/objidUtil.ts b/frontend/lib/utils/objidUtil.ts new file mode 100644 index 00000000..1474ed2b --- /dev/null +++ b/frontend/lib/utils/objidUtil.ts @@ -0,0 +1,33 @@ +// part_mng / attach_file_info 등 wace 운영판 `objid bigint` 컬럼 채번 유틸. +// 백엔드 `backend-node/src/utils/objidUtil.ts` 와 1:1 동일 알고리즘. +// +// wace java `com.pms.common.CommonUtils.createObjId()` 1:1 이식: +// 1) UUID v4 생성 +// 2) 하이픈 제거 → 32 hex 문자열 +// 3) Java String.hashCode() (int32) 적용 +// 4) 결과 정수를 문자열로 반환 + +function javaStringHashCode(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + } + return h; +} + +function uuidv4(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // 폴백: getRandomValues 기반 RFC4122 v4 + const buf = new Uint8Array(16); + (crypto as Crypto).getRandomValues(buf); + buf[6] = (buf[6] & 0x0f) | 0x40; + buf[8] = (buf[8] & 0x3f) | 0x80; + const hex = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function createObjId(): string { + return String(javaStringHashCode(uuidv4().replace(/-/g, ""))); +} From 7218edc500cf15673484600298f94e682bcc2da0 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 14:36:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>PART?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=EB=8B=A4=EC=A4=91=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20M1=C2=B7M2=20[=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=97=85=EB=A1=9C=EB=93=9C]=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20+=20=ED=8C=8C=EC=9D=BC=EB=AA=85=E2=86=94=ED=92=88?= =?UTF-8?q?=EB=B2=88=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 분기 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) --- .../src/controllers/devPartController.ts | 37 +++ backend-node/src/routes/devPartRoutes.ts | 17 ++ backend-node/src/services/devPartService.ts | 253 ++++++++++++++++++ .../development/part-regist/page.tsx | 5 + .../development/part-search/page.tsx | 3 + .../PartDrawingMultiUploadButton.tsx | 243 +++++++++++++++++ frontend/lib/api/devPart.ts | 39 +++ 7 files changed, 597 insertions(+) create mode 100644 frontend/components/development/PartDrawingMultiUploadButton.tsx 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[]; +}