From ea6606da0cbac3ca857f095b927644febce73a76 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 16:14:10 +0900 Subject: [PATCH 01/11] =?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=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=8B=A0=EC=84=A4=20(PR-A)=20=E2=80=94=20wace=20pa?= =?UTF-8?q?rtMng=201:1=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend (M1+M2): - devPartService: listTemp/listRelease/getByObjid/create/update/deploy/removeMany - partMngBaseSimple SELECT + 추가 15컬럼(acctfg/odrfg/unit_dc/unitmang_dc/lot_fg 등) 라벨/CASE - deploy 트랜잭션 3단계 (isLastInit → part_mng_history INSERT → partMngDeploy + EO_NO 채번) - EO_NO 분기: is_longd='1'→EOB{yy}-{seq} / else EO{yy}-{seq} - objidUtil: wace CommonUtils.createObjId() 1:1 (bigint objid 채번) - DDL: 9 신규 테이블 + part_mng 15컬럼 ALTER (운영판 1:1 추출) frontend (M1+M2): - part-regist (M1) / part-search (M2): 23셀 그리드 + 검색폼 + 액션 - PartFormDialog: 등록/수정 통합 (mode prop, 4 섹션) - PartDetailDialog: 읽기 전용 + "수정" dispatch - AdminPageRenderer dynamic 임포트 2건 + menu_info URL spec 정렬 본 PR 제외 (별 PR): 도면 다중 업로드, ERP 업로드, Excel Import, BOM_PART_QTY R/W Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 2 + .../src/controllers/devPartController.ts | 126 +++++ backend-node/src/routes/devPartRoutes.ts | 28 + backend-node/src/services/devPartService.ts | 513 ++++++++++++++++++ .../src/services/devPartSqlFragments.ts | 125 +++++ backend-node/src/utils/objidUtil.ts | 24 + docs/migration/development/00-gap.md | 197 +++++++ docs/migration/development/01-part.md | 313 +++++++++++ .../ddl-extracted/300_part_bom.sql | 428 +++++++++++++++ .../ddl-extracted/301_alter_part_mng.sql | 45 ++ .../development/part-regist/page.tsx | 232 ++++++++ .../development/part-search/page.tsx | 195 +++++++ .../development/PartDetailDialog.tsx | 177 ++++++ .../components/development/PartFormDialog.tsx | 499 +++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 2 + frontend/lib/api/devPart.ts | 249 +++++++++ 16 files changed, 3155 insertions(+) create mode 100644 backend-node/src/controllers/devPartController.ts create mode 100644 backend-node/src/routes/devPartRoutes.ts create mode 100644 backend-node/src/services/devPartService.ts create mode 100644 backend-node/src/services/devPartSqlFragments.ts create mode 100644 backend-node/src/utils/objidUtil.ts create mode 100644 docs/migration/development/00-gap.md create mode 100644 docs/migration/development/01-part.md create mode 100644 docs/migration/development/ddl-extracted/300_part_bom.sql create mode 100644 docs/migration/development/ddl-extracted/301_alter_part_mng.sql create mode 100644 frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/development/part-search/page.tsx create mode 100644 frontend/components/development/PartDetailDialog.tsx create mode 100644 frontend/components/development/PartFormDialog.tsx create mode 100644 frontend/lib/api/devPart.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 574b49c8..9781ae35 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -177,6 +177,7 @@ import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+ import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers) import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) +import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -423,6 +424,7 @@ app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers) app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인) app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) +app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts new file mode 100644 index 00000000..ff550250 --- /dev/null +++ b/backend-node/src/controllers/devPartController.ts @@ -0,0 +1,126 @@ +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) 컨트롤러. +// 라우트: +// GET /api/development/part-temp/list (M1 그리드) +// POST /api/development/part-temp/deploy (M1 → M2 확정) +// GET /api/development/part/list (M2 그리드) +// GET /api/development/part/:objid (단건 상세) +// POST /api/development/part (신규 등록) +// PUT /api/development/part/:objid (상세 수정) +// DELETE /api/development/part (다중 삭제, body: { objids }) +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/devPartService"; +import { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.PartListFilter { + const filter: svc.PartListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + // status_arr 는 ?status_arr=a&status_arr=b 또는 콤마 직렬화 둘 다 수용 + if (q.status_arr) { + if (Array.isArray(q.status_arr)) filter.status_arr = q.status_arr.map(String); + else filter.status_arr = String(q.status_arr).split(",").map((s) => s.trim()).filter(Boolean); + } + return filter; +} + +// ─── M1 그리드 ────────────────────────────────────────────── + +export async function getTempList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listTemp(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("개발관리 PART(M1) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M2 그리드 ────────────────────────────────────────────── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listRelease(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("개발관리 PART(M2) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 단건 상세 ────────────────────────────────────────────── + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("개발관리 PART 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 신규 등록 ────────────────────────────────────────────── + +export async function create(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const objid = await svc.create(userId, req.body); + return res.status(201).json({ success: true, data: { objid }, message: "PART가 등록되었습니다." }); + } catch (e: any) { + logger.error("개발관리 PART 등록 실패", { error: e.message }); + return res.status(400).json({ success: false, message: e.message }); + } +} + +// ─── 상세 수정 ────────────────────────────────────────────── + +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const rowCount = await svc.update(objid, req.body); + if (rowCount === 0) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, message: "PART가 수정되었습니다." }); + } catch (e: any) { + logger.error("개발관리 PART 수정 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 확정 (M1 → M2) ───────────────────────────────────────── + +export async function deploy(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const result = await svc.deploy(userId, objids); + return res.json({ success: true, data: result, message: `${result.deployed}건이 확정되었습니다.` }); + } catch (e: any) { + logger.error("개발관리 PART 확정 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 다중 삭제 ────────────────────────────────────────────── + +export async function removeMany(req: AuthenticatedRequest, res: Response) { + try { + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const removed = await svc.removeMany(objids); + return res.json({ success: true, data: { removed }, message: `${removed}건이 삭제되었습니다.` }); + } catch (e: any) { + logger.error("개발관리 PART 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts new file mode 100644 index 00000000..ee0db031 --- /dev/null +++ b/backend-node/src/routes/devPartRoutes.ts @@ -0,0 +1,28 @@ +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) 라우트. +// app.ts: app.use("/api/development", devPartRoutes) +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/devPartController"; + +const router = Router(); +router.use(authenticateToken); + +// M1 — 임시(등록) 그리드 +router.get("/part-temp/list", ctrl.getTempList); +router.post("/part-temp/deploy", ctrl.deploy); + +// M2 — 릴리즈 그리드 +router.get("/part/list", ctrl.getList); + +// 다중 삭제 (body: { objids: string[] }) — /:objid 보다 위 +router.delete("/part", ctrl.removeMany); + +// 단건 + CRUD +router.get("/part/:objid", ctrl.getByObjid); +router.post("/part", ctrl.create); +router.put("/part/:objid", ctrl.update); + +export default router; diff --git a/backend-node/src/services/devPartService.ts b/backend-node/src/services/devPartService.ts new file mode 100644 index 00000000..1a9acb62 --- /dev/null +++ b/backend-node/src/services/devPartService.ts @@ -0,0 +1,513 @@ +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) — wace_plm partMng.xml 1:1 이식. +// +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/partMng.xml): +// partMngTempGridList → listTemp() (M1, status != 'release') +// partMngGridList → listRelease() (M2, status = 'release') +// partMngInfo → getByObjid() +// insertpartInfo → create() (38 컬럼 INSERT, wace 24 + 추가 15) +// updatePartDetail → update() (21 컬럼 UPDATE) +// partMngDeploy → deploy() (3-step 트랜잭션: isLastInit + history + deploy) +// partMngIsLastInit → (deploy 내부) +// insertPartMngHistory → (deploy 내부) +// partMngDelete → removeMany() +// +// 채번 정책: part_mng.objid 는 bigint. 클라이언트가 part_objid 안 보내면 +// wace CommonUtils.createObjId() 1:1 구현(objidUtil.createObjId) 사용. +// +// EO_NO 채번: IS_LONGD='1' 이면 EOB{yy}-{seq} / 아니면 EO{yy}-{seq}. wace 그대로. +// ============================================================ + +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; +import { PART_BASE_SIMPLE } from "./devPartSqlFragments"; + +// ─── 필터/바디 타입 ────────────────────────────────────────── + +export interface PartTempListFilter { + search_part_no?: string; + search_part_name?: string; + search_material?: string; + search_spec?: string; + search_part_type?: string; + writer?: string; + status?: string; + status_arr?: string[]; + product_code?: string; + upg_no?: string; + page?: number; + page_size?: number; +} + +export interface PartListFilter extends PartTempListFilter { + search_year?: string; + search_hardness?: string; + search_method?: string; + search_surface?: string; + customer_objid?: string; + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_design_date_from?: string; + search_design_date_to?: string; + is_last?: string; + eo?: string; +} + +export interface PartCreateBody { + part_objid?: string; + part_no: string; + part_name: string; + unit?: string; + qty?: string; + spec?: string; + material?: string; + thickness?: string; + width?: string; + height?: string; + out_diameter?: string; + in_diameter?: string; + length?: string; + remark?: string; + part_type: string; + product_mgmt_objid?: string; + supply_code?: string; + maker?: string; + contract_objid?: string; + post_processing?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + acctfg?: string; + odrfg?: string; + unit_dc?: string; + unitmang_dc?: string; + unitchng_nb?: string | number; + lot_fg?: string; + use_yn?: string; + qc_fg?: string; + setitem_fg?: string; + req_fg?: string; + unit_length?: string; + unit_qty?: string; +} + +export interface PartUpdateBody { + part_name?: string; + material?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + maker?: string; + part_type?: string; + acctfg?: string; + odrfg?: string; + spec?: string; + unit_dc?: string; + unitmang_dc?: string; + unitchng_nb?: string | number; + lot_fg?: string; + use_yn?: string; + qc_fg?: string; + setitem_fg?: string; + req_fg?: string; + unit_length?: string; + unit_qty?: string; + remark?: string; +} + +// ─── 공용 검색 절 생성 ────────────────────────────────────── + +function buildCommonWhere( + filter: PartTempListFilter & Partial, + startIdx: number +): { sql: string; params: any[] } { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.product_code) { + conds.push(`T.PRODUCT_MGMT_OBJID = $${idx++}`); + params.push(filter.product_code); + } + if (filter.upg_no) { + conds.push(`T.UPG_NO = $${idx++}`); + params.push(filter.upg_no); + } + if (filter.search_part_no) { + conds.push(`UPPER(T.PART_NO) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + conds.push(`UPPER(T.PART_NAME) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_name}%`); + } + if (filter.search_material) { + conds.push(`UPPER(T.MATERIAL) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_material}%`); + } + if (filter.search_spec) { + conds.push(`UPPER(T.SPEC) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_spec}%`); + } + if (filter.search_part_type) { + conds.push(`T.PART_TYPE = $${idx++}`); + params.push(filter.search_part_type); + } + if (filter.writer) { + conds.push(`T.WRITER = $${idx++}`); + params.push(filter.writer); + } + if (filter.status) { + conds.push(`T.STATUS = $${idx++}`); + params.push(filter.status); + } + if (filter.status_arr && filter.status_arr.length > 0) { + const placeholders = filter.status_arr.map(() => `$${idx++}`).join(","); + conds.push(`T.STATUS IN (${placeholders})`); + params.push(...filter.status_arr); + } + + // M2 전용 추가 필터 + if (filter.search_design_date_from) { + conds.push(`TO_DATE(T.DESIGN_DATE, 'YYYY-MM-DD') >= $${idx++}::timestamp`); + params.push(filter.search_design_date_from); + } + if (filter.search_design_date_to) { + conds.push(`TO_DATE(T.DESIGN_DATE, 'YYYY-MM-DD') <= $${idx++}::timestamp`); + params.push(filter.search_design_date_to); + } + if (filter.is_last) { + conds.push(`T.IS_LAST = $${idx++}`); + params.push(filter.is_last); + } + if (filter.eo) { + // wace 원본: AND T.EO_TEMP IS NULL OR EO_TEMP = '' — eo=1일 때만 적용 + conds.push(`(T.EO_TEMP IS NULL OR T.EO_TEMP = '')`); + } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }): { limit: number; offset: number; page: number; pageSize: number } { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 20)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── M1 그리드 (status != 'release') ──────────────────────── + +export async function listTemp(filter: PartTempListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildCommonWhere(filter, 1); + + // M1 추가 컬럼: PARTNER_TITLE, BOM_REPORT_OBJID/CHILD_OBJID/QTY/QTY_TEMP, Q_QTY, PARENT_PART_INFO, SORT + const baseSql = ` + SELECT T.*, + CASE WHEN T.REVISION IS NULL THEN '0' ELSE T.REVISION END AS SORT, + O.PARTNER_TITLE, + (SELECT PART_NO FROM PART_MNG SP WHERE SP.OBJID::varchar = Q.PARENT_PART_NO) AS PARENT_PART_INFO, + Q.BOM_REPORT_OBJID, + Q.OBJID AS OBJID_QTY, + Q.CHILD_OBJID, + Q.QTY AS Q_QTY_RAW, + Q.QTY_TEMP, + (CASE + WHEN Q.STATUS = 'deploy' THEN Q.QTY + WHEN (Q.QTY_TEMP IS NULL OR Q.QTY_TEMP = '') THEN Q.QTY + ELSE Q.QTY_TEMP + END) AS Q_QTY + FROM (${PART_BASE_SIMPLE}) T + LEFT JOIN ( + SELECT PART_OBJID, + ARRAY_TO_STRING(ARRAY_AGG(PARTNER_TITLE ORDER BY SEQ), ',') AS PARTNER_TITLE + FROM ( + SELECT OSM.PART_OBJID, OSM.SEQ, + OSM.SEQ || '. ' || (SELECT SUPPLY_NAME FROM ADMIN_SUPPLY_MNG + WHERE OBJID::varchar = OSM.PARTNER_OBJID::varchar) AS PARTNER_TITLE + FROM ORDER_SPEC_MNG OSM + ) OSMO + GROUP BY PART_OBJID + ) O ON T.OBJID::varchar = O.PART_OBJID::varchar + LEFT JOIN BOM_PART_QTY Q ON ( + T.OBJID::varchar IN ( + SELECT DISTINCT PM1.OBJID::varchar + FROM PART_MNG PM1, PART_MNG PM2 + WHERE PM1.STATUS = 'changing' + AND PM2.OBJID::varchar = Q.PART_NO + AND PM1.PART_NO = PM2.PART_NO + ) + AND Q.STATUS = 'beforeEdit' + ) + WHERE ${where.sql} + AND COALESCE(T.STATUS, '') <> 'release' + ORDER BY PARENT_PART_INFO NULLS LAST, T.PART_NO + `; + + const pool = getPool(); + const dataSql = `${baseSql} LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { + rows: dataRes.rows, + total: countRes.rows[0]?.total ?? 0, + page, + pageSize, + }; +} + +// ─── M2 그리드 (status = 'release') ───────────────────────── + +export async function listRelease(filter: PartListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildCommonWhere(filter, 1); + + const baseSql = ` + SELECT + ROW_NUMBER() OVER ( + ORDER BY T.PART_NO, + CASE WHEN T.REVISION LIKE 'RE%' THEN 0 ELSE 1 END, + T.REVISION DESC + ) AS NUM, + T.*, + /* wace 1:1 — PART_TYPE='0000063'(SET) 면 '1', 그 외엔 BOM_QTY 합 */ + CASE WHEN T.PART_TYPE = '0000063' THEN '1' + ELSE (SELECT SUM(CASE WHEN COALESCE(Q.QTY,'') = '' THEN 0 ELSE Q.QTY::numeric END)::varchar + FROM BOM_PART_QTY Q + WHERE Q.LAST_PART_OBJID = T.OBJID::varchar) + END AS BOM_QTY + FROM (${PART_BASE_SIMPLE}) T + WHERE ${where.sql} + AND T.STATUS = 'release' + ORDER BY T.PART_NO, + CASE WHEN T.REVISION LIKE 'RE%' THEN 0 ELSE 1 END, + T.REVISION DESC + `; + + const pool = getPool(); + const dataSql = `${baseSql} LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { + rows: dataRes.rows, + total: countRes.rows[0]?.total ?? 0, + page, + pageSize, + }; +} + +// ─── 단건 상세 ────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = `SELECT T.* FROM (${PART_BASE_SIMPLE}) T WHERE T.OBJID = $1`; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── 신규 등록 (insertpartInfo + 15 추가컬럼) ─────────────── + +export async function create(userId: string, body: PartCreateBody): Promise { + if (!body.part_no || !body.part_name) { + throw new Error("필수값 누락: part_no, part_name"); + } + const partObjid = body.part_objid && String(body.part_objid).trim() !== "" + ? String(body.part_objid) + : createObjId(); + + const sql = ` + INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, UNIT, QTY, SPEC, MATERIAL, + THICKNESS, WIDTH, HEIGHT, OUT_DIAMETER, IN_DIAMETER, LENGTH, + REMARK, STATUS, REG_DATE, WRITER, IS_LAST, + PART_TYPE, PRODUCT_MGMT_OBJID, SUPPLY_CODE, MAKER, CONTRACT_OBJID, POST_PROCESSING, + HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, + ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB, + LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG, UNIT_LENGTH, UNIT_QTY + ) VALUES ( + $1::numeric, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, + $14, 'create', NOW(), $15, '1', + $16, $17, $18, $19, $20, $21, + $22, $23, $24, + $25, $26, $27, $28, $29, + $30, $31, $32, $33, $34, $35, $36 + ) + `; + + const params = [ + partObjid, + body.part_no, body.part_name, body.unit ?? null, body.qty ?? null, body.spec ?? null, body.material ?? null, + body.thickness ?? null, body.width ?? null, body.height ?? null, body.out_diameter ?? null, body.in_diameter ?? null, body.length ?? null, + body.remark ?? null, userId, + body.part_type, body.product_mgmt_objid ?? null, body.supply_code ?? null, body.maker ?? null, body.contract_objid ?? null, body.post_processing ?? null, + body.heat_treatment_hardness ?? null, body.heat_treatment_method ?? null, body.surface_treatment ?? null, + body.acctfg ?? null, body.odrfg ?? null, body.unit_dc ?? null, body.unitmang_dc ?? null, body.unitchng_nb ?? null, + body.lot_fg ?? null, body.use_yn ?? null, body.qc_fg ?? null, body.setitem_fg ?? null, body.req_fg ?? null, body.unit_length ?? null, body.unit_qty ?? null, + ]; + + await getPool().query(sql, params); + return partObjid; +} + +// ─── 상세 수정 (updatePartDetail, 21 컬럼) ────────────────── + +export async function update(objid: string, body: PartUpdateBody): Promise { + const sql = ` + UPDATE PART_MNG SET + PART_NAME = $1, + MATERIAL = $2, + HEAT_TREATMENT_HARDNESS = $3, + HEAT_TREATMENT_METHOD = $4, + SURFACE_TREATMENT = $5, + MAKER = $6, + PART_TYPE = $7, + ACCTFG = $8, + ODRFG = $9, + SPEC = $10, + UNIT_DC = $11, + UNITMANG_DC = $12, + UNITCHNG_NB = $13, + LOT_FG = $14, + USE_YN = $15, + QC_FG = $16, + SETITEM_FG = $17, + REQ_FG = $18, + UNIT_LENGTH = $19, + UNIT_QTY = $20, + REMARK = $21, + EDIT_DATE = NOW() + WHERE OBJID = $22 + `; + const r = await getPool().query(sql, [ + body.part_name ?? null, body.material ?? null, + body.heat_treatment_hardness ?? null, body.heat_treatment_method ?? null, body.surface_treatment ?? null, + body.maker ?? null, body.part_type ?? null, + body.acctfg ?? null, body.odrfg ?? null, body.spec ?? null, + body.unit_dc ?? null, body.unitmang_dc ?? null, + body.unitchng_nb ?? null, + body.lot_fg ?? null, body.use_yn ?? null, body.qc_fg ?? null, + body.setitem_fg ?? null, body.req_fg ?? null, + body.unit_length ?? null, body.unit_qty ?? null, + body.remark ?? null, + objid, + ]); + return r.rowCount ?? 0; +} + +// ─── 확정 (M1→M2): 다중 objids 순차 트랜잭션 ──────────────── + +export async function deploy(userId: string, objids: string[]): Promise<{ deployed: number; eo_nos: Record }> { + if (!objids || objids.length === 0) return { deployed: 0, eo_nos: {} }; + + const eoNos: Record = {}; + let deployed = 0; + + await transaction(async (client: PoolClient) => { + for (const objid of objids) { + // 1) 동일 PART_NO 모두 IS_LAST='0' (wace partMngIsLastInit 1:1) + await client.query( + `UPDATE PART_MNG SET IS_LAST = '0', EDIT_DATE = NOW() + WHERE PART_NO = (SELECT PART_NO FROM PART_MNG WHERE OBJID = $1)`, + [objid] + ); + + // 2) PART_MNG_HISTORY 이력 INSERT (wace insertPartMngHistory 1:1, BOM_PART_QTY 미연계) + // BOM 컨텍스트(CHILD_OBJID/CHANGE_OPTION 등)는 deploy 단계에선 NULL. + await client.query( + `INSERT INTO PART_MNG_HISTORY ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, + qty, spec, material, weight, part_type, remark, + es_spec, ms_spec, change_option, design_apply_point, management_flag, + revision, status, reg_date, edit_date, writer, is_last, + eo_no, eo_temp, excel_upload_seq, sourcing_code, sub_material, + parent_part_no, design_date, eo_date, deploy_date, + thickness, width, height, out_diameter, in_diameter, length, + supply_code, change_type, contract_objid, maker, + his_reg_date, his_writer, his_status, + heat_treatment_hardness, heat_treatment_method, surface_treatment + ) + SELECT + P.OBJID::numeric, P.PRODUCT_MGMT_OBJID, P.UPG_NO, P.PART_NO, P.PART_NAME, P.UNIT, + P.QTY, P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REMARK, + P.ES_SPEC, P.MS_SPEC, P.CHANGE_OPTION, P.DESIGN_APPLY_POINT, P.MANAGEMENT_FLAG, + P.REVISION, P.STATUS, P.REG_DATE, NOW(), $2, P.IS_LAST, + P.EO_NO, P.EO_TEMP, P.EXCEL_UPLOAD_SEQ::varchar, P.SOURCING_CODE, P.SUB_MATERIAL, + P.PARENT_PART_NO, P.DESIGN_DATE, P.EO_DATE, P.DEPLOY_DATE, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SUPPLY_CODE, P.CHANGE_TYPE, P.CONTRACT_OBJID, P.MAKER, + NOW(), $2, 'DEPLOY', + P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT + FROM PART_MNG P + WHERE P.OBJID = $1`, + [objid, userId] + ); + + // 3) 본 행 deploy: IS_LAST='1', STATUS='release', DEPLOY_DATE=NOW(), + // REVISION=COALESCE→'RE', EO_DATE=오늘, EO_NO=신규 채번 (IS_LONGD에 따라 EOB/EO 분기). + const deployRes = await client.query<{ eo_no: string }>( + `UPDATE PART_MNG P SET + IS_LAST = '1', + EDIT_DATE = NOW(), + DEPLOY_DATE = NOW(), + STATUS = 'release', + REVISION = (CASE WHEN COALESCE(P.REVISION,'') = '' THEN 'RE' ELSE P.REVISION END), + EO_DATE = TO_CHAR(NOW(),'YYYY-MM-DD'), + EO_NO = ( + CASE WHEN P.IS_LONGD = '1' THEN + 'EOB' || TO_CHAR(NOW(),'yy') || '-' || LPAD( + (SELECT COALESCE(SUBSTR(MAX(SP.EO_NO), 7, 8)::integer + 1, 1)::varchar + FROM PART_MNG SP + WHERE SP.EO_NO LIKE 'EOB' || TO_CHAR(NOW(),'yy') || '-%' + AND SP.PART_NO <> P.PART_NO + AND COALESCE(SP.REVISION, '') <> COALESCE(P.REVISION, '') + ), 4, '0') + ELSE + 'EO' || TO_CHAR(NOW(),'yy') || '-' || LPAD( + (SELECT COALESCE(SUBSTR(MAX(SP.EO_NO), 6, 8)::integer + 1, 1)::varchar + FROM PART_MNG SP + WHERE SP.EO_NO IS NOT NULL + AND SP.EO_NO NOT LIKE 'EOB%' + AND SP.PART_NO <> P.PART_NO + AND COALESCE(SP.REVISION, '') <> COALESCE(P.REVISION, '') + ), 4, '0') + END + ) + WHERE OBJID = $1 + RETURNING EO_NO AS eo_no`, + [objid] + ); + + if (deployRes.rowCount && deployRes.rowCount > 0) { + deployed += deployRes.rowCount; + eoNos[objid] = deployRes.rows[0]?.eo_no ?? ""; + } + } + }); + + logger.info("PART deploy 완료", { count: deployed, userId }); + return { deployed, eo_nos: eoNos }; +} + +// ─── 다중 삭제 (wace partMngDelete — POSITION 트릭 → ANY 배열) ─ + +export async function removeMany(objids: string[]): Promise { + if (!objids || objids.length === 0) return 0; + // bigint 컬럼이므로 numeric[] 캐스팅 + const r = await getPool().query( + `DELETE FROM PART_MNG WHERE OBJID = ANY($1::numeric[])`, + [objids] + ); + return r.rowCount ?? 0; +} diff --git a/backend-node/src/services/devPartSqlFragments.ts b/backend-node/src/services/devPartSqlFragments.ts new file mode 100644 index 00000000..719d854d --- /dev/null +++ b/backend-node/src/services/devPartSqlFragments.ts @@ -0,0 +1,125 @@ +// ============================================================ +// 개발관리 PART (M1·M2·상세) 공용 SELECT fragment. +// wace_plm/src/com/pms/mapper/partMng.xml#partMngBaseSimple 1:1 이식 + +// vexplor_rps part_mng 의 15 추가컬럼(acctfg/odrfg/unit_dc/unitmang_dc/ +// lot_fg/use_yn/qc_fg/setitem_fg/req_fg/unit_length/unit_qty/ +// heat_treatment_hardness/heat_treatment_method/surface_treatment/unitchng_nb) +// 의 *_NM(comm_code 라벨) / Y/N CASE 변환 추가. +// +// 검색/페이징은 호출 측에서 WHERE 절·LIMIT/OFFSET 만 덧붙여 사용. +// ============================================================ + +/** + * partMngBaseSimple — wace 운영판 1:1. + * SELECT 컬럼만 정의. 호출 측에서 `${PART_BASE_SIMPLE} WHERE … ORDER BY …` 형태로 조합. + */ +export const PART_BASE_SIMPLE = ` + SELECT + -- 기본 컬럼 (wace partMngBaseSimple 1:1) + P.OBJID, + P.PART_NO, + P.PART_NAME, + P.PRODUCT_MGMT_OBJID, + P.UPG_NO, + P.UNIT, + CC_UNIT.code_name AS UNIT_TITLE, + COALESCE( + (SELECT QTY FROM BOM_PART_QTY Q + WHERE Q.LAST_PART_OBJID = P.OBJID::varchar + AND Q.STATUS = 'deploy' + ORDER BY Q.DEPLOY_DATE DESC LIMIT 1), + P.QTY + ) AS QTY, + P.SPEC, + P.POST_PROCESSING, + P.MATERIAL, + P.WEIGHT, + P.PART_TYPE, + CC_PART.code_name AS PART_TYPE_TITLE, + P.REMARK, + P.ES_SPEC, + P.MS_SPEC, + P.CHANGE_TYPE, + P.DESIGN_APPLY_POINT, + P.CHANGE_OPTION, + -- CHANGE_OPTION 다중 라벨 ARRAY_AGG (wace 1:1) + (SELECT ARRAY_TO_STRING(ARRAY_AGG(CC.code_name), ',') + FROM COMM_CODE CC + WHERE CC.code_id IN ( + SELECT UNNEST(STRING_TO_ARRAY(P.CHANGE_OPTION, ',')) + )) AS CHANGE_OPTION_NAME, + P.MANAGEMENT_FLAG, + P.REVISION, + P.STATUS, + P.REG_DATE, + TO_CHAR(P.REG_DATE, 'YYYY-MM-DD') AS PART_REGDATE_TITLE, + P.EDIT_DATE, + P.WRITER, + P.IS_LAST, + P.IS_LONGD, + P.EO_DATE, + P.EO_NO, + P.EO_TEMP, + P.MAKER, + P.CONTRACT_OBJID, + P.THICKNESS, + P.WIDTH, + P.HEIGHT, + P.OUT_DIAMETER, + P.IN_DIAMETER, + P.LENGTH, + P.SOURCING_CODE, + P.SUPPLY_CODE, + SUP.SUPPLY_NAME AS SUPPLY_NAME, + P.SUB_MATERIAL, + P.PARENT_PART_NO, + P.DESIGN_DATE, + P.DEPLOY_DATE, + P.EXCEL_UPLOAD_SEQ, + + -- 첨부 파일 카운트 + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar AND F.DOC_TYPE = '3D_CAD') AS CU01_CNT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar AND F.DOC_TYPE = '2D_DRAWING_CAD') AS CU02_CNT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar AND F.DOC_TYPE = '2D_PDF_CAD') AS CU03_CNT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar + AND F.DOC_TYPE IN ('2D_PDF_CAD','2D_DRAWING_CAD')) AS CU_TOTAL_CNT, + + -- 추가 15컬럼(301_alter_part_mng.sql) + 라벨 + P.HEAT_TREATMENT_HARDNESS, + P.HEAT_TREATMENT_METHOD, + P.SURFACE_TREATMENT, + P.ACCTFG, + CC_ACCT.code_name AS ACCTFG_NM, + P.ODRFG, + CC_ODR.code_name AS ODRFG_NM, + P.UNIT_DC, + CC_UDC.code_name AS UNIT_DC_NM, + P.UNITMANG_DC, + CC_UMDC.code_name AS UNITMANG_DC_NM, + P.UNITCHNG_NB, + P.LOT_FG, + CASE WHEN P.LOT_FG = '1' THEN '예' WHEN P.LOT_FG = '0' THEN '아니오' ELSE '' END AS LOT_FG_NM, + P.USE_YN, + CASE WHEN P.USE_YN = '1' THEN '예' WHEN P.USE_YN = '0' THEN '아니오' ELSE '' END AS USE_YN_NM, + P.QC_FG, + CASE WHEN P.QC_FG = '1' THEN '예' WHEN P.QC_FG = '0' THEN '아니오' ELSE '' END AS QC_FG_NM, + P.SETITEM_FG, + CASE WHEN P.SETITEM_FG = '1' THEN '예' WHEN P.SETITEM_FG = '0' THEN '아니오' ELSE '' END AS SETITEM_FG_NM, + P.REQ_FG, + CASE WHEN P.REQ_FG = '1' THEN '예' WHEN P.REQ_FG = '0' THEN '아니오' ELSE '' END AS REQ_FG_NM, + P.UNIT_LENGTH, + P.UNIT_QTY + + FROM PART_MNG P + LEFT JOIN COMM_CODE CC_UNIT ON CC_UNIT.code_id = P.UNIT AND CC_UNIT.status = 'active' + LEFT JOIN COMM_CODE CC_PART ON CC_PART.code_id = P.PART_TYPE AND CC_PART.status = 'active' + LEFT JOIN COMM_CODE CC_ACCT ON CC_ACCT.code_id = P.ACCTFG AND CC_ACCT.status = 'active' + LEFT JOIN COMM_CODE CC_ODR ON CC_ODR.code_id = P.ODRFG AND CC_ODR.status = 'active' + LEFT JOIN COMM_CODE CC_UDC ON CC_UDC.code_id = P.UNIT_DC AND CC_UDC.status = 'active' + LEFT JOIN COMM_CODE CC_UMDC ON CC_UMDC.code_id = P.UNITMANG_DC AND CC_UMDC.status = 'active' + LEFT JOIN admin_supply_mng SUP ON SUP.OBJID::varchar = P.SUPPLY_CODE +`; diff --git a/backend-node/src/utils/objidUtil.ts b/backend-node/src/utils/objidUtil.ts new file mode 100644 index 00000000..6c502f0b --- /dev/null +++ b/backend-node/src/utils/objidUtil.ts @@ -0,0 +1,24 @@ +// ============================================================ +// part_mng / part_mng_history / attach_file_info 등 wace 운영판 +// `objid bigint`(또는 numeric) 컬럼 채번 유틸. +// +// wace java `com.pms.common.CommonUtils.createObjId()` 1:1 이식: +// 1) UUID v4 생성 +// 2) 하이픈 제거 → 32 hex 문자열 +// 3) Java String.hashCode() (int32) 적용 +// 4) 결과 정수를 문자열로 반환 +// 결과 범위: -2,147,483,648 ~ 2,147,483,647 (Java int 범위). +// ============================================================ +import { randomUUID } from "crypto"; + +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; +} + +export function createObjId(): string { + return String(javaStringHashCode(randomUUID().replace(/-/g, ""))); +} diff --git a/docs/migration/development/00-gap.md b/docs/migration/development/00-gap.md new file mode 100644 index 00000000..1c8badbc --- /dev/null +++ b/docs/migration/development/00-gap.md @@ -0,0 +1,197 @@ +# 개발관리 이식 GAP 분석 (원본 wace_plm 대비) + +> 작성: 2026-05-12 / 작성자: hjjeong +> 대상 메뉴 5종 (1 도메인 `development/`): +> - PART 등록 / PART 조회 / E-BOM 등록 / E-BOM 조회 / 설계변경 리스트 +> 원본 위치: `wace_plm/WebContent/WEB-INF/view/partMng/` (단일 디렉토리) + `mapper/partMng.xml` 단일 매퍼. + +--- + +## 0. 한 문장 요약 + +5개 메뉴 모두 wace `partMng/` 단일 디렉토리 + `partMng.xml` 매퍼에 1:1 매핑됨. 의존 테이블 15개 중 **6개 보유(`part_mng`/`comm_code`/`pms_wbs_task`/`project_mgmt`/`user_info`/`product_mgmt`)** · **9개 신규 추가 완료(`300_part_bom.sql`)** · **`part_mng`에 누락 15컬럼 ALTER 완료(`301_alter_part_mng.sql`)**. 5개 메뉴 모두 P1에서 실데이터 표시 가능. + +## 0.1 이식 원칙 + +- JSP/매퍼XML 안의 `/* */`, ``, `//` 주석 블록은 비활성. 활성 코드만 이식. +- `company_code` 멀티테넌시 분기는 vexplor_rps 측에 만들지 않음 (COMPANY_16 단독). +- `CODE_NAME()`은 영업/프로젝트와 동일하게 `LEFT JOIN comm_code CC_X ON CC_X.code_id=...` 패턴 통일. +- `client_mng`/`supply_mng` → vexplor는 `customer_mng`로 통합되어 있으나, 개발관리 5개 메뉴는 `customer_mng`를 직접 참조하지 않음(`project_mgmt.customer_objid` 경유). 분기 변환 불필요. +- 금액 1,234.00 / 수량 1,234 / 모든 숫자 right-align (memory `feedback_number_format.md`). +- wace JSP 컬럼 정의 끝의 주석 블록은 비활성 항목 — grep만으로 카운트하지 말 것 (memory `feedback_wace_jsp_columns.md`). + +--- + +## 1. 메뉴 ↔ JSP ↔ 매퍼 1:1 매핑 + +| # | 메뉴 | wace JSP | 매퍼 쿼리 (partMng.xml) | LOC | +|---|---|---|---|---:| +| M1 | **PART 등록** | `partMngTempList.jsp` | `partMngTempGridList` (S), `partMngDeploy` (U), `partMngDelete` (D) | 649 | +| M2 | **PART 조회** | `partMngList.jsp` | `partMngGridList` (S), `partMngDelete` (D), `partMngFormPopUp` (S) | 834 | +| M3 | **E-BOM 등록** | `structureList.jsp` | `getBOMStandardStructureGridList` (S), `deleteStructure` (D), `structureStatusChange` (U) | 782 | +| M4 | **E-BOM 조회** | `structureAscendingList.jsp` | `structureAscendingList`/`structureAscendingListExcel`/`structureDescendingExcelList` (S) | 1,064 | +| M5 | **설계변경 리스트** | `partMngHisList.jsp` | `partMngHistList` (S, read-only) | 198 | + +vexplor_rps 측 라우트(예정): + +``` +GET /api/development/part-temp/list (M1 그리드) +POST /api/development/part-temp/deploy (M1 확정) +DEL /api/development/part-temp (M1·M2 삭제 공용) +GET /api/development/part/list (M2 그리드) +GET /api/development/part/:objid (M2 상세 팝업) +GET /api/development/ebom/list (M3 그리드) +PUT /api/development/ebom/status (M3 상태변경) +DEL /api/development/ebom/:objid (M3 삭제) +GET /api/development/ebom/ascending (M4 정전개) +GET /api/development/ebom/descending (M4 역전개) +GET /api/development/eo/history/list (M5 그리드) +``` + +--- + +## 2. 메뉴별 검색 필드 & 그리드 컬럼 (활성만) + +### M1 PART 등록 (`partMngTempList.jsp`) + +**검색**: SEARCH_PART_NO, SEARCH_PART_NAME (둘 다 autocomplete) +**그리드 23셀**: PART_NO · PART_NAME · CU01_CNT(3D) · CU02_CNT(2D) · CU03_CNT(PDF) · MATERIAL · HEAT_TREATMENT_HARDNESS · HEAT_TREATMENT_METHOD · SURFACE_TREATMENT · MAKER · PART_TYPE_TITLE · SPEC · ACCTFG_NM · ODRFG_NM · UNIT_DC_NM · UNITMANG_DC_NM · UNITCHNG_NB · LOT_FG_NM · USE_YN_NM · QC_FG_NM · SETITEM_FG_NM · REQ_FG_NM · UNIT_LENGTH/QTY +**액션**: 확정(Deploy) · 삭제 · 등록 · Excel Upload · 도면다중업로드 · 조회 +**팝업**: partMngFormPopUp(신규) · partMngDetailPopUp(편집) · openPartExcelImportPopUp +**핵심 의존 테이블**: `part_mng` (메인) · `order_spec_mng` · `admin_supply_mng` · `bom_part_qty` + +### M2 PART 조회 (`partMngList.jsp`) + +**검색**: 없음(메인 조회 화면). 그리드 컬럼 동일하게 23셀(M1과 동일). +**액션**: 등록 · 삭제 · 도면연동 · ERP업로드(전체/단일/모두) · Excel Upload · 조회 +**팝업**: partMngFormPopUp · partMngDetailPopUp · FileRegistPopup · openPartExcelImportPopUp +**핵심 의존 테이블**: `part_mng` · `bom_part_qty` + +### M3 E-BOM 등록 (`structureList.jsp`) + +**검색 9 필드**: customer_cd · project_name · unit_code · SEARCH_UNIT_NAME · SEARCH_WRITER · product_cd · SEARCH_PART_NO · SEARCH_PART_NAME · search_fromDate~toDate · status +**그리드 9셀**: PRODUCT_NAME · PART_NO · PART_NAME · BOM_CNT · DEPT_USER_NAME · REG_DATE · DEPLOY_DATE · REVISION · STATUS +**액션**: 조회 · 삭제 · E-BOM등록 · 상태변경 +**팝업**: setStructureStandardFormPopup · setBomCopyFormPopup · setStructurePopupMainFS · changeDesignNotePopUp · structureStatusChangePopup · openBomReportExcelImportPopUp +**핵심 의존 테이블**: `part_bom_report` · `supply_mng` · `project_mgmt` · `pms_wbs_task` · `user_info` · `bom_part_qty` · `part_mng` · `comm_code` + +### M4 E-BOM 조회 (`structureAscendingList.jsp`) + +**검색 4 필드**: project_name · unit_code · search_partNo · search_partName +**그리드**: 동적 — MAX_LEVEL 레벨 컬럼 + 품번 · 품명 · 3D/2D/PDF · 수량 · 변경일 · 변경항목 · 규격 · 재질 · 중량 · 비고 +**액션**: 정전개조회 · 역전개조회 · 엑셀다운로드(정/역전개) +**팝업**: partMngDetailPopUp(클릭) · FileRegistPopup(도면) +**핵심 의존 테이블**: `bom_part_qty` · `sales_bom_report` · `part_bom_report` · `product_mgmt_upg_detail`/`_master` · `part_mng` · `project_mgmt` · `pms_wbs_task` · `user_info` · `product_mgmt` + +### M5 설계변경 리스트 (`partMngHisList.jsp`) + +**검색 10 필드**: Year · contract_objid · unit_code · part_no · part_name · change_option · eo_start_date~end_date · change_type · part_type · writer_id +**그리드 16셀**: EO_NO · PROJECT_NO · PROJECT_NAME · UNIT_NAME · PARENT_PART_INFO · PART_NO · PART_NAME · QTY · QTY_TEMP · CHANGE_TYPE_NAME · CHANGE_OPTION_NAME · REVISION · EO_DATE · PART_TYPE_NAME · WRITER_NAME · HIS_REG_DATE_TITLE +**액션**: 조회만(Read-Only) +**팝업**: partMngHisDetailPopUp(행 클릭) +**핵심 의존 테이블**: `part_mng_history` · `project_mgmt` · `part_bom_report` · `pms_wbs_task` · `user_info` · `comm_code` + +--- + +## 3. RPS DB 보유 매트릭스 (적용 완료) + +| 테이블 | M1 | M2 | M3 | M4 | M5 | 종류 | RPS 상태 | +|---|:-:|:-:|:-:|:-:|:-:|---|---| +| `part_mng` | R/W | R/W | R | R | R | 메인 | ✅ +15컬럼 ALTER(`301_alter_part_mng.sql`) | +| `bom_part_qty` | R | R | R/W | R | R | BOM 수량 | ✅ 신규(`300`) | +| `part_bom_report` | R | R | R/W | R | R | BOM 리포트 헤더 | ✅ 신규(`300`) | +| `part_mng_history` | – | – | – | – | R | 변경이력 | ✅ 신규(`300`) | +| `order_spec_mng` | R | – | – | – | – | 발주 스펙 | ✅ 신규(`300`) | +| `admin_supply_mng` | R | – | – | – | – | 공급사(관리자) | ✅ 신규(`300`) | +| `supply_mng` | – | – | R | – | – | 공급사 | ✅ 신규(`300`) | +| `sales_bom_report` | – | – | – | R | – | 영업 BOM 단가 | ✅ 신규(`300`) | +| `product_mgmt_upg_master` | – | – | – | R | – | 제품 업그레이드 마스터 | ✅ 신규(`300`) | +| `product_mgmt_upg_detail` | – | – | – | R | – | 제품 업그레이드 디테일 | ✅ 신규(`300`) | +| `project_mgmt` | – | – | R | R | R | 프로젝트 | ✅ 기존 | +| `pms_wbs_task` | – | – | R | R | R | 작업/유닛 | ✅ 기존 | +| `user_info` | – | – | R | R | R | 사용자 | ✅ 기존(컬럼명 매핑 필요) | +| `comm_code` | – | – | R | R | R | 공통코드 | ✅ 기존 | +| `product_mgmt` | – | – | R | R | – | 제품 | ✅ 기존 | + +**→ 5개 메뉴 모두 P1에서 실데이터 표시 가능.** + +--- + +## 4. GAP 매트릭스 + +| # | 우선 | 항목 | 권장 작업 | +|---|---|---|---| +| **DEV-1** | 🔴 | 개발관리 메뉴 자체 부재 → 5개 메뉴 운영판 1:1 이식 | **본 PR 시리즈 (3 묶음)** | +| **DEV-2** | 🔴 | `part_mng` 15컬럼 누락 (열처리/표면처리/단위/Y-N flag) | ✅ **완료** — `301_alter_part_mng.sql` | +| **DEV-3** | 🔴 | 9개 테이블 부재 | ✅ **완료** — `300_part_bom.sql` (BEGIN/COMMIT 트랜잭션, IDEMPOTENT) | +| **DEV-4** | 🟠 | `user_info` 컬럼명 매핑 (wace `empseq`/`rank` ↔ vexplor `emp_seq`/`rank_code`+`rank_name`) | 코드 측 alias로 처리 | +| **DEV-5** | 🟠 | M3 상태값(작성중/적용완료 등) — wace는 `comm_code` 0000099 자식 사용 | comm_code 그대로 사용. RPS DB에 이미 존재 여부 확인 후 부재 시 INSERT | +| **DEV-6** | 🟠 | M1·M2 팝업(등록/상세) 다이얼로그 — wace `partMngFormPopUp.jsp` 별도 LOC 큼 | M1·M2 묶음 PR에 포함 (한 번에 가는 게 효율) | +| **DEV-7** | 🟡 | M1 도면 다중 업로드 / M2 ERP 업로드 | 본 PR 시리즈 제외 (별 PR) | +| **DEV-8** | 🟡 | M3 BOM Excel Import / M4 엑셀 다운로드 | 본 PR 시리즈 제외 (별 PR) | +| **DEV-9** | 🟢 | M4 동적 MAX_LEVEL 컬럼 — BOM 트리 깊이에 따라 컬럼 추가 | DataGrid 동적 컬럼 모드. 본 PR(E-BOM 묶음)에 포함 | +| **DEV-10** | 🟢 | `admin_supply_mng.employee_email` 운영 타입 버그(`xid`) | ✅ **완료** — `300` 추출 시 `character varying`으로 정정 | + +--- + +## 5. PR 묶음 스코프 (3 PR 시리즈) + +### 5.1 PR-A : PART 등록·조회 묶음 (M1+M2) + +**범위**: +- backend: `routes/devPartRoutes.ts` + `services/devPartService.ts` + `controllers/devPartController.ts` + - 엔드포인트: `/api/development/part-temp/list`·`/deploy`, `/api/development/part/list`·`/:objid`, `/api/development/part` (DELETE) +- frontend: + - `app/(main)/COMPANY_16/development/part-regist/page.tsx` (M1) + - `app/(main)/COMPANY_16/development/part-search/page.tsx` (M2) + - `components/development/PartFormDialog.tsx` (등록/수정 공용) + - `components/development/PartDetailDialog.tsx` (상세) + - `lib/api/devPart.ts` +- 매퍼 1:1: `partMngTempGridList` · `partMngGridList` · `partMngFormPopUp` · `partMngDeploy` · `partMngDelete` + +**제외**: 도면 다중 업로드 · ERP 업로드 · Excel Import → 별 PR + +### 5.2 PR-B : E-BOM 등록·조회 묶음 (M3+M4) + +**범위**: +- backend: `routes/devBomRoutes.ts` + `services/devBomService.ts` + `controllers/devBomController.ts` + - 엔드포인트: `/api/development/ebom/list`·`/status`·`/:objid` (DELETE), `/api/development/ebom/ascending`·`/descending` +- frontend: + - `app/(main)/COMPANY_16/development/ebom-regist/page.tsx` (M3) + - `app/(main)/COMPANY_16/development/ebom-search/page.tsx` (M4) + - `components/development/BomStandardFormDialog.tsx` (M3 등록) + - `components/development/BomStatusChangeDialog.tsx` (M3 상태변경) + - `lib/api/devBom.ts` +- M4 동적 MAX_LEVEL 컬럼 처리 (DataGrid 동적 컬럼) + +**제외**: BOM Excel Import · 정/역전개 엑셀 다운로드 → 별 PR + +### 5.3 PR-C : 설계변경 리스트 (M5) + +**범위**: +- backend: `routes/devEoHistoryRoutes.ts` + `services/devEoHistoryService.ts` + `controllers/devEoHistoryController.ts` + - 엔드포인트: `/api/development/eo/history/list` (read-only) +- frontend: + - `app/(main)/COMPANY_16/development/change-list/page.tsx` + - `components/development/PartHisDetailDialog.tsx` (행 클릭 상세) + - `lib/api/devEoHistory.ts` +- read-only — INSERT/UPDATE/DELETE 없음 + +--- + +## 6. 사용자 결정 사항 (2026-05-12) + +| # | 항목 | 결정 | +|---|---|---| +| 1 | 도메인 폴더 | 단일 `development/` | +| 2 | 메뉴 진행 순서 | PART 묶음(M1+M2) → E-BOM 묶음(M3+M4) → 설계변경(M5) | +| 3 | 문서 구조 | 단일 00-gap.md (본 문서) + 묶음별 *.md (총 3개) | +| 4 | DDL 적용 | 운영DB → vexplor_rps 직접 적용 완료 (9 신규 + 1 ALTER) | + +--- + +## 7. 다음 단계 + +1. **PR-A** : `01-part.md` 작성 → backend route → frontend page 2개 → verify +2. **PR-B** : `02-ebom.md` 작성 → backend route → frontend page 2개 → verify +3. **PR-C** : `03-eo-history.md` 작성 → backend route → frontend page → verify diff --git a/docs/migration/development/01-part.md b/docs/migration/development/01-part.md new file mode 100644 index 00000000..b85608b5 --- /dev/null +++ b/docs/migration/development/01-part.md @@ -0,0 +1,313 @@ +# PR-A : PART 등록·조회 묶음 구현 명세 + +> 작성: 2026-05-12 / 범위: 개발관리 M1(PART 등록) + M2(PART 조회) — 같은 `part_mng` 테이블 R/W, 매퍼 공유. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +원본 `wace_plm/src/com/pms/mapper/partMng.xml`: + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `partMngBaseSimple` (sql) | 78 | (서비스 측 공통 SELECT fragment) | PART_MNG 메인 88+ 컬럼 SELECT | +| `partMngTempGridList` | 2,354 | `GET /api/development/part-temp/list` | M1 그리드 (status != 'release') + ORDER_SPEC_MNG·ADMIN_SUPPLY_MNG JOIN | +| `partMngGridList` | 1,903 | `GET /api/development/part/list` | M2 그리드 (status = 'release' 고정) | +| `partMngInfo` | 2,699 | `GET /api/development/part/:objid` | 상세 단건 (편집 팝업 진입) | +| `insertpartInfo` | 7,625 | `POST /api/development/part` | 신규 등록 (38 컬럼 INSERT) | +| `updatePartDetail` | 2,711 | `PUT /api/development/part/:objid` | 상세 수정 (21 컬럼 UPDATE) | +| `partMngDeploy` | 4,190 | `POST /api/development/part-temp/deploy` | 확정 (M1→M2) STATUS='release', EO_NO 채번 | +| `partMngIsLastInit` | 4,230 | (deploy 트랜잭션 내부) | 동일 PART_NO 이전 IS_LAST='0' | +| `insertPartMngHistory` | 4,244 | (deploy 트랜잭션 내부) | PART_MNG_HISTORY 이력 INSERT | +| `partMngDelete` | 4,486 | `DELETE /api/development/part` (body: `objids: string[]`) | 다중 삭제 | + +`partMngBaseSimple` SELECT 핵심: `PART_MNG P` + `COMM_CODE CC_UNIT`(UNIT) + `COMM_CODE CC_PART`(PART_TYPE) + `admin_supply_mng SUP`(SUPPLY_CODE) + LATERAL `BOM_PART_QTY`(LAST_PART_OBJID·status='deploy'·최신 1행) + LATERAL `COMM_CODE`(CHANGE_OPTION 다중 라벨) + `ATTACH_FILE_INFO`(3D/2D/PDF 파일 카운트). 23개 그리드 컬럼 + CODE_NAME 라벨 + Y/N flag CASE 변환 자체 처리. + +--- + +## 2. API 엔드포인트 명세 + +### 2.1 M1 그리드 — `GET /api/development/part-temp/list` + +**Query**: +``` +search_part_no?: string +search_part_name?: string +search_material?: string +search_spec?: string +search_part_type?: string (PART_TYPE_CODE comm_code id) +writer?: string +status?: string // 단일: 'create'/'changing'/'editing' +status_arr?: string[] // 다중 (둘 중 하나만 사용) +product_code?: string +upg_no?: string +page?: number // 기본 1 +page_size?: number // 기본 20 +``` + +**SQL** (요약): +```sql +SELECT T.*, SORT (REVISION), O.PARTNER_TITLE, Q.OBJID/CHILD_OBJID/QTY/QTY_TEMP, Q_QTY (CASE), + (SELECT PART_NO FROM PART_MNG SP WHERE SP.OBJID = Q.PARENT_PART_NO) PARENT_PART_INFO +FROM T +LEFT JOIN (ORDER_SPEC_MNG OSM JOIN ADMIN_SUPPLY_MNG SUP) O ON T.OBJID::VARCHAR = O.PART_OBJID::VARCHAR +LEFT JOIN BOM_PART_QTY Q ON ( + T.OBJID IN (SELECT DISTINCT PM1.OBJID FROM PART_MNG PM1, PART_MNG PM2 + WHERE PM1.STATUS='changing' AND PM2.STATUS!='changing' + AND PM2.OBJID = Q.PART_NO AND PM1.PART_NO = PM2.PART_NO) + AND Q.STATUS = 'beforeEdit' +) +WHERE 1=1 + 동적 (SEARCH_PART_NO/NAME/MATERIAL/SPEC/PART_TYPE, WRITER, STATUS, STATUS_ARR) +ORDER BY PARENT_PART_INFO, T.PART_NO +``` + +**Response**: +```ts +{ + rows: PartTempRow[]; // 그리드 23셀 + 추가 필드 + total: number; + page: number; + pageSize: number; +} +``` + +### 2.2 M2 그리드 — `GET /api/development/part/list` + +**Query**: 위 + `search_year?` `search_hardness?` `search_method?` `search_surface?` `customer_objid?` `customer_cd?` `project_name?` `unit_code?` `search_design_date_from?` `search_design_date_to?` `is_last?` `eo?` + +**SQL** (요약): +```sql +SELECT NUM (ROW_NUMBER), T.*, + DECODE(PART_TYPE, '0000063', '1', + (SELECT SUM(...) FROM BOM_PART_QTY Q WHERE Q.LAST_PART_OBJID=T.OBJID)::CHARACTER) BOM_QTY +FROM T +WHERE 1=1 AND T.status='release' -- M1 vs M2 핵심 차이 + + 동적 (M1 검색 필드 + 추가 5종) +``` + +**Response**: `{ rows: PartRow[]; total, page, pageSize }` + +### 2.3 단건 상세 — `GET /api/development/part/:objid` + +```sql +SELECT T.* FROM T WHERE T.OBJID = #{OBJID} +``` + +→ `PartRow` 단일 반환. 404 시 `{ error: 'not_found' }`. + +### 2.4 신규 등록 — `POST /api/development/part` + +**Body** (38 컬럼, 핵심): +```ts +{ + part_objid: string; // numeric, 클라이언트 채번 (nanoid-based) 또는 서버 채번 + part_no: string; + part_name: string; + unit?: string; // comm_code + qty?: string; + spec?: string; + material?: string; + thickness?: string; width?: string; height?: string; + out_diameter?: string; in_diameter?: string; length?: string; + remark?: string; + part_type: string; // comm_code (PART_TYPE_CODE) + product_mgmt_objid?: string; + supply_code?: string; + maker?: string; + contract_objid?: string; + post_processing?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + acctfg?: string; // comm_code 계정구분 + odrfg?: string; // 0=구매/1=생산/8=Phantom + unit_dc?: string; unitmang_dc?: string; + unitchng_nb?: string; + lot_fg?: '0'|'1'; + use_yn?: '0'|'1'; + qc_fg?: '0'|'1'; + setitem_fg?: '0'|'1'; + req_fg?: '0'|'1'; + unit_length?: string; + unit_qty?: string; +} +``` + +**SQL**: `insertpartInfo` (7,625) 그대로. `STATUS='create'`, `REG_DATE=now()`, `IS_LAST='1'`, `WRITER=#{CONNECTUSERID}` (서버에서 `req.user.user_id` 주입). + +**채번 정책**: `part_mng.objid` 는 **`bigint`** 타입(다른 영업관리 테이블 `contract_mgmt.objid` 등은 varchar — `genObjid("CM")` 패턴 사용). bigint 컬럼은 prefix-string 못 쓰므로 **wace `CommonUtils.createObjId()` 1:1 구현** 사용: + +```typescript +// backend-node/src/utils/objidUtil.ts (신규) +import { randomUUID } from 'crypto'; + +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; +} + +/** wace CommonUtils.createObjId() 1:1 — UUID v4 → 하이픈 제거(32 hex) → Java String.hashCode (int32) → String. 결과: -2,147,483,648 ~ 2,147,483,647. */ +export function createObjId(): string { + return String(javaStringHashCode(randomUUID().replaceAll('-', ''))); +} +``` + +INSERT 시 `body.part_objid` 가 비어 있으면 서버에서 `createObjId()` 호출(클라이언트 채번도 허용하되 권장 X). + +### 2.5 상세 수정 — `PUT /api/development/part/:objid` + +**Body** (21 컬럼, `updatePartDetail` 1:1): +`part_name, material, heat_treatment_hardness, heat_treatment_method, surface_treatment, maker, part_type, acctfg, odrfg, spec, unit_dc, unitmang_dc, unitchng_nb, lot_fg, use_yn, qc_fg, setitem_fg, req_fg, unit_length, unit_qty, remark` + +→ `EDIT_DATE = NOW()` 자동. + +### 2.6 확정 — `POST /api/development/part-temp/deploy` + +**Body**: `{ objids: string[] }` — 다중 선택 확정. + +**트랜잭션 (각 objid에 대해 순차 처리)**: +1. `partMngIsLastInit`: 같은 PART_NO 모든 행 `IS_LAST='0'` +2. `insertPartMngHistory`: 현재 행을 `PART_MNG_HISTORY`로 복사 (이력 보존) +3. `partMngDeploy`: 본 행 `IS_LAST='1'`, `STATUS='release'`, `DEPLOY_DATE=NOW()`, `REVISION=COALESCE(REVISION,'RE')`, `EO_DATE=...`, `EO_NO=` 채번 (IS_LONGD에 따라 `EOB{yy}-{seq}` or `EO{yy}-{seq}`) + +**EO_NO 채번 SQL** (wace 운영판 그대로): +```sql +CASE WHEN P.IS_LONGD = '1' THEN + 'EOB' || TO_CHAR(NOW(),'yy') || '-' || LPAD( + (SELECT COALESCE(SUBSTR(MAX(EO_NO),7,8)::INTEGER+1, 1) + FROM PART_MNG SP + WHERE SP.EO_NO LIKE 'EOB' || TO_CHAR(NOW(),'yy') || '-%' + AND SP.PART_NO != P.PART_NO + AND SP.REVISION != P.REVISION + )||'', 4, '0') + ELSE + 'EO' || TO_CHAR(NOW(),'yy') || '-' || LPAD(... 'EO{yy}-{seq}' ...) +END +``` + +**Response**: `{ deployed: number, eo_nos: Record }` + +### 2.7 다중 삭제 — `DELETE /api/development/part` + +**Body**: `{ objids: string[] }` + +**SQL** (wace 그대로 POSITION 트릭): +```sql +DELETE FROM PART_MNG WHERE POSITION(OBJID||',' IN #{checkArr}||',') > 0 +``` + +→ backend-node에서는 PostgreSQL 표준인 `WHERE OBJID = ANY($1::numeric[])` 로 정리(동일 효과 + 인덱스 활용 가능). + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/ + devPartRoutes.ts // Express Router — 7 endpoint + controllers/ + devPartController.ts // req/res 처리, validation + services/ + devPartService.ts // SQL 실행 (pg 트랜잭션 처리 포함) + devPartSqlFragments.ts // partMngBaseSimple SELECT fragment 재사용 +``` + +`app.ts`에 `app.use('/api/development', devPartRoutes)` 추가 (또는 메뉴 묶음 라우터 도입 시 그쪽). + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/ + part-regist/ + page.tsx // M1 그리드 + 상단 액션 + 페이징 + part-search/ + page.tsx // M2 그리드 + 상단 액션 + 페이징 + components/development/ + PartFormDialog.tsx // 신규/수정 통합 (mode prop) + PartDetailDialog.tsx // 읽기 전용 상세 + lib/api/ + devPart.ts // 7 endpoint 호출 함수 + 타입 +``` + +### 4.1 그리드 23셀 (M1·M2 공통) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| part_no | 품번 | left | 140 | +| part_name | 품명 | left | 220 | +| cu01_cnt | 3D | right | 60 | +| cu02_cnt | 2D | right | 60 | +| cu03_cnt | PDF | right | 60 | +| material | 재료 | left | 100 | +| heat_treatment_hardness | 열처리경도 | left | 110 | +| heat_treatment_method | 열처리방법 | left | 110 | +| surface_treatment | 표면처리 | left | 100 | +| maker | 메이커 | left | 100 | +| part_type_title | 범주이름 | left | 100 | +| spec | 규격 | left | 140 | +| acctfg_nm | 계정구분 | center | 80 | +| odrfg_nm | 조달구분 | center | 80 | +| unit_dc_nm | 재고단위 | center | 80 | +| unitmang_dc_nm | 관리단위 | center | 80 | +| unitchng_nb | 환산수량 | right | 90 | +| lot_fg_nm | LOT구분 | center | 80 | +| use_yn_nm | 사용여부 | center | 80 | +| qc_fg_nm | 검사여부 | center | 80 | +| setitem_fg_nm | SET품여부 | center | 90 | +| req_fg_nm | 의뢰여부 | center | 80 | +| unit_length / unit_qty | 개당길이/수량 | right | 100 | + +추가 (M1만): `partner_title`, `q_qty`, `parent_part_info` +추가 (M2만): `bom_qty` + +### 4.2 검색 폼 + +**M1 (PART 등록)** — 2 필드: SEARCH_PART_NO · SEARCH_PART_NAME (둘 다 PartSelect autocomplete) +**M2 (PART 조회)** — 메인 조회 화면 (별도 검색 폼 없음, 그리드 헤더 inline 필터로 처리하거나 상단 간소화 검색바 1줄로 통합 — 본 PR 우선 `` 2개로 시작, 추후 보강) + +### 4.3 액션 버튼 (각 page 상단) + +**M1**: 등록 · 수정 · 삭제 · 확정 · 조회 +**M2**: 등록 · 수정 · 삭제 · 조회 (도면연동/ERP업로드/Excel은 본 PR 제외) + +### 4.4 PartFormDialog (신규/수정 통합) + +- mode: `'create' | 'edit'` +- 38 필드 — `` + `` 조합 +- 검증: part_no/part_name 필수, comm_code 필드는 SmartSelect +- 신규: POST → 신규 행 추가 +- 수정: PUT → 21 필드만 전송 (insertpartInfo는 38, updatePartDetail는 21 — wace 그대로) + +### 4.5 PartDetailDialog (읽기 전용) + +행 더블클릭 시 진입. 모든 필드 disabled. "수정" 버튼 → PartFormDialog(mode='edit') 전환. + +--- + +## 5. 본 PR 제외 항목 + +| 항목 | 사유 / 후속 | +|---|---| +| 도면 다중 업로드 (M1) | `ATTACH_FILE_INFO` 다파일 업로드 — 별 PR | +| ERP 업로드 (M2) | wace 외부 시스템 연동 — 별 PR | +| Excel Upload (M1·M2) | `openPartExcelImportPopUp.jsp` 별도 — 별 PR | +| BOM_PART_QTY R/W (M3 영역) | PR-B 에서 다룸 | +| EO_NO 채번 분기 일부 (`IS_LONGD` flag) | 본 PR 포함 — 운영판 그대로 | + +--- + +## 6. 검증 시나리오 (verify.md 기준) + +1. M1 페이지 진입 → 그리드 표시(status != 'release') 확인 +2. "등록" → PartFormDialog 신규 → POST → M1 그리드에 새 행 +3. M1 행 선택 → "확정" → POST deploy → STATUS='release', EO_NO 채번 확인 +4. M2 페이지 진입 → deploy된 행이 M2 그리드에 표시 +5. M2 행 선택 → "수정" → PartFormDialog 수정 → PUT +6. M2 행 다중 선택 → "삭제" → DELETE → 그리드에서 제거 +7. 검색 (SEARCH_PART_NO/NAME) → 필터 적용 확인 +8. 운영DB 11133/waceplm 의 동일 SQL 결과와 vexplor_rps 결과 행 수 비교 (sanity) diff --git a/docs/migration/development/ddl-extracted/300_part_bom.sql b/docs/migration/development/ddl-extracted/300_part_bom.sql new file mode 100644 index 00000000..376df2b7 --- /dev/null +++ b/docs/migration/development/ddl-extracted/300_part_bom.sql @@ -0,0 +1,428 @@ +-- ============================================================ +-- 개발관리(PART/E-BOM/설계변경) 운영 DDL — wace_plm 운영DB(211.115.91.141:11133/waceplm) 추출 +-- 추출일: 2026-05-12 +-- 추출 방법: information_schema + pg_indexes + pg_description 쿼리 +-- (pg_dump 14.19 ↔ PG 16.8 mismatch로 pg_dump 사용 불가) +-- +-- 대상 테이블 9개 (운영 카운트): +-- admin_supply_mng 28 cols / 7건 ← 공급업체 마스터(관리자) +-- bom_part_qty 19 cols / 835건 ← BOM 수량 트리 +-- order_spec_mng 12 cols / 12,522건 ← 발주 스펙 이력 +-- part_bom_report 23 cols / 40건 ← BOM 리포트 헤더 +-- part_mng_history 59 cols / 263건 ← 파트 이력(설계변경) +-- product_mgmt_upg_detail 7 cols / 87건 ← 제품 업그레이드 디테일 +-- product_mgmt_upg_master 5 cols / 6건 ← 제품 업그레이드 마스터 +-- sales_bom_report 16 cols / 1,529건 ← 영업 BOM 단가 +-- supply_mng 29 cols / 1건 ← 공급업체(고객) +-- +-- 비고: +-- · 운영 스키마 1:1 보존 — 컬럼 순서/타입/길이/default 모두 운영과 동일. +-- · objid 컬럼은 wace Java 측에서 UUID/임의 채번(연관 시퀀스 없음 — 확인 완료). +-- · admin_supply_mng.objid·supply_mng.objid는 numeric, default 0(운영 그대로). +-- · admin_supply_mng.employee_email 운영 타입이 'xid'(PostgreSQL 시스템 타입, transaction id) +-- → 데이터 의미(이메일)와 무관한 운영 측 추정 실수. character varying 으로 정정 적용. +-- · part_mng_history.objid는 numeric NOT NULL (PK)이지만 default 미지정. +-- · product_mgmt_upg_master PK는 (objid, target_objid) 복합키. +-- · sales_bom_report 의 supply_objid*/price* 컬럼 length 가 들쭉날쭉한 부분은 운영 그대로. +-- · sales_bom_report_parent_objid_idx 는 UNIQUE 인덱스(운영 정의 그대로 — 1 BOM당 1 단가). +-- · company_code 분기 없음(vexplor_rps는 COMPANY_16 단독). +-- ============================================================ + +BEGIN; + +-- ------------------------------------------------------------ +-- 1) admin_supply_mng (공급업체 마스터 — 관리자 측 입력) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS admin_supply_mng CASCADE; +CREATE TABLE admin_supply_mng ( + objid numeric NOT NULL DEFAULT '0'::numeric, + supply_code character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_name character varying(100) DEFAULT 'NULL::character varying'::character varying, + reg_no character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_address character varying(500) DEFAULT 'NULL::character varying'::character varying, + supply_busname character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_stockname character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_tel_no character varying DEFAULT 'NULL::character varying'::character varying, + supply_fax_no character varying DEFAULT 'NULL::character varying'::character varying, + charge_user_name character varying DEFAULT 'NULL::character varying'::character varying, + payment_method character varying, + reg_id character varying DEFAULT 'NULL::character varying'::character varying, + reg_date timestamp, + status character varying DEFAULT 'NULL::character varying'::character varying, + area_cd character varying DEFAULT 'NULL::character varying'::character varying, + bus_reg_no character varying DEFAULT 'NULL::character varying'::character varying, + office_no character varying DEFAULT 'NULL::character varying'::character varying, + email character varying DEFAULT 'NULL::character varying'::character varying, + account_code character varying, + remark character varying, + account_bank character varying, + account_number character varying, + account_user_name character varying, + employee_name character varying, + employee_position character varying, + employee_number character varying, + -- 운영은 'xid' 시스템 타입으로 잘못 정의됨. 의미상 이메일 → character varying 으로 정정. + employee_email character varying, + david character varying(50), + CONSTRAINT admin_supply_mng_pkey PRIMARY KEY (objid) +); + +-- ------------------------------------------------------------ +-- 2) bom_part_qty (BOM 수량 트리 — 835건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS bom_part_qty CASCADE; +CREATE TABLE bom_part_qty ( + bom_report_objid character varying(64) NOT NULL, + objid character varying(64) NOT NULL, + parent_objid character varying(64) DEFAULT 'NULL::character varying'::character varying, + child_objid character varying(64) DEFAULT 'NULL::character varying'::character varying, + parent_part_no character varying(64) DEFAULT 'NULL::character varying'::character varying, + part_no character varying(64) DEFAULT 'NULL::character varying'::character varying, + qty character varying, + regdate timestamp, + seq integer, + status character varying, + deploy_date character varying, + deploy_user_id character varying, + edit_date character varying, + writer character varying, + qty_temp character varying, + last_part_objid character varying, + editer character varying, + item_qty character varying, + supplier character varying, + CONSTRAINT bom_part_qty_pkey PRIMARY KEY (objid) +); +CREATE INDEX bom_part_qty_bom_report_objid2_idx ON bom_part_qty USING btree (bom_report_objid, last_part_objid, part_no); +CREATE INDEX bom_part_qty_bom_report_objid_idx ON bom_part_qty USING btree (bom_report_objid); +CREATE INDEX bom_part_qty_last_part_objid_idx ON bom_part_qty USING btree (last_part_objid); +CREATE INDEX bom_part_qty_parent_objid_idx ON bom_part_qty USING btree (parent_objid); +CREATE INDEX idx_bom_part_qty_part_no ON bom_part_qty USING btree (part_no) WHERE ((part_no IS NOT NULL) AND ((part_no)::text <> ''::text)); + +COMMENT ON COLUMN bom_part_qty.status IS '상태'; +COMMENT ON COLUMN bom_part_qty.deploy_date IS '배포일'; +COMMENT ON COLUMN bom_part_qty.deploy_user_id IS '배포자'; +COMMENT ON COLUMN bom_part_qty.edit_date IS '수정일'; +COMMENT ON COLUMN bom_part_qty.writer IS '등록자'; +COMMENT ON COLUMN bom_part_qty.qty_temp IS '수량(설변중)'; +COMMENT ON COLUMN bom_part_qty.last_part_objid IS '마지막 품번키'; +COMMENT ON COLUMN bom_part_qty.editer IS '수정자'; +COMMENT ON COLUMN bom_part_qty.item_qty IS '항목수량'; +COMMENT ON COLUMN bom_part_qty.supplier IS '공급업체'; + +-- ------------------------------------------------------------ +-- 3) order_spec_mng (발주 스펙 이력 — 12,522건, 운영 최다) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS order_spec_mng CASCADE; +CREATE TABLE order_spec_mng ( + objid character varying NOT NULL, + seq character varying NOT NULL, + part_objid character varying NOT NULL, + partner_rank character varying, + partner_objid character varying, + partner_price character varying, + partner_qty character varying, + apply_date character varying, + remark character varying, + regdate timestamp, + is_last character varying, + writer character varying, + CONSTRAINT order_spec_mng_pkey PRIMARY KEY (objid) +); + +-- ------------------------------------------------------------ +-- 4) part_bom_report (BOM 리포트 헤더 — 40건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS part_bom_report CASCADE; +CREATE TABLE part_bom_report ( + objid character varying NOT NULL DEFAULT ''::character varying, + customer_objid character varying, + contract_objid character varying, + unit_code character varying, + revision character varying, + writer character varying(64), + regdate timestamp, + status character varying(64), + deploy_date character varying(64), + eo_no character varying(100), + eo_date character varying(100), + note character varying(2000), + edit_date timestamp, + editer character varying, + unit_code_old character varying, + multi_break_yn character varying, + multi_yn character varying, + multi_master_yn character varying, + multi_master_objid character varying, + product_cd character varying, + part_no character varying, + part_name character varying, + version character varying, + CONSTRAINT part_bom_report_pkey PRIMARY KEY (objid) +); +CREATE INDEX idx_part_bom_report_customer ON part_bom_report USING btree (customer_objid) WHERE ((customer_objid IS NOT NULL) AND ((customer_objid)::text <> ''::text)); +CREATE INDEX idx_part_bom_report_regdate ON part_bom_report USING btree (regdate DESC); +CREATE INDEX idx_part_bom_report_writer ON part_bom_report USING btree (writer); +CREATE INDEX part_bom_report_contract_objid_idx ON part_bom_report USING btree (contract_objid); +CREATE INDEX part_bom_report_unit_code_idx ON part_bom_report USING btree (unit_code, contract_objid); + +COMMENT ON COLUMN part_bom_report.objid IS 'OBJECT ID'; +COMMENT ON COLUMN part_bom_report.customer_objid IS '고객사 OBJID'; +COMMENT ON COLUMN part_bom_report.contract_objid IS '계약objid'; +COMMENT ON COLUMN part_bom_report.unit_code IS 'unit'; +COMMENT ON COLUMN part_bom_report.revision IS 'rev'; +COMMENT ON COLUMN part_bom_report.writer IS '작성자'; +COMMENT ON COLUMN part_bom_report.regdate IS '등록일'; +COMMENT ON COLUMN part_bom_report.status IS '상태'; +COMMENT ON COLUMN part_bom_report.deploy_date IS '배포일'; +COMMENT ON COLUMN part_bom_report.edit_date IS '수정일'; +COMMENT ON COLUMN part_bom_report.editer IS '수정자'; +COMMENT ON COLUMN part_bom_report.unit_code_old IS 'UNIT_CODE'; +COMMENT ON COLUMN part_bom_report.multi_break_yn IS '동시적용프로젝트 깨짐 여부'; +COMMENT ON COLUMN part_bom_report.multi_yn IS '동시적용프로젝트 여부'; +COMMENT ON COLUMN part_bom_report.multi_master_yn IS '동시적용프로젝트 마스터 여부'; +COMMENT ON COLUMN part_bom_report.multi_master_objid IS '동시적용프로젝트 마스터 키'; + +-- ------------------------------------------------------------ +-- 5) part_mng_history (파트 이력 — 설계변경 핵심, 59 cols / 263건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS part_mng_history CASCADE; +CREATE TABLE part_mng_history ( + objid numeric NOT NULL, + product_mgmt_objid character varying(100) DEFAULT NULL::character varying, + upg_no character varying(100) DEFAULT NULL::character varying, + part_no character varying(100) DEFAULT NULL::character varying, + part_name character varying(100) DEFAULT NULL::character varying, + unit character varying(50) DEFAULT NULL::character varying, + qty character varying(50) DEFAULT NULL::character varying, + spec character varying(100) DEFAULT 'NULL::character varying'::character varying, + material character varying(100) DEFAULT NULL::character varying, + weight character varying(50) DEFAULT NULL::character varying, + part_type character varying(100) DEFAULT NULL::character varying, + remark character varying(1000) DEFAULT NULL::character varying, + es_spec character varying(100) DEFAULT NULL::character varying, + ms_spec character varying(100) DEFAULT NULL::character varying, + change_option character varying(50) DEFAULT NULL::character varying, + design_apply_point character varying(50) DEFAULT NULL::character varying, + management_flag character varying(50) DEFAULT NULL::character varying, + revision character varying(50) DEFAULT NULL::character varying, + status character varying(30) DEFAULT NULL::character varying, + reg_date timestamp, + edit_date timestamp, + writer character varying(30) DEFAULT NULL::character varying, + is_last character varying(5) DEFAULT NULL::character varying, + eo_no character varying, + eo_temp character varying, + excel_upload_seq character varying, + sourcing_code character varying, + sub_material character varying(100) DEFAULT 'NULL::character varying'::character varying, + parent_part_no character varying, + design_date character varying, + eo_date character varying, + deploy_date timestamp, + thickness character varying, + width character varying, + height character varying, + out_diameter character varying, + in_diameter character varying, + length character varying, + supply_code character varying, + change_type character varying, + contract_objid character varying, + maker character varying, + qty_temp character varying, + bom_report_objid character varying, + parent_part_objid character varying, + parent_qty_child_objid character varying, + bom_qty_status character varying, + his_reg_date timestamp, + his_writer character varying, + his_status character varying, + qty_child_objid character varying, + bom_status character varying, + bom_deploy_date timestamp, + chg_part_objid character varying, + chg_part_no character varying, + chg_part_rev character varying, + heat_treatment_hardness character varying, + heat_treatment_method character varying, + surface_treatment character varying, + CONSTRAINT part_mng_history_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN part_mng_history.qty_temp IS '수량(설변중)'; +COMMENT ON COLUMN part_mng_history.bom_report_objid IS 'BOM 키'; +COMMENT ON COLUMN part_mng_history.parent_part_objid IS '부모 파트 키'; +COMMENT ON COLUMN part_mng_history.parent_qty_child_objid IS '부모 구조 키'; +COMMENT ON COLUMN part_mng_history.bom_qty_status IS 'BOM 상태'; +COMMENT ON COLUMN part_mng_history.his_reg_date IS '등록일'; +COMMENT ON COLUMN part_mng_history.his_writer IS '등록자'; +COMMENT ON COLUMN part_mng_history.his_status IS '상태'; +COMMENT ON COLUMN part_mng_history.qty_child_objid IS '구조 키'; +COMMENT ON COLUMN part_mng_history.bom_status IS 'BOM 상태'; +COMMENT ON COLUMN part_mng_history.bom_deploy_date IS '배포일'; + +-- ------------------------------------------------------------ +-- 6) product_mgmt_upg_detail (제품 업그레이드 디테일 — 87건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS product_mgmt_upg_detail CASCADE; +CREATE TABLE product_mgmt_upg_detail ( + objid integer NOT NULL, + target_objid integer, + upg_name character varying(100), + upg_code character varying(100), + vc character varying(100), + note character varying(1000), + product_objid integer, + CONSTRAINT product_mgmt_upg_detail_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN product_mgmt_upg_detail.objid IS 'objid'; +COMMENT ON COLUMN product_mgmt_upg_detail.target_objid IS 'upg_masterobjid'; +COMMENT ON COLUMN product_mgmt_upg_detail.upg_name IS 'upg명'; +COMMENT ON COLUMN product_mgmt_upg_detail.upg_code IS 'upg코드'; +COMMENT ON COLUMN product_mgmt_upg_detail.vc IS 'vc'; +COMMENT ON COLUMN product_mgmt_upg_detail.note IS '비고'; + +-- ------------------------------------------------------------ +-- 7) product_mgmt_upg_master (제품 업그레이드 마스터 — 6건, 복합 PK) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS product_mgmt_upg_master CASCADE; +CREATE TABLE product_mgmt_upg_master ( + objid integer NOT NULL, + target_objid integer NOT NULL, + spec_name character varying NOT NULL, + writer character varying, + regdate timestamp, + CONSTRAINT product_mgmt_upg_master_pkey PRIMARY KEY (objid, target_objid) +); + +COMMENT ON COLUMN product_mgmt_upg_master.objid IS '제품사양마스터코드'; +COMMENT ON COLUMN product_mgmt_upg_master.target_objid IS '양산마스터코드'; +COMMENT ON COLUMN product_mgmt_upg_master.spec_name IS '사양명'; +COMMENT ON COLUMN product_mgmt_upg_master.writer IS '작성자'; +COMMENT ON COLUMN product_mgmt_upg_master.regdate IS '동록일'; + +-- ------------------------------------------------------------ +-- 8) sales_bom_report (영업 BOM 단가 — 1,529건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS sales_bom_report CASCADE; +CREATE TABLE sales_bom_report ( + objid character varying NOT NULL DEFAULT ''::character varying, + parent_objid character varying, + supply_objid character varying, + price character varying, + supply_objid1 character varying, + price1 character varying(64), + supply_objid2 character varying(100), + price2 character varying(64), + supply_objid3 character varying(64), + price3 character varying(100), + supply_objid4 character varying(100), + price4 character varying(2000), + writer character varying, + regdate timestamp, + update_date timestamp, + modifier character varying, + CONSTRAINT sales_bom_report_pkey PRIMARY KEY (objid) +); +-- 운영 인덱스는 UNIQUE — 1 BOM(parent_objid) 당 1 단가행 +CREATE UNIQUE INDEX sales_bom_report_parent_objid_idx ON sales_bom_report USING btree (parent_objid); + +COMMENT ON COLUMN sales_bom_report.objid IS '키'; +COMMENT ON COLUMN sales_bom_report.parent_objid IS 'bom_report_objid'; +COMMENT ON COLUMN sales_bom_report.supply_objid IS '공급업체key'; +COMMENT ON COLUMN sales_bom_report.price IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid1 IS '레이져업체명'; +COMMENT ON COLUMN sales_bom_report.price1 IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid2 IS '용접업체명'; +COMMENT ON COLUMN sales_bom_report.price2 IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid3 IS '가공업체명'; +COMMENT ON COLUMN sales_bom_report.price3 IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid4 IS '후처리'; +COMMENT ON COLUMN sales_bom_report.price4 IS '단가'; +COMMENT ON COLUMN sales_bom_report.writer IS '담당자'; +COMMENT ON COLUMN sales_bom_report.regdate IS '작성일'; +COMMENT ON COLUMN sales_bom_report.update_date IS '수정일'; +COMMENT ON COLUMN sales_bom_report.modifier IS '수정자'; + +-- ------------------------------------------------------------ +-- 9) supply_mng (공급업체/고객 — 1건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS supply_mng CASCADE; +CREATE TABLE supply_mng ( + objid numeric NOT NULL DEFAULT 0, + supply_code character varying(100) DEFAULT NULL::character varying, + supply_name character varying(100) DEFAULT NULL::character varying, + reg_no character varying(100) DEFAULT NULL::character varying, + supply_address character varying(500) DEFAULT NULL::character varying, + supply_busname character varying(100) DEFAULT NULL::character varying, + supply_stockname character varying(100) DEFAULT NULL::character varying, + supply_tel_no character varying(30) DEFAULT NULL::character varying, + supply_fax_no character varying(30) DEFAULT NULL::character varying, + charge_user_name character varying(100) DEFAULT NULL::character varying, + payment_method character varying(100), + reg_id character varying(100) DEFAULT NULL::character varying, + reg_date timestamp, + status character varying(32) DEFAULT NULL::character varying, + area_cd character varying(32) DEFAULT NULL::character varying, + bus_reg_no character varying(100) DEFAULT NULL::character varying, + office_no character varying(32) DEFAULT 'NULL::character varying'::character varying, + email character varying(32) DEFAULT 'NULL::character varying'::character varying, + cus_no character varying, + manager1_name character varying(100), + manager1_email character varying(100), + manager2_name character varying(100), + manager2_email character varying(100), + manager3_name character varying(100), + manager3_email character varying(100), + manager4_name character varying(100), + manager4_email character varying(100), + manager5_name character varying(100), + manager5_email character varying(100), + CONSTRAINT supply_mng_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN supply_mng.supply_code IS '구분'; +COMMENT ON COLUMN supply_mng.supply_name IS '고객명'; +COMMENT ON COLUMN supply_mng.reg_no IS '법인/주민번호'; +COMMENT ON COLUMN supply_mng.supply_address IS '주소'; +COMMENT ON COLUMN supply_mng.supply_busname IS '업태'; +COMMENT ON COLUMN supply_mng.supply_stockname IS '업종'; +COMMENT ON COLUMN supply_mng.supply_tel_no IS '핸드폰'; +COMMENT ON COLUMN supply_mng.supply_fax_no IS '팩스번호'; +COMMENT ON COLUMN supply_mng.charge_user_name IS '대표자명'; +COMMENT ON COLUMN supply_mng.reg_id IS '실사용자명'; +COMMENT ON COLUMN supply_mng.reg_date IS '등록일'; +COMMENT ON COLUMN supply_mng.status IS '상태'; +COMMENT ON COLUMN supply_mng.area_cd IS '지역'; +COMMENT ON COLUMN supply_mng.bus_reg_no IS '사업자등록번호'; +COMMENT ON COLUMN supply_mng.office_no IS '오피스no'; +COMMENT ON COLUMN supply_mng.email IS '이메일'; +COMMENT ON COLUMN supply_mng.cus_no IS '고객번호'; +COMMENT ON COLUMN supply_mng.manager1_name IS '담당자1 이름'; +COMMENT ON COLUMN supply_mng.manager1_email IS '담당자1 이메일'; +COMMENT ON COLUMN supply_mng.manager2_name IS '담당자2 이름'; +COMMENT ON COLUMN supply_mng.manager2_email IS '담당자2 이메일'; +COMMENT ON COLUMN supply_mng.manager3_name IS '담당자3 이름'; +COMMENT ON COLUMN supply_mng.manager3_email IS '담당자3 이메일'; +COMMENT ON COLUMN supply_mng.manager4_name IS '담당자4 이름'; +COMMENT ON COLUMN supply_mng.manager4_email IS '담당자4 이메일'; +COMMENT ON COLUMN supply_mng.manager5_name IS '담당자5 이름'; +COMMENT ON COLUMN supply_mng.manager5_email IS '담당자5 이메일'; + +COMMIT; + +-- ============================================================ +-- vexplor_rps 적용 방법 (메인 agent 검토 후 직접 실행): +-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \ +-- -f /Users/jhj/vexplor_rps/docs/migration/development/ddl-extracted/300_part_bom.sql +-- +-- 적용 후 검증: +-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps -c " +-- SELECT relname, (SELECT count(*) FROM pg_attribute WHERE attrelid = c.oid AND attnum > 0 AND NOT attisdropped) AS cols +-- FROM pg_class c WHERE relkind='r' AND relnamespace=(SELECT oid FROM pg_namespace WHERE nspname='public') +-- AND relname IN ('admin_supply_mng','bom_part_qty','order_spec_mng','part_bom_report','part_mng_history','product_mgmt_upg_detail','product_mgmt_upg_master','sales_bom_report','supply_mng') +-- ORDER BY relname;" +-- 기대값: 28 / 19 / 12 / 23 / 59 / 7 / 5 / 16 / 29 +-- ============================================================ diff --git a/docs/migration/development/ddl-extracted/301_alter_part_mng.sql b/docs/migration/development/ddl-extracted/301_alter_part_mng.sql new file mode 100644 index 00000000..7250bc8b --- /dev/null +++ b/docs/migration/development/ddl-extracted/301_alter_part_mng.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- part_mng ALTER — 개발관리 메뉴(PART 등록/조회) 누락 컬럼 추가 +-- 추출일: 2026-05-12 +-- 출처: 211.115.91.141:11133/waceplm (PG 16.8) +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 사유: wace PART 등록/조회 그리드 23개 컬럼 중 15개가 vexplor part_mng 에 부재. +-- 전부 ADD COLUMN IF NOT EXISTS 로 안전하게 추가 (IDEMPOTENT). +-- ============================================================ + +BEGIN; + +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS heat_treatment_hardness character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS heat_treatment_method character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS surface_treatment character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS acctfg character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS odrfg character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit_dc character varying(20); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unitmang_dc character varying(20); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unitchng_nb numeric(11,6); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS lot_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS use_yn character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS qc_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS setitem_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS req_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit_length character varying(20); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit_qty character varying(20); + +COMMENT ON COLUMN part_mng.heat_treatment_hardness IS '열처리경도'; +COMMENT ON COLUMN part_mng.heat_treatment_method IS '열처리방법'; +COMMENT ON COLUMN part_mng.surface_treatment IS '표면처리'; +COMMENT ON COLUMN part_mng.acctfg IS '계정구분 (comm_code)'; +COMMENT ON COLUMN part_mng.odrfg IS '조달구분 (comm_code)'; +COMMENT ON COLUMN part_mng.unit_dc IS '재고단위 (comm_code)'; +COMMENT ON COLUMN part_mng.unitmang_dc IS '관리단위 (comm_code)'; +COMMENT ON COLUMN part_mng.unitchng_nb IS '환산수량'; +COMMENT ON COLUMN part_mng.lot_fg IS 'LOT구분 (Y/N)'; +COMMENT ON COLUMN part_mng.use_yn IS '사용여부 (Y/N)'; +COMMENT ON COLUMN part_mng.qc_fg IS '검사여부 (Y/N)'; +COMMENT ON COLUMN part_mng.setitem_fg IS 'SET품여부 (Y/N)'; +COMMENT ON COLUMN part_mng.req_fg IS '의뢰여부 (Y/N)'; +COMMENT ON COLUMN part_mng.unit_length IS '개당길이'; +COMMENT ON COLUMN part_mng.unit_qty IS '개당수량'; + +COMMIT; diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx new file mode 100644 index 00000000..1063c0b4 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +// 개발관리 > PART 등록 (M1) — wace partMngTempList.jsp 1:1 +// 그리드: status != 'release' 인 PART 23셀 +// 액션: 등록 / 수정 / 삭제 / 확정 / 조회 +// 참조: docs/migration/development/01-part.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Search, Loader2, RotateCcw, Plus, Pencil, Trash2, CheckSquare, +} from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart"; +import { PartFormDialog } from "@/components/development/PartFormDialog"; +import { PartDetailDialog } from "@/components/development/PartDetailDialog"; + +// wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY) +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "part_no", label: "품번", width: "w-[140px]", frozen: true }, + { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, + { 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: "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-[100px]" }, + { key: "part_type_title", label: "범주", width: "w-[100px]" }, + { key: "spec", label: "규격", width: "w-[140px]" }, + { key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" }, + { key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" }, + { key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" }, + { key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" }, + { key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" }, + { key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" }, + { key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" }, + { key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" }, + { key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" }, + { key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" }, + { key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" }, + // M1 부속 + { key: "partner_title", label: "공급업체(시퀀스)", minWidth: "min-w-[180px]" }, + { key: "parent_part_info", label: "상위 품번", width: "w-[120px]" }, + { key: "q_qty", label: "수량(BOM)", width: "w-[90px]", align: "right" }, + { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, + { key: "status", label: "상태", width: "w-[80px]", align: "center" }, +]; + +const EMPTY_FILTER: PartListFilter = { + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function PartRegistPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + // 다이얼로그 상태 + const [formOpen, setFormOpen] = useState(false); + const [formMode, setFormMode] = useState<"create" | "edit">("create"); + const [formObjid, setFormObjid] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devPartApi.listTemp(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + // 행 더블클릭 → 상세 다이얼로그 + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "part_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + // 등록 + const handleCreate = () => { + setFormMode("create"); + setFormObjid(null); + setFormOpen(true); + }; + + // 수정 (단일 선택 필요) + const handleEdit = () => { + if (checkedIds.length !== 1) return toast.error("수정할 행 1개를 선택하세요."); + setFormMode("edit"); + setFormObjid(checkedIds[0]); + setFormOpen(true); + }; + + // 삭제 (다중) + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까?`)) return; + try { + const res = await devPartApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + // 확정 (M1 → M2): EO_NO 채번 + part_mng_history 이력 + const handleDeploy = async () => { + if (checkedIds.length === 0) return toast.error("확정할 행을 선택하세요."); + if (!confirm(`${checkedIds.length}건을 확정하시겠습니까? (M1 → M2)`)) return; + try { + const res = await devPartApi.deploy(checkedIds); + toast.success(`${res.deployed}건이 확정되었습니다.`); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "확정 실패"); + } + }; + + // 상세 → 수정 전환 + const handleEditFromDetail = (objid: string) => { + setDetailOpen(false); + setFormMode("edit"); + setFormObjid(objid); + setFormOpen(true); + }; + + return ( +
+ {/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */} +
+
+
+ + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> +
+
+ + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> +
+
+ + + + + + +
+
+
+ 총 {total.toLocaleString()}건 (M1: status ≠ 'release') +
+
+ +
+ +
+ + + +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx new file mode 100644 index 00000000..a7f31d84 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +// 개발관리 > PART 조회 (M2) — wace partMngList.jsp 1:1 +// 그리드: status = 'release' 인 PART 23셀 + BOM_QTY +// 액션: 등록 / 수정 / 삭제 / 조회 (도면연동/ERP/Excel은 별 PR) +// 참조: docs/migration/development/01-part.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Search, Loader2, RotateCcw, Plus, Pencil, Trash2, +} from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart"; +import { PartFormDialog } from "@/components/development/PartFormDialog"; +import { PartDetailDialog } from "@/components/development/PartDetailDialog"; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "part_no", label: "품번", width: "w-[140px]", frozen: true }, + { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, + { 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: "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-[100px]" }, + { key: "part_type_title", label: "범주", width: "w-[100px]" }, + { key: "spec", label: "규격", width: "w-[140px]" }, + { key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" }, + { key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" }, + { key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" }, + { key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" }, + { key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" }, + { key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" }, + { key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" }, + { key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" }, + { key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" }, + { key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" }, + { key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" }, + // M2 추가 + { key: "bom_qty", label: "BOM 수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, + { key: "eo_no", label: "EO_NO", width: "w-[120px]" }, +]; + +const EMPTY_FILTER: PartListFilter = { + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function PartSearchPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [formOpen, setFormOpen] = useState(false); + const [formMode, setFormMode] = useState<"create" | "edit">("create"); + const [formObjid, setFormObjid] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devPartApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "part_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + const handleCreate = () => { + setFormMode("create"); setFormObjid(null); setFormOpen(true); + }; + const handleEdit = () => { + if (checkedIds.length !== 1) return toast.error("수정할 행 1개를 선택하세요."); + setFormMode("edit"); setFormObjid(checkedIds[0]); setFormOpen(true); + }; + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까?`)) return; + try { + const res = await devPartApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + const handleEditFromDetail = (objid: string) => { + setDetailOpen(false); + setFormMode("edit"); setFormObjid(objid); setFormOpen(true); + }; + + return ( +
+
+
+
+ + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> +
+
+ + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> +
+
+ + + + + +
+
+
+ 총 {total.toLocaleString()}건 (M2: status = 'release') +
+
+ +
+ +
+ + + +
+ ); +} diff --git a/frontend/components/development/PartDetailDialog.tsx b/frontend/components/development/PartDetailDialog.tsx new file mode 100644 index 00000000..6b9122cb --- /dev/null +++ b/frontend/components/development/PartDetailDialog.tsx @@ -0,0 +1,177 @@ +"use client"; + +// 개발관리 > PART 상세 조회 다이얼로그 (read-only). +// 행 더블클릭 진입. "수정" 버튼 클릭 시 PartFormDialog(mode='edit')로 전환은 +// 호출 페이지가 dispatch (open=false → 부모가 form dialog 오픈). + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2, Pencil } from "lucide-react"; +import { toast } from "sonner"; +import { devPartApi, PartRow } from "@/lib/api/devPart"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; + /** "수정" 버튼 클릭 시 호출 — 부모는 본 다이얼로그 닫고 PartFormDialog(mode='edit')를 띄움 */ + onEdit?: (objid: string) => void; +} + +export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) { + const [row, setRow] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devPartApi.detail(objid) + .then((data) => { if (alive) setRow(data); }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + if (!open) return null; + + return ( + + + + PART 상세 정보 + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
+ )} + + + {row && onEdit && ( + + )} + + +
+
+ ); +} + +// ─── 보조 ───────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Row({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) { + const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : ""; + return ( +
+ {label && } +
+ {value != null && value !== "" ? value : } +
+
+ ); +} diff --git a/frontend/components/development/PartFormDialog.tsx b/frontend/components/development/PartFormDialog.tsx new file mode 100644 index 00000000..db2224cf --- /dev/null +++ b/frontend/components/development/PartFormDialog.tsx @@ -0,0 +1,499 @@ +"use client"; + +// 개발관리 > PART 등록/수정 통합 다이얼로그. +// wace partMngFormPopUp.jsp + partMngDetailPopUp.jsp 1:1 (mode 분기). +// +// 신규: POST /api/development/part (38 컬럼) +// 수정: PUT /api/development/part/:objid (21 컬럼만 — wace updatePartDetail 1:1) +// +// 그룹: +// ① 기본정보 (필수 ★: part_no, part_name, part_type) +// ② 크기/형상 +// ③ 분류/단위 (comm_code SmartSelect) +// ④ Y/N 플래그 (radio '1'/'0') + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +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 { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart"; + +// comm_code 그룹 ID (vexplor_rps DB 실재 그룹) +const GROUP_PART_TYPE = "0000062"; // PART TYPE (조립품/부품/구매품) +const GROUP_UNIT = "0001399"; // 단위 (m/Set/EA/식/BAG/kg/...) +const GROUP_ACCTFG = "0900213"; // 파트_계정구분 (원자재/제품/...) + +// ODRFG: spec '0=구매/1=생산/8=Phantom' — 하드코딩 +const ODRFG_OPTIONS = [ + { code: "0", label: "구매" }, + { code: "1", label: "생산" }, + { code: "8", label: "Phantom" }, +]; + +interface FormState { + // 기본 + part_no: string; + part_name: string; + part_type: string; + unit: string; + qty: string; + spec: string; + material: string; + remark: string; + maker: string; + // 크기/형상 + thickness: string; + width: string; + height: string; + out_diameter: string; + in_diameter: string; + length: string; + // 분류/단위 + acctfg: string; + odrfg: string; + unit_dc: string; + unitmang_dc: string; + unitchng_nb: string; + unit_length: string; + unit_qty: string; + // 열처리/표면처리/후가공 + heat_treatment_hardness: string; + heat_treatment_method: string; + surface_treatment: string; + post_processing: string; + // 부속 (신규 시만 표시) + product_mgmt_objid: string; + supply_code: string; + contract_objid: string; + // Y/N + lot_fg: string; + use_yn: string; + qc_fg: string; + setitem_fg: string; + req_fg: string; +} + +const EMPTY_FORM: FormState = { + part_no: "", part_name: "", part_type: "", unit: "", qty: "", spec: "", material: "", remark: "", maker: "", + thickness: "", width: "", height: "", out_diameter: "", in_diameter: "", length: "", + acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "", unit_length: "", unit_qty: "", + heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "", post_processing: "", + product_mgmt_objid: "", supply_code: "", contract_objid: "", + lot_fg: "1", use_yn: "1", qc_fg: "0", setitem_fg: "0", req_fg: "0", +}; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "edit"; + editObjid?: string | null; + onSaved: () => void; +} + +export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: Props) { + const isEdit = mode === "edit"; + const [form, setForm] = useState(EMPTY_FORM); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const setField = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [] + ); + + // 초기화/로드 + useEffect(() => { + if (!open) return; + if (isEdit && editObjid) { + loadDetail(editObjid); + } else { + setForm(EMPTY_FORM); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const loadDetail = async (objid: string) => { + setLoading(true); + try { + const row = await devPartApi.detail(objid); + if (!row) { + toast.error("PART를 찾을 수 없습니다."); + onOpenChange(false); + return; + } + setForm(rowToForm(row)); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!form.part_no.trim()) return toast.error("품번은 필수입니다."); + if (!form.part_name.trim()) return toast.error("품명은 필수입니다."); + if (!isEdit && !form.part_type.trim()) return toast.error("범주(PART TYPE)는 필수입니다."); + + setSaving(true); + try { + if (isEdit && editObjid) { + const body: PartUpdateBody = { + part_name: form.part_name, + material: form.material, + heat_treatment_hardness: form.heat_treatment_hardness, + heat_treatment_method: form.heat_treatment_method, + surface_treatment: form.surface_treatment, + maker: form.maker, + part_type: form.part_type, + acctfg: form.acctfg, + odrfg: form.odrfg, + spec: form.spec, + unit_dc: form.unit_dc, + unitmang_dc: form.unitmang_dc, + unitchng_nb: form.unitchng_nb, + lot_fg: form.lot_fg, + use_yn: form.use_yn, + qc_fg: form.qc_fg, + setitem_fg: form.setitem_fg, + req_fg: form.req_fg, + unit_length: form.unit_length, + unit_qty: form.unit_qty, + remark: form.remark, + }; + await devPartApi.update(editObjid, body); + toast.success("PART가 수정되었습니다."); + } else { + const body: PartCreateBody = { + part_no: form.part_no, + part_name: form.part_name, + part_type: form.part_type, + unit: form.unit, + qty: form.qty, + spec: form.spec, + material: form.material, + thickness: form.thickness, + width: form.width, + height: form.height, + out_diameter: form.out_diameter, + in_diameter: form.in_diameter, + length: form.length, + remark: form.remark, + product_mgmt_objid: form.product_mgmt_objid, + supply_code: form.supply_code, + maker: form.maker, + contract_objid: form.contract_objid, + post_processing: form.post_processing, + heat_treatment_hardness: form.heat_treatment_hardness, + heat_treatment_method: form.heat_treatment_method, + surface_treatment: form.surface_treatment, + acctfg: form.acctfg, + odrfg: form.odrfg, + unit_dc: form.unit_dc, + unitmang_dc: form.unitmang_dc, + unitchng_nb: form.unitchng_nb, + lot_fg: form.lot_fg, + use_yn: form.use_yn, + qc_fg: form.qc_fg, + setitem_fg: form.setitem_fg, + req_fg: form.req_fg, + unit_length: form.unit_length, + unit_qty: form.unit_qty, + }; + await devPartApi.create(body); + toast.success("PART가 등록되었습니다."); + } + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const titleText = isEdit ? "PART 수정" : "PART 신규 등록"; + + return ( + + + + {titleText} + + + {loading ? ( +
+ +
+ ) : ( +
+ {/* ① 기본정보 */} +
+ + + setField("part_no", e.target.value)} /> + + + setField("part_name", e.target.value)} /> + + + setField("part_type", v)} /> + + + + + setField("unit", v)} /> + + + setField("qty", e.target.value)} /> + + + setField("spec", e.target.value)} /> + + + + + setField("material", e.target.value)} /> + + + setField("maker", e.target.value)} /> + + + setField("remark", e.target.value)} /> + + +
+ + {/* ② 크기/형상 */} +
+ + setField("thickness", e.target.value)} /> + setField("width", e.target.value)} /> + setField("height", e.target.value)} /> + + + setField("out_diameter", e.target.value)} /> + setField("in_diameter", e.target.value)} /> + setField("length", e.target.value)} /> + +
+ + {/* ③ 분류 / 단위 */} +
+ + + setField("acctfg", v)} /> + + + + + + setField("unitchng_nb", e.target.value)} /> + + + + + setField("unit_dc", v)} /> + + + setField("unitmang_dc", v)} /> + + +
+ setField("unit_length", e.target.value)} /> + / + setField("unit_qty", e.target.value)} /> +
+
+
+ + + setField("heat_treatment_hardness", e.target.value)} /> + + + setField("heat_treatment_method", e.target.value)} /> + + + setField("surface_treatment", e.target.value)} /> + + + + + setField("post_processing", e.target.value)} /> + + {!isEdit && ( + + setField("supply_code", e.target.value)} + placeholder="admin_supply_mng.objid" /> + + )} + {!isEdit && ( + + setField("product_mgmt_objid", e.target.value)} + placeholder="product_mgmt.objid" /> + + )} + +
+ + {/* ④ Y/N 플래그 */} +
+ + setField("lot_fg", v)} /> + setField("use_yn", v)} /> + setField("qc_fg", v)} /> + + + setField("setitem_fg", v)} /> + setField("req_fg", v)} /> + + +
+
+ )} + + + + + +
+
+ ); +} + +// ─── 보조 컴포넌트 ────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Row({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Field({ label, required, children }: { label: string; required?: boolean; children?: React.ReactNode }) { + return ( +
+ {label && ( + + )} + {children} +
+ ); +} + +function YNRadio({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+ + +
+ ); +} + +// ─── PartRow → FormState 매핑 ────────────────────────────── + +function rowToForm(r: PartRow): FormState { + return { + part_no: r.part_no ?? "", + part_name: r.part_name ?? "", + part_type: r.part_type ?? "", + unit: r.unit ?? "", + qty: r.qty ?? "", + spec: r.spec ?? "", + material: r.material ?? "", + remark: r.remark ?? "", + maker: r.maker ?? "", + thickness: r.thickness ?? "", + width: r.width ?? "", + height: r.height ?? "", + out_diameter: r.out_diameter ?? "", + in_diameter: r.in_diameter ?? "", + length: r.length ?? "", + acctfg: r.acctfg ?? "", + odrfg: r.odrfg ?? "", + unit_dc: r.unit_dc ?? "", + unitmang_dc: r.unitmang_dc ?? "", + unitchng_nb: r.unitchng_nb != null ? String(r.unitchng_nb) : "", + unit_length: r.unit_length ?? "", + unit_qty: r.unit_qty ?? "", + heat_treatment_hardness: r.heat_treatment_hardness ?? "", + heat_treatment_method: r.heat_treatment_method ?? "", + surface_treatment: r.surface_treatment ?? "", + post_processing: r.post_processing ?? "", + product_mgmt_objid: r.product_mgmt_objid ?? "", + supply_code: r.supply_code ?? "", + contract_objid: r.contract_objid ?? "", + lot_fg: r.lot_fg ?? "1", + use_yn: r.use_yn ?? "1", + qc_fg: r.qc_fg ?? "0", + setitem_fg: r.setitem_fg ?? "0", + req_fg: r.req_fg ?? "0", + }; +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 6156d405..23eddf88 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -107,6 +107,8 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/part-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-regist/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/devPart.ts b/frontend/lib/api/devPart.ts new file mode 100644 index 00000000..3798a600 --- /dev/null +++ b/frontend/lib/api/devPart.ts @@ -0,0 +1,249 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) — wace partMng.xml 1:1 +// 라우트: /api/development/part-temp/*, /api/development/part/* +// ============================================================ + +export interface PartListFilter { + search_part_no?: string; + search_part_name?: string; + search_material?: string; + search_spec?: string; + search_part_type?: string; + writer?: string; + status?: string; + status_arr?: string[]; + product_code?: string; + upg_no?: string; + page?: number; + page_size?: number; + + // M2 추가 필터 + search_year?: string; + search_design_date_from?: string; + search_design_date_to?: string; + customer_objid?: string; + customer_cd?: string; + project_name?: string; + unit_code?: string; + is_last?: string; + eo?: string; +} + +/** partMngBaseSimple + M1/M2 추가 컬럼 평탄화 — Postgres는 컬럼명을 소문자로 반환 */ +export interface PartRow { + objid: string; + part_no: string | null; + part_name: string | null; + product_mgmt_objid: string | null; + upg_no: string | null; + unit: string | null; + unit_title: string | null; + qty: string | null; + spec: string | null; + post_processing: string | null; + material: string | null; + weight: string | null; + part_type: string | null; + part_type_title: string | null; + remark: string | null; + es_spec: string | null; + ms_spec: string | null; + change_type: string | null; + design_apply_point: string | null; + change_option: string | null; + change_option_name: string | null; + management_flag: string | null; + revision: string | null; + status: string | null; + reg_date: string | null; + part_regdate_title: string | null; + edit_date: string | null; + writer: string | null; + is_last: string | null; + is_longd: string | null; + eo_date: string | null; + eo_no: string | null; + eo_temp: string | null; + maker: string | null; + contract_objid: string | null; + thickness: string | null; + width: string | null; + height: string | null; + out_diameter: string | null; + in_diameter: string | null; + length: string | null; + sourcing_code: string | null; + supply_code: string | null; + supply_name: string | null; + sub_material: string | null; + parent_part_no: string | null; + design_date: string | null; + deploy_date: string | null; + excel_upload_seq: string | number | null; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; + cu_total_cnt: number | string | null; + + // 추가 15컬럼 + 라벨 + heat_treatment_hardness: string | null; + heat_treatment_method: string | null; + surface_treatment: string | null; + acctfg: string | null; + acctfg_nm: string | null; + odrfg: string | null; + odrfg_nm: string | null; + unit_dc: string | null; + unit_dc_nm: string | null; + unitmang_dc: string | null; + unitmang_dc_nm: string | null; + unitchng_nb: string | number | null; + lot_fg: string | null; + lot_fg_nm: string | null; + use_yn: string | null; + use_yn_nm: string | null; + qc_fg: string | null; + qc_fg_nm: string | null; + setitem_fg: string | null; + setitem_fg_nm: string | null; + req_fg: string | null; + req_fg_nm: string | null; + unit_length: string | null; + unit_qty: string | null; + + // M1 전용 부속 + partner_title?: string | null; + parent_part_info?: string | null; + bom_report_objid?: string | null; + objid_qty?: string | null; + child_objid?: string | null; + q_qty?: string | null; + q_qty_raw?: string | null; + qty_temp?: string | null; + sort?: string | null; + + // M2 전용 부속 + num?: number | null; + bom_qty?: string | null; +} + +export interface PartListResponse { + rows: PartRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface PartCreateBody { + part_objid?: string; + part_no: string; + part_name: string; + unit?: string; + qty?: string; + spec?: string; + material?: string; + thickness?: string; + width?: string; + height?: string; + out_diameter?: string; + in_diameter?: string; + length?: string; + remark?: string; + part_type: string; + product_mgmt_objid?: string; + supply_code?: string; + maker?: string; + contract_objid?: string; + post_processing?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + acctfg?: string; + odrfg?: string; + unit_dc?: string; + unitmang_dc?: string; + unitchng_nb?: string; + lot_fg?: string; + use_yn?: string; + qc_fg?: string; + setitem_fg?: string; + req_fg?: string; + unit_length?: string; + unit_qty?: string; +} + +export interface PartUpdateBody { + part_name?: string; + material?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + maker?: string; + part_type?: string; + acctfg?: string; + odrfg?: string; + spec?: string; + unit_dc?: string; + unitmang_dc?: string; + unitchng_nb?: string; + lot_fg?: string; + use_yn?: string; + qc_fg?: string; + setitem_fg?: string; + req_fg?: string; + unit_length?: string; + unit_qty?: string; + remark?: string; +} + +export interface DeployResult { + deployed: number; + eo_nos: Record; +} + +// ─── API ──────────────────────────────────────────────────── + +export const devPartApi = { + // M1 그리드 + async listTemp(filter: PartListFilter = {}): Promise { + const res = await apiClient.get("/development/part-temp/list", { params: filter }); + return res.data?.data as PartListResponse; + }, + + // M2 그리드 + async list(filter: PartListFilter = {}): Promise { + const res = await apiClient.get("/development/part/list", { params: filter }); + return res.data?.data as PartListResponse; + }, + + // 단건 상세 + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/part/${objid}`); + return res.data?.data ?? null; + }, + + // 신규 등록 (38 컬럼) + async create(body: PartCreateBody): Promise<{ objid: string }> { + const res = await apiClient.post("/development/part", body); + return res.data?.data; + }, + + // 상세 수정 (21 컬럼) + async update(objid: string, body: PartUpdateBody) { + return (await apiClient.put(`/development/part/${objid}`, body)).data; + }, + + // 확정 (M1→M2): EO_NO 채번 + part_mng_history 이력 + async deploy(objids: string[]): Promise { + const res = await apiClient.post("/development/part-temp/deploy", { objids }); + return res.data?.data as DeployResult; + }, + + // 다중 삭제 + async remove(objids: string[]) { + const res = await apiClient.delete("/development/part", { data: { objids } }); + return res.data; + }, +}; From 0872199b30072898d832b91dada658c647a8a2cf Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 16:23:10 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BO?= =?UTF-8?q?M=20=EB=93=B1=EB=A1=9D=C2=B7=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=8B=A0=EC=84=A4=20(PR-B)=20=E2=80=94=20wace=20pa?= =?UTF-8?q?rtMng=201:1=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend (M3+M4): - devBomService: list/getByObjid/updateStatus/removeMany/ascending/descending - M3 그리드 SQL (getBOMStandardStructureGridList 1:1) - customer_mng 매핑 (wace SUPPLY_MNG → vexplor customer_mng.customer_code) - PRODUCT_NAME LEFT JOIN comm_code (CODE_NAME 함수 대체) - M3 다중 삭제 트랜잭션 (bom_part_qty + part_bom_report CASCADE) - M4 정/역전개 재귀 CTE (bom_part_qty 트리 + part_mng JOIN) - vexplor 적응: M4 product_mgmt_spec/upg/vc 분기 제거 (스키마 단순화) - PG 재귀 CTE 타입 일치: ARRAY[BP.objid::varchar] 명시 cast frontend (M3+M4): - ebom-regist (M3): 9셀 그리드 (제품구분·품번·품명·E-BOM·등록자·등록일·확정일·Version·상태) - ebom-search (M4): 동적 LEVEL 컬럼 + 정/역전개 토글 - BomReportStatusDialog: 상태 변경 (create/changeDesign/deploy + version) - AdminPageRenderer dynamic 임포트 2건 + menu_info URL spec 정렬 본 PR 제외 (별 PR): E-BOM Excel Import, 정/역전개 Excel Download, BOM_PART_QTY 수량 편집 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 2 + .../src/controllers/devBomController.ts | 103 ++++++ backend-node/src/routes/devBomRoutes.ts | 23 ++ backend-node/src/services/devBomService.ts | 341 ++++++++++++++++++ docs/migration/development/02-ebom.md | 282 +++++++++++++++ .../development/ebom-regist/page.tsx | 183 ++++++++++ .../development/ebom-search/page.tsx | 164 +++++++++ .../development/BomReportStatusDialog.tsx | 130 +++++++ .../components/layout/AdminPageRenderer.tsx | 2 + frontend/lib/api/devBom.ts | 146 ++++++++ 10 files changed, 1376 insertions(+) create mode 100644 backend-node/src/controllers/devBomController.ts create mode 100644 backend-node/src/routes/devBomRoutes.ts create mode 100644 backend-node/src/services/devBomService.ts create mode 100644 docs/migration/development/02-ebom.md create mode 100644 frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx create mode 100644 frontend/components/development/BomReportStatusDialog.tsx create mode 100644 frontend/lib/api/devBom.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9781ae35..9c164e37 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -178,6 +178,7 @@ import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식) +import devBomRoutes from "./routes/devBomRoutes"; // 개발관리>E-BOM 등록/조회 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -425,6 +426,7 @@ app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/p app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인) app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인) +app.use("/api/development", devBomRoutes); // 개발관리>E-BOM 등록/조회 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts new file mode 100644 index 00000000..50281618 --- /dev/null +++ b/backend-node/src/controllers/devBomController.ts @@ -0,0 +1,103 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) 컨트롤러. +// 라우트: +// GET /api/development/ebom/list (M3 그리드) +// GET /api/development/ebom/:objid (M3 단건) +// PUT /api/development/ebom/:objid/status (M3 상태변경) +// DELETE /api/development/ebom (M3 다중 삭제, body: { objids }) +// GET /api/development/ebom-tree/ascending (M4 정전개) +// GET /api/development/ebom-tree/descending (M4 역전개) +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/devBomService"; +import { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.BomReportListFilter { + const filter: svc.BomReportListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +// ─── M3 그리드 ────────────────────────────────────────────── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM(M3) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M3 단건 ──────────────────────────────────────────────── + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("E-BOM 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M3 상태 변경 ────────────────────────────────────────── + +export async function updateStatus(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { objid } = req.params; + const rowCount = await svc.updateStatus(userId, objid, req.body); + if (rowCount === 0) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, message: "상태가 변경되었습니다." }); + } catch (e: any) { + logger.error("E-BOM 상태 변경 실패", { error: e.message }); + return res.status(400).json({ success: false, message: e.message }); + } +} + +// ─── M3 다중 삭제 ────────────────────────────────────────── + +export async function removeMany(req: AuthenticatedRequest, res: Response) { + try { + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const removed = await svc.removeMany(objids); + return res.json({ success: true, data: { removed }, message: `${removed}건이 삭제되었습니다.` }); + } catch (e: any) { + logger.error("E-BOM 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M4 정전개 ───────────────────────────────────────────── + +export async function ascending(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.ascending(req.query as svc.BomTreeFilter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM 정전개 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M4 역전개 ───────────────────────────────────────────── + +export async function descending(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.descending(req.query as svc.BomTreeFilter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM 역전개 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts new file mode 100644 index 00000000..9e0676a5 --- /dev/null +++ b/backend-node/src/routes/devBomRoutes.ts @@ -0,0 +1,23 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) 라우트. +// app.ts: app.use("/api/development", devBomRoutes) — devPart 라우터와 prefix 공유. +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/devBomController"; + +const router = Router(); +router.use(authenticateToken); + +// M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위) +router.get("/ebom-tree/ascending", ctrl.ascending); +router.get("/ebom-tree/descending", ctrl.descending); + +// M3 — 그리드 + CRUD +router.get("/ebom/list", ctrl.getList); +router.delete("/ebom", ctrl.removeMany); +router.put("/ebom/:objid/status", ctrl.updateStatus); +router.get("/ebom/:objid", ctrl.getByObjid); + +export default router; diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts new file mode 100644 index 00000000..e37c0fc0 --- /dev/null +++ b/backend-node/src/services/devBomService.ts @@ -0,0 +1,341 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 이식. +// +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/partMng.xml): +// getBOMStandardStructureGridList → list() (M3 그리드, part_bom_report) +// updateStructureStatus → updateStatus() (M3 상태변경) +// deleteBomReport + deleteBomQty → removeMany() (M3 다중 삭제 트랜잭션) +// structureAscendingList → ascending() (M4 정전개, 재귀 CTE) +// selectStructureDescendingList → descending() (M4 역전개, 재귀 CTE) +// +// vexplor_rps 적응: +// · customer_mng 매핑: wace SUPPLY_MNG.OBJID → vexplor customer_mng.customer_code +// · PRODUCT_NAME: wace CODE_NAME() → vexplor LEFT JOIN comm_code CC_PRD +// · M4 product_mgmt_spec/upg/vc 분기 제거 (vexplor part_bom_report 단순화) +// ============================================================ + +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 필터/바디 타입 ────────────────────────────────────────── + +export interface BomReportListFilter { + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_unit_name?: string; + search_writer?: string; + product_cd?: string; + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + status?: string; + page?: number; + page_size?: number; +} + +export interface BomReportStatusBody { + product_cd?: string; + part_no?: string; + part_name?: string; + version?: string; + status: string; +} + +export interface BomTreeFilter { + bom_report_objid?: string; + project_name?: string; // part_bom_report.contract_objid + unit_code?: string; + search_part_no?: string; + search_part_name?: string; +} + +// ─── 공용 파라미터 빌더 ──────────────────────────────────── + +function buildListWhere(filter: BomReportListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.customer_cd) { conds.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_cd); } + if (filter.project_name) { conds.push(`T.CONTRACT_OBJID = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { conds.push(`T.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); } + if (filter.search_unit_name) { + conds.push(`EXISTS (SELECT 1 FROM PMS_WBS_TASK W + WHERE W.OBJID = T.UNIT_CODE + AND (UPPER(W.TASK_NAME) LIKE UPPER($${idx}) OR UPPER(W.UNIT_NO) LIKE UPPER($${idx})))`); + params.push(`%${filter.search_unit_name}%`); idx++; + } + if (filter.search_writer) { conds.push(`T.WRITER = $${idx++}`); params.push(filter.search_writer); } + if (filter.product_cd) { conds.push(`T.PRODUCT_CD = $${idx++}`); params.push(filter.product_cd); } + if (filter.search_part_no) { conds.push(`UPPER(T.PART_NO) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_no}%`); } + if (filter.search_part_name) { conds.push(`UPPER(T.PART_NAME) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_name}%`); } + if (filter.search_from_date) { conds.push(`T.REGDATE::date >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_from_date); } + if (filter.search_to_date) { conds.push(`T.REGDATE::date <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_to_date); } + if (filter.status) { conds.push(`T.STATUS = $${idx++}`); params.push(filter.status); } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── M3 그리드 ────────────────────────────────────────────── + +export async function list(filter: BomReportListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildListWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + SELECT + ROW_NUMBER() OVER (ORDER BY T.REGDATE DESC) AS NUM, + T.OBJID, T.CUSTOMER_OBJID, SM.customer_name AS CUSTOMER_NAME, + 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, + CASE UPPER(T.STATUS) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' 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, + T.DEPLOY_DATE, T.REVISION, + EO_DATA.EO_NO, EO_DATA.EO_DATE, + T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID, + COALESCE(EO_DATA.BOM_CNT, 0)::int AS BOM_CNT, + T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME, + T.PART_NO, T.PART_NAME + FROM PART_BOM_REPORT T + LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = T.WRITER + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active' + LEFT JOIN ( + SELECT BP.BOM_REPORT_OBJID, + MAX(PM2.EO_NO) AS EO_NO, + MAX(PM2.EO_DATE) AS EO_DATE, + COUNT(*) AS BOM_CNT + FROM BOM_PART_QTY BP + LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar + GROUP BY BP.BOM_REPORT_OBJID + ) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID + WHERE ${where.sql} + `; + + const dataSql = `${baseSql} ORDER BY T.REGDATE DESC NULLS LAST LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize }; +} + +// ─── M3 단건 ──────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = ` + SELECT T.*, + CC_PRD.code_name AS PRODUCT_NAME, + (SELECT COUNT(*) FROM BOM_PART_QTY Q WHERE Q.BOM_REPORT_OBJID = T.OBJID) AS BOM_CNT + FROM PART_BOM_REPORT T + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status='active' + WHERE T.OBJID = $1 + `; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── M3 상태 변경 ────────────────────────────────────────── + +export async function updateStatus(userId: string, objid: string, body: BomReportStatusBody) { + if (!body.status) throw new Error("status는 필수입니다."); + 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() + WHERE OBJID = $7 + `; + const r = await getPool().query(sql, [ + body.product_cd ?? null, + body.part_no ?? null, + body.part_name ?? null, + body.version ?? null, + body.status, + userId, + objid, + ]); + return r.rowCount ?? 0; +} + +// ─── M3 다중 삭제 (트랜잭션) ─────────────────────────────── + +export async function removeMany(objids: string[]): Promise { + if (!objids || objids.length === 0) return 0; + let removed = 0; + await transaction(async (client: PoolClient) => { + // 1) 자식 트리 + await client.query( + `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1::varchar[])`, + [objids] + ); + // 2) 메인 + const r = await client.query( + `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1::varchar[])`, + [objids] + ); + removed = r.rowCount ?? 0; + }); + logger.info("E-BOM 삭제 완료", { removed }); + return removed; +} + +// ─── M4 정전개 (재귀 CTE) ────────────────────────────────── + +export async function ascending(filter: BomTreeFilter) { + const pool = getPool(); + const params: any[] = []; + const conds: string[] = []; + let idx = 1; + + // 시작점 필터: 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘 + if (filter.bom_report_objid) { + conds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + 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++})`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_name}%`); + } + 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 ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.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, + 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.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, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + 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, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (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 + ${finalWhere} + ORDER BY T.path + `; + const r = await pool.query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + +// ─── M4 역전개 (재귀 CTE — parent 방향) ──────────────────── + +export async function descending(filter: BomTreeFilter) { + const pool = getPool(); + const params: any[] = []; + const anchorConds: string[] = []; + let idx = 1; + + // anchor: PART 검색 필터로 leaf 후보 선택. 없으면 전체 (잎-가지가 자식 없는 행) + if (filter.search_part_no) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_no) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_name) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_name}%`); + } + if (filter.bom_report_objid) { + anchorConds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + anchorConds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + if (anchorConds.length === 0) { + // 검색 조건이 전혀 없으면 빈 결과 반환 (역전개는 통상 PART 한정 조회) + return { rows: [], max_level: 0 }; + } + const anchorWhere = anchorConds.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, + BP.part_no, BP.qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE ${anchorWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.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.objid = T.parent_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, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + 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, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (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 + ORDER BY T.path + `; + const r = await pool.query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} diff --git a/docs/migration/development/02-ebom.md b/docs/migration/development/02-ebom.md new file mode 100644 index 00000000..d50acd14 --- /dev/null +++ b/docs/migration/development/02-ebom.md @@ -0,0 +1,282 @@ +# PR-B : E-BOM 등록·조회 묶음 구현 명세 (M3+M4) + +> 작성: 2026-05-12 / 범위: 개발관리 M3(E-BOM 등록) + M4(E-BOM 조회) — `part_bom_report` 메인, `bom_part_qty` 트리. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +원본 `wace_plm/src/com/pms/mapper/partMng.xml`: + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `getBOMStandardStructureGridList` | 2,859 | `GET /api/development/ebom/list` | M3 그리드 (PART_BOM_REPORT + 집계) | +| `updateStructureStatus` | 8,027 | `PUT /api/development/ebom/status` | M3 상태변경 (PRODUCT_CD/PART_NO/NAME/VERSION/STATUS) | +| `deleteBomReport` | 6,838 | `DELETE /api/development/ebom` (body `objids`) | M3 다중 삭제 + BOM_PART_QTY CASCADE | +| `deleteBomQty` | 6,847 | (deleteBomReport 내부) | M3 삭제 시 자식 트리 동시 삭제 | +| `structureAscendingList` | 7,361 | `GET /api/development/ebom/ascending` | M4 정전개 (root → leaf) | +| `selectStructureDescendingList` | 6,582 | `GET /api/development/ebom/descending` | M4 역전개 (leaf → root) | + +--- + +## 2. API 엔드포인트 명세 + +### 2.1 M3 그리드 — `GET /api/development/ebom/list` + +**Query**: +``` +customer_cd?: string // part_bom_report.customer_objid +project_name?: string // part_bom_report.contract_objid (project_mgmt.objid) +unit_code?: string // pms_wbs_task.objid +search_unit_name?: string // pms_wbs_task.unit_no/task_name LIKE +search_writer?: string // part_bom_report.writer +product_cd?: string // wace 'product_code' 검색 (part_bom_report.product_cd) +search_part_no?: string // part_bom_report.part_no LIKE +search_part_name?: string // part_bom_report.part_name LIKE +search_from_date?: string // regdate from +search_to_date?: string // regdate to +status?: string // part_bom_report.status (create/changeDesign/deploy) +page?, page_size? +``` + +**SQL** (vexplor_rps part_bom_report 스키마 적응 — wace `getBOMStandardStructureGridList` 의 PRODUCT_CD/PART_NO/PART_NAME 분기 활성, MULTI_* 컬럼 그대로): +```sql +SELECT + ROW_NUMBER() OVER(ORDER BY T.REGDATE DESC) AS NUM, + T.OBJID, T.CUSTOMER_OBJID, SM.SUPPLY_NAME AS CUSTOMER_NAME, + 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, + CASE UPPER(T.STATUS) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' END AS STATUS_TITLE, + T.WRITER, UI.DEPT_NAME, UI.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, + T.DEPLOY_DATE, T.REVISION, + EO_DATA.EO_NO, EO_DATA.EO_DATE, + T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID, + COALESCE(EO_DATA.BOM_CNT, 0) AS BOM_CNT, + T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME, + T.PART_NO, T.PART_NAME + FROM PART_BOM_REPORT T + LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID -- vexplor 매핑 + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE + LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active' + LEFT JOIN ( + SELECT BP.BOM_REPORT_OBJID, + MAX(PM2.EO_NO) AS EO_NO, + MAX(PM2.EO_DATE) AS EO_DATE, + COUNT(*) AS BOM_CNT + FROM BOM_PART_QTY BP + LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar + GROUP BY BP.BOM_REPORT_OBJID + ) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID + WHERE 1=1 + 동적 (위 11 필터) +``` + +**Response**: `{ rows: BomReportRow[], total, page, pageSize }` + +### 2.2 M3 단건 상세 — `GET /api/development/ebom/:objid` + +`SELECT T.* FROM PART_BOM_REPORT T WHERE T.OBJID = $1` + (옵션) BOM_PART_QTY 카운트. +편집 다이얼로그 진입용. + +### 2.3 M3 상태 변경 — `PUT /api/development/ebom/:objid/status` + +**Body**: `{ product_cd?, part_no?, part_name?, version?, status }` — wace `updateStructureStatus` 1:1. +`STATUS` 만 변경하는 단순 케이스도 지원 (다른 필드 NULL 허용). + +```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() + WHERE OBJID = $7 +``` + +### 2.4 M3 다중 삭제 — `DELETE /api/development/ebom` (body: `{ objids: string[] }`) + +**트랜잭션**: +1. `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1)` (자식 트리) +2. `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1)` (메인) + +wace는 part_mng도 정리(`deleteBomQtyPart`, status='create'만)하지만 본 PR에서는 part_mng 보존 (M1·M2와 결합 안 됨). + +### 2.5 M4 정전개 — `GET /api/development/ebom/ascending` + +**Query**: +``` +bom_report_objid?: string // 단일 BOM 한정 조회 +project_name?: string // PART_BOM_REPORT.contract_objid +unit_code?: string +search_part_no?: string +search_part_name?: string +``` + +**SQL** (재귀 CTE — wace `structureAscendingList` 의 BOM_PART_QTY 트리 1:1): +```sql +WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, 1, ARRAY[BP.objid], FALSE + FROM bom_part_qty BP + WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') + AND BP.bom_report_objid = $bom_report_objid /* 또는 필터 적용된 PART_BOM_REPORT 서브쿼리 */ + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.parent_objid = T.objid AND NOT T.cycle +) +SELECT T.*, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + PM.edit_date, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (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 MAX(lev) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ORDER BY T.path +``` + +**Response**: `{ rows: AscRow[], max_level: number }` + +### 2.6 M4 역전개 — `GET /api/development/ebom/descending` + +같은 Query 파라미터. 재귀 방향 반대: +- 시작점: 리프(`child_objid` 가 다른 행의 `parent_objid` 가 아닌 행) 또는 사용자가 지정한 PART +- 트리 부모 방향으로 traverse + +**SQL** (역전개 — wace `selectStructureDescendingList` 단순 매핑): +```sql +WITH RECURSIVE TREE(...) AS ( + /* 1. anchor: 조건 매칭 BOM 또는 leaf part */ + SELECT BP.* , 1 AS lev, ARRAY[BP.objid] AS path, FALSE AS cycle + FROM bom_part_qty BP + WHERE ... /* PART_NO 매칭 등 */ + UNION ALL + /* 2. parent 방향 traverse */ + SELECT B.*, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.objid = T.parent_objid AND NOT T.cycle +) +SELECT ... (정전개와 동일 part_mng / attach_file_info JOIN) +``` + +**Response**: `{ rows: DescRow[], max_level: number }` + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/ + devBomRoutes.ts // 6 endpoint + controllers/ + devBomController.ts + services/ + devBomService.ts // list/getById/updateStatus/removeMany/ascending/descending +``` + +`app.ts`: `app.use("/api/development", devBomRoutes)` (devPart 라우터와 prefix 공유 — Express 중복 등록 안전, 경로 충돌 없음). + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/ + ebom-regist/page.tsx // M3 그리드 + 검색 + 액션 + ebom-search/page.tsx // M4 정/역전개 (동적 LEVEL 컬럼) + components/development/ + BomReportStatusDialog.tsx // M3 상태 변경 다이얼로그 (status select) + lib/api/ + devBom.ts // 6 endpoint 호출 + 타입 +``` + +### 4.1 M3 그리드 (9 셀, wace structureList.jsp:185~215 1:1) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| product_name | 제품구분 | center | 160 | +| part_no | 품번 | left | 210 | +| part_name | 품명 | left | flex | +| bom_cnt | E-BOM (folder click) | center | 150 | +| dept_user_name | 등록자 | center | 120 | +| reg_date | 등록일 | center | 130 | +| deploy_date | 확정일 | center | 100 | +| revision | Version | center | 110 | +| status_title | 상태 | center | 110 | + +### 4.2 M3 검색 폼 (wace 1:1 — 9 필드) + +customer_cd · project_name · unit_code · SEARCH_UNIT_NAME · SEARCH_WRITER · product_cd · SEARCH_PART_NO · SEARCH_PART_NAME · search_fromDate~toDate · status + +본 PR 1차: PRODUCT_CD · SEARCH_PART_NO · SEARCH_PART_NAME · STATUS 4필드로 시작. 나머지 추후 보강. + +### 4.3 M3 액션 버튼 (wace 1:1) + +- 조회 / 삭제 / E-BOM등록(Excel Import — 별 PR) / 상태변경 + +본 PR 포함: 조회 · 삭제 · 상태변경. **E-BOM등록(Excel Import)은 별 PR**. + +### 4.4 M4 동적 LEVEL 컬럼 + +backend response의 `max_level` 값에 따라 컬럼을 동적 생성: +- LEVEL 1..max_level: 각 레벨 컬럼은 `row.lev === i` 인 행의 `part_no` 표시 (트리 들여쓰기 효과) +- 고정 컬럼: 품번 · 품명 · 3D/2D/PDF · 수량 · 변경일 · 규격 · 재질 · 중량 · 비고 + +DataGrid의 컬럼 배열을 fetch 결과 도착 시 동적 생성 (state). + +### 4.5 M4 액션 + +- 정전개 조회 (default) +- 역전개 조회 +- (Excel Download — 별 PR) + +--- + +## 5. 본 PR 제외 항목 + +| 항목 | 사유 / 후속 | +|---|---| +| E-BOM 등록 (Excel Import) | `openBomReportExcelImportPopUp.jsp` — 별 PR | +| 정/역전개 Excel Download | `structureAscendingListExcel`/`structureDescendingExcelList` — 별 PR | +| `BOM_PART_QTY` 직접 편집 (수량 변경) | `structureQtySave` — wace 운영판에서도 별 화면 | +| 다중 BOM(MULTI_*) 분기 처리 | 현재 vexplor 데이터 없음 — 기본 1:1만 | +| wace_plm `product_mgmt_spec/upg/vc` 분기 | vexplor 스키마는 product_cd 단순 — 운영판 1:1 적응 | + +--- + +## 6. 검증 시나리오 (verify.md 기준) + +1. M3 페이지 진입 → part_bom_report 그리드 (현재 0건, schema/UI 동작 확인) +2. M3 상태변경 다이얼로그 → status='deploy' → DB 반영 확인 +3. M3 다중 삭제 → bom_part_qty CASCADE 확인 +4. M4 페이지 진입 → 정전개 (`/ascending`) 0건 응답 → 페이지 정상 표시 +5. M4 역전개 토글 → `/descending` 응답 +6. (시드 후) MAX_LEVEL=3 트리에서 동적 컬럼 3개 생성 확인 + +--- + +## 7. 적응 사항 (운영판 대비 변경점) + +| # | 항목 | 변경 | +|---|---|---| +| 1 | `customer_mng` 매핑 | wace `SUPPLY_MNG.OBJID::VARCHAR = T.CUSTOMER_OBJID` → vexplor `customer_mng.customer_code = T.CUSTOMER_OBJID` | +| 2 | `PRODUCT_NAME` lookup | wace `CODE_NAME(PRODUCT_CD)` → vexplor `LEFT JOIN comm_code` (`CC_PRD`) | +| 3 | M4 `PRODUCT_MGMT_*` 분기 제거 | vexplor part_bom_report 는 product_cd/version 단순화 — wace `product_mgmt_spec/upg/vc` 컬럼 없음 → JOIN 생략, 정전개는 BOM_PART_QTY 트리만 | +| 4 | 다중 삭제 트랜잭션 | wace 두 매퍼(`deleteBomQty` + `deleteBomReport`) 호출 → backend `transaction()` 한 번 | diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx new file mode 100644 index 00000000..52f17273 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +// 개발관리 > E-BOM 등록 (M3) — wace structureList.jsp 1:1 +// 그리드: part_bom_report 9셀 +// 액션: 조회 / 삭제 / 상태변경 (E-BOM등록 Excel Import는 별 PR) +// 참조: docs/migration/development/02-ebom.md + +import React, { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Search, Loader2, RotateCcw, Trash2, Settings, +} from "lucide-react"; +import { toast } from "sonner"; +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"; + +const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용) + +const STATUS_OPTIONS = [ + { code: "create", label: "등록중" }, + { code: "changeDesign", label: "설계변경미배포" }, + { code: "deploy", label: "배포완료" }, +]; + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "product_name", label: "제품구분", width: "w-[160px]", align: "center", frozen: true }, + { key: "part_no", label: "품번", width: "w-[210px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, + { key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "right", formatNumber: true }, + { key: "dept_user_name", label: "등록자", width: "w-[140px]", align: "center" }, + { key: "reg_date", label: "등록일", width: "w-[120px]", align: "center" }, + { key: "deploy_date", label: "확정일", width: "w-[120px]", align: "center" }, + { key: "revision", label: "Version", width: "w-[100px]", align: "center" }, + { key: "status_title", label: "상태", width: "w-[120px]", align: "center" }, +]; + +const EMPTY_FILTER: BomReportListFilter = { + product_cd: "", status: "", + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function EbomRegistPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [statusOpen, setStatusOpen] = useState(false); + const [statusObjid, setStatusObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devBomApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까? (자식 BOM 트리도 함께 삭제됨)`)) return; + try { + const res = await devBomApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + const handleStatusChange = () => { + if (checkedIds.length !== 1) return toast.error("상태 변경할 행 1개를 선택하세요."); + setStatusObjid(checkedIds[0]); + setStatusOpen(true); + }; + + return ( +
+
+
+ + setFilter({ ...filter, product_cd: v })} + /> + + + + + + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> + +
+
+
총 {total.toLocaleString()}건
+
+ + + + +
+
+
+ +
+ +
+ + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx new file mode 100644 index 00000000..6e4bd229 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +// 개발관리 > E-BOM 조회 (M4) — wace structureAscendingList.jsp 1:1 +// 정전개(루트→리프) / 역전개(리프→부모) 토글. 동적 LEVEL 컬럼. +// 참조: docs/migration/development/02-ebom.md + +import React, { useCallback, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, +} from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom"; + +type Direction = "ascending" | "descending"; + +const EMPTY_FILTER: BomTreeFilter = { + project_name: "", unit_code: "", + search_part_no: "", search_part_name: "", +}; + +export default function EbomSearchPage() { + const [filter, setFilter] = useState(EMPTY_FILTER); + const [direction, setDirection] = useState("ascending"); + const [rows, setRows] = useState([]); + const [maxLevel, setMaxLevel] = useState(0); + const [loading, setLoading] = useState(false); + + const runQuery = useCallback(async (dir: Direction) => { + setLoading(true); + try { + const fn = dir === "ascending" ? devBomApi.ascending : devBomApi.descending; + const res = await fn(filter); + setRows(res.rows ?? []); + setMaxLevel(Number(res.max_level) || 0); + setDirection(dir); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + // 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시 + 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]", + }); + } + return [ + ...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]" }, + ]; + }, [maxLevel]); + + // 행 데이터: __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], + ); + + return ( +
+
+
+ + setFilter({ ...filter, project_name: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, unit_code: e.target.value })} + placeholder="pms_wbs_task.objid" /> + + + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="part_name LIKE" /> + +
+
+
+ 모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} +
+
+ + + +
+
+ {direction === "descending" && ( +
+ 역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다. +
+ )} +
+ +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/components/development/BomReportStatusDialog.tsx b/frontend/components/development/BomReportStatusDialog.tsx new file mode 100644 index 00000000..c783b765 --- /dev/null +++ b/frontend/components/development/BomReportStatusDialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +// 개발관리 > E-BOM 상태 변경 다이얼로그. +// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드. + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { devBomApi, BomReportRow } from "@/lib/api/devBom"; + +const STATUS_OPTIONS = [ + { code: "create", label: "등록중" }, + { code: "changeDesign", label: "설계변경미배포" }, + { code: "deploy", label: "배포완료" }, +]; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; + onSaved: () => void; +} + +export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) { + const [row, setRow] = useState(null); + const [status, setStatus] = useState(""); + const [version, setVersion] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devBomApi.detail(objid) + .then((data) => { + if (!alive) return; + if (!data) { + toast.error("E-BOM 보고서를 찾을 수 없습니다."); + onOpenChange(false); + return; + } + setRow(data); + setStatus(data.status ?? ""); + setVersion(data.revision ?? ""); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + const handleSave = async () => { + if (!objid) return; + if (!status) return toast.error("상태를 선택하세요."); + setSaving(true); + try { + await devBomApi.updateStatus(objid, { + status, + version: version || undefined, + }); + toast.success("상태가 변경되었습니다."); + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + + + + E-BOM 상태 변경 + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+
제품구분: {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..." /> +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 23eddf88..25684a8d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -109,6 +109,8 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/development/part-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-regist/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/ebom-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-regist/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/ebom-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-search/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts new file mode 100644 index 00000000..fff3363b --- /dev/null +++ b/frontend/lib/api/devBom.ts @@ -0,0 +1,146 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 +// 라우트: /api/development/ebom/*, /api/development/ebom-tree/* +// ============================================================ + +export interface BomReportListFilter { + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_unit_name?: string; + search_writer?: string; + product_cd?: string; + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + status?: string; + page?: number; + page_size?: number; +} + +export interface BomReportRow { + num: number | string; + objid: string; + customer_objid: string | null; + customer_name: string | null; + contract_objid: string | null; + customer_project_name: string | null; + project_no: string | null; + unit_code: string | null; + unit_name: string | null; + status: string | null; + status_title: string | null; + writer: string | null; + dept_name: string | null; + user_name: string | null; + dept_user_name: string | null; + regdate: string | null; + reg_date: string | null; + deploy_date: string | null; + revision: string | null; + eo_no: string | null; + eo_date: string | null; + note: string | null; + multi_yn: string | null; + multi_master_yn: string | null; + multi_break_yn: string | null; + multi_master_objid: string | null; + bom_cnt: number | string | null; + product_cd: string | null; + product_name: string | null; + part_no: string | null; + part_name: string | null; +} + +export interface BomReportListResponse { + rows: BomReportRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface BomReportStatusBody { + product_cd?: string; + part_no?: string; + part_name?: string; + version?: string; + status: string; +} + +export interface BomTreeFilter { + bom_report_objid?: string; + project_name?: string; + unit_code?: string; + search_part_no?: string; + search_part_name?: string; +} + +export interface BomTreeRow { + bom_report_objid: string | null; + objid: string; + parent_objid: string | null; + child_objid: string | null; + part_no: string | null; // bom_part_qty.part_no (= part_mng.objid) + qty: string | null; + seq: number | string | null; + status: string | null; + lev: number; + path: string[] | null; + // part_mng JOIN + pm_part_no: string | null; + pm_part_name: string | null; + spec: string | null; + material: string | null; + weight: string | null; + remark: string | null; + edit_date: string | null; + eo_no: string | null; + revision: string | null; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; + max_level: number | string | null; +} + +export interface BomTreeResponse { + rows: BomTreeRow[]; + max_level: number; +} + +// ─── API ───────────────────────────────────────────────── + +export const devBomApi = { + // M3 그리드 + async list(filter: BomReportListFilter = {}): Promise { + const res = await apiClient.get("/development/ebom/list", { params: filter }); + return res.data?.data as BomReportListResponse; + }, + + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/ebom/${objid}`); + return res.data?.data ?? null; + }, + + async updateStatus(objid: string, body: BomReportStatusBody) { + return (await apiClient.put(`/development/ebom/${objid}/status`, body)).data; + }, + + async remove(objids: string[]) { + const res = await apiClient.delete("/development/ebom", { data: { objids } }); + return res.data; + }, + + // M4 + async ascending(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/ascending", { params: filter }); + return res.data?.data as BomTreeResponse; + }, + + async descending(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/descending", { params: filter }); + return res.data?.data as BomTreeResponse; + }, +}; From c9adfd7327d313b55b55e60f5c25ef5e1a9ebe9d Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 16:27:37 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=B3=80=EA=B2=BD=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EB=89=B4=20=EC=8B=A0=EC=84=A4=20(PR-C)=20?= =?UTF-8?q?=E2=80=94=20wace=20partMng=201:1=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend (M5, read-only): - devEoHistoryService: list + getByObjid - partMngHistList SQL 1:1 (NVL→COALESCE, PART_MNG.OBJID bigint cast, CODE_NAME→LEFT JOIN comm_code) - 동적 필터 10종 (Year/contract_objid/unit_code/part_no/part_name/change_option/eo_start~end/change_type/part_type/writer_id) - 신규등록 제외 가드: NOT (HIS_STATUS='DEPLOY' AND CHANGE_TYPE IS NULL AND REVISION='RE') + BOM_STATUS='deploy' - 품번변경(CHANGE_OPTION=0001790) 'A->B' 머지 CASE (part_no_disp/part_name_disp/revision_disp) frontend (M5): - change-list/page.tsx: 16셀 그리드 + 검색 8필드 + 페이징 - PartHisDetailDialog: 모든 필드 disabled, 4 섹션 (EO/프로젝트/PART/수량) - AdminPageRenderer dynamic 임포트 + 기존 menu_info URL 그대로 사용 개발관리 5개 메뉴 (M1~M5) baseline 완료. 본 PR 제외 (별 PR): writer SmartSelect, change_type/change_option comm_code 그룹 SmartSelect (그룹 ID 확정 후) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 2 + .../src/controllers/devEoHistoryController.ts | 39 ++++ backend-node/src/routes/devEoHistoryRoutes.ts | 16 ++ .../src/services/devEoHistoryService.ts | 155 +++++++++++++++ docs/migration/development/03-eo-history.md | 181 +++++++++++++++++ .../development/change-list/page.tsx | 187 ++++++++++++++++++ .../development/PartHisDetailDialog.tsx | 148 ++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 1 + frontend/lib/api/devEoHistory.ts | 106 ++++++++++ 9 files changed, 835 insertions(+) create mode 100644 backend-node/src/controllers/devEoHistoryController.ts create mode 100644 backend-node/src/routes/devEoHistoryRoutes.ts create mode 100644 backend-node/src/services/devEoHistoryService.ts create mode 100644 docs/migration/development/03-eo-history.md create mode 100644 frontend/app/(main)/COMPANY_16/development/change-list/page.tsx create mode 100644 frontend/components/development/PartHisDetailDialog.tsx create mode 100644 frontend/lib/api/devEoHistory.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9c164e37..3b35c385 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -179,6 +179,7 @@ import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관 import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식) import devBomRoutes from "./routes/devBomRoutes"; // 개발관리>E-BOM 등록/조회 (wace_plm 도메인 이식) +import devEoHistoryRoutes from "./routes/devEoHistoryRoutes"; // 개발관리>설계변경 리스트 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -427,6 +428,7 @@ app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진 app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인) app.use("/api/development", devBomRoutes); // 개발관리>E-BOM 등록/조회 (wace_plm 도메인) +app.use("/api/development", devEoHistoryRoutes); // 개발관리>설계변경 리스트 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/devEoHistoryController.ts b/backend-node/src/controllers/devEoHistoryController.ts new file mode 100644 index 00000000..1ff1af29 --- /dev/null +++ b/backend-node/src/controllers/devEoHistoryController.ts @@ -0,0 +1,39 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) 컨트롤러 — read-only. +// GET /api/development/eo-history/list +// GET /api/development/eo-history/:objid +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/devEoHistoryService"; +import { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.EoHistoryListFilter { + const filter: svc.EoHistoryListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("설계변경 리스트 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("설계변경 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/devEoHistoryRoutes.ts b/backend-node/src/routes/devEoHistoryRoutes.ts new file mode 100644 index 00000000..6ea255c0 --- /dev/null +++ b/backend-node/src/routes/devEoHistoryRoutes.ts @@ -0,0 +1,16 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) — read-only 라우트. +// app.ts: app.use("/api/development", devEoHistoryRoutes) — prefix 공유. +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/devEoHistoryController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/eo-history/list", ctrl.getList); +router.get("/eo-history/:objid", ctrl.getByObjid); + +export default router; diff --git a/backend-node/src/services/devEoHistoryService.ts b/backend-node/src/services/devEoHistoryService.ts new file mode 100644 index 00000000..3c874444 --- /dev/null +++ b/backend-node/src/services/devEoHistoryService.ts @@ -0,0 +1,155 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) — wace partMng.xml#partMngHistList 1:1 read-only. +// +// 매퍼 매핑: +// partMngHistList (line 7,787) → list() +// + getByObjid() — 행 클릭 상세 다이얼로그용 (raw PART_MNG_HISTORY 행) +// +// vexplor_rps 적응: +// · NVL() → COALESCE() +// · PART_MNG.OBJID(bigint) = PM.PARENT_PART_NO(varchar) → ::varchar cast +// · CODE_NAME() → LEFT JOIN comm_code 별칭 +// ============================================================ + +import { getPool } from "../database/db"; + +export interface EoHistoryListFilter { + Year?: string; + contract_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + change_option?: string; + eo_start_date?: string; + eo_end_date?: string; + change_type?: string; + part_type?: string; + writer_id?: string; + page?: number; + page_size?: number; +} + +function buildWhere(filter: EoHistoryListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.Year) { conds.push(`TO_CHAR(CM.REGDATE,'YYYY') = $${idx++}`); params.push(filter.Year); } + if (filter.contract_objid) { conds.push(`CM.OBJID = $${idx++}`); params.push(filter.contract_objid); } + if (filter.unit_code) { conds.push(`B.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); } + if (filter.part_no) { conds.push(`UPPER(PM.PART_NO) LIKE UPPER($${idx++})`); params.push(`%${filter.part_no}%`); } + if (filter.part_name) { conds.push(`UPPER(PM.PART_NAME) LIKE UPPER($${idx++})`); params.push(`%${filter.part_name}%`); } + if (filter.change_option) { conds.push(`PM.CHANGE_OPTION = $${idx++}`); params.push(filter.change_option); } + if (filter.eo_start_date) { conds.push(`TO_DATE(PM.EO_DATE,'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); params.push(filter.eo_start_date); } + if (filter.eo_end_date) { conds.push(`TO_DATE(PM.EO_DATE,'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); params.push(filter.eo_end_date); } + if (filter.change_type) { conds.push(`PM.CHANGE_TYPE = $${idx++}`); params.push(filter.change_type); } + if (filter.part_type) { conds.push(`PM.PART_TYPE = $${idx++}`); params.push(filter.part_type); } + if (filter.writer_id) { conds.push(`PM.WRITER = $${idx++}`); params.push(filter.writer_id); } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── 그리드 ──────────────────────────────────────────────── + +export async function list(filter: EoHistoryListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + SELECT + PM.OBJID, + PM.EO_NO, + TO_CHAR(CM.REGDATE, 'YYYY') AS YEAR, + COALESCE(CM.CUSTOMER_PROJECT_NAME, CM2.CUSTOMER_PROJECT_NAME) AS PROJECT_NAME, + COALESCE(CM2.PROJECT_NO, CM.PROJECT_NO) AS PROJECT_NO, + (SELECT SP.PART_NO || ' ' || SP.PART_NAME + FROM PART_MNG SP + WHERE SP.OBJID::varchar = PM.PARENT_PART_NO) AS PARENT_PART_INFO, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NO,'') || '->' || COALESCE(PM.CHG_PART_NO,'') + ELSE PM.PART_NO END AS PART_NO_DISP, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NAME,'') || '->' || + COALESCE((SELECT P.PART_NAME FROM PART_MNG P WHERE P.OBJID::varchar = PM.CHG_PART_OBJID), '') + ELSE PM.PART_NAME END AS PART_NAME_DISP, + PM.PART_NO, + PM.PART_NAME, + PM.BOM_QTY_STATUS, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN PM.QTY_TEMP ELSE PM.QTY END AS QTY, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN '' + WHEN PM.BOM_QTY_STATUS = 'beforeEdit' AND PM.QTY = PM.QTY_TEMP THEN '' + ELSE PM.QTY_TEMP END AS QTY_TEMP, + PM.CHANGE_TYPE, + CC_CHGTYPE.code_name AS CHANGE_TYPE_NAME, + PM.CHANGE_OPTION, + CC_CHGOPT.code_name AS CHANGE_OPTION_NAME, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.REVISION,'') || '->' || COALESCE(PM.CHG_PART_REV,'') + ELSE PM.REVISION END AS REVISION_DISP, + PM.REVISION, + PM.EO_DATE, + PM.PART_TYPE, + CC_PARTTYPE.code_name AS PART_TYPE_NAME, + PM.WRITER, + UI.user_name AS WRITER_NAME, + COALESCE(WTS.UNIT_NO || '-' || WTS.TASK_NAME, '') AS UNIT_NAME, + TO_CHAR(PM.HIS_REG_DATE, 'YYYY-MM-DD') AS HIS_REG_DATE_TITLE, + PM.BOM_DEPLOY_DATE, + TO_CHAR(PM.BOM_DEPLOY_DATE, 'YYYY-MM-DD') AS BOM_DEPLOY_DATE_TITLE + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN PART_BOM_REPORT B ON B.OBJID = PM.BOM_REPORT_OBJID + LEFT JOIN PROJECT_MGMT CM2 ON CM2.OBJID = B.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WTS ON WTS.OBJID = B.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE NOT (PM.HIS_STATUS = 'DEPLOY' AND PM.CHANGE_TYPE IS NULL AND PM.REVISION = 'RE') + AND PM.REVISION IS NOT NULL + AND COALESCE(PM.BOM_STATUS, '') = 'deploy' + AND ${where.sql} + `; + + const dataSql = `${baseSql} + ORDER BY COALESCE(PM.HIS_REG_DATE, PM.REG_DATE) DESC, PM.PART_NO + LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize }; +} + +// ─── 단건 ──────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = ` + SELECT PM.*, + CC_CHGTYPE.code_name AS change_type_name, + CC_CHGOPT.code_name AS change_option_name, + CC_PARTTYPE.code_name AS part_type_name, + UI.user_name AS writer_name, + CM.customer_project_name, + CM.project_no + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE PM.OBJID = $1::numeric + `; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} diff --git a/docs/migration/development/03-eo-history.md b/docs/migration/development/03-eo-history.md new file mode 100644 index 00000000..2ef4024c --- /dev/null +++ b/docs/migration/development/03-eo-history.md @@ -0,0 +1,181 @@ +# PR-C : 설계변경 리스트 구현 명세 (M5) + +> 작성: 2026-05-12 / 범위: 개발관리 M5 (설계변경 리스트) — read-only `part_mng_history` 조회. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `partMngHistList` | 7,787 | `GET /api/development/eo-history/list` | M5 그리드 (read-only) | + +추가 엔드포인트: +- `GET /api/development/eo-history/:objid` — 행 클릭 상세 다이얼로그용 (raw row 반환). + +--- + +## 2. API 명세 + +### 2.1 그리드 — `GET /api/development/eo-history/list` + +**Query** (wace 10 필터 1:1): +``` +Year?: string // TO_CHAR(project_mgmt.regdate, 'YYYY') +contract_objid?: string // project_mgmt.objid +unit_code?: string // part_bom_report.unit_code +part_no?: string // LIKE +part_name?: string // LIKE +change_option?: string // comm_code id (EO사유) +eo_start_date?: string +eo_end_date?: string +change_type?: string // comm_code id (EO구분) +part_type?: string // comm_code id +writer_id?: string +page?, page_size? +``` + +**SQL** (wace `partMngHistList` 1:1, vexplor 적응): +```sql +SELECT + PM.OBJID, + PM.EO_NO, + TO_CHAR(CM.REGDATE, 'YYYY') AS YEAR, + COALESCE(CM.CUSTOMER_PROJECT_NAME, CM2.CUSTOMER_PROJECT_NAME) AS PROJECT_NAME, + COALESCE(CM2.PROJECT_NO, CM.PROJECT_NO) AS PROJECT_NO, + /* 모품번: PART_MNG SP에서 PARENT_PART_NO로 조회. PART_MNG.OBJID는 bigint → varchar cast */ + (SELECT SP.PART_NO || ' ' || SP.PART_NAME + FROM PART_MNG SP + WHERE SP.OBJID::varchar = PM.PARENT_PART_NO) AS PARENT_PART_INFO, + /* 품번변경(0001790) 시 'A->B' 머지 */ + CASE WHEN PM.CHANGE_OPTION = '0001790' THEN COALESCE(PM.PART_NO,'') || '->' || COALESCE(PM.CHG_PART_NO,'') + ELSE PM.PART_NO END AS PART_NO_DISP, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NAME,'') || '->' || + COALESCE((SELECT P.PART_NAME FROM PART_MNG P WHERE P.OBJID::varchar = PM.CHG_PART_OBJID), '') + ELSE PM.PART_NAME END AS PART_NAME_DISP, + PM.PART_NO, -- raw + PM.PART_NAME, -- raw + PM.BOM_QTY_STATUS, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN PM.QTY_TEMP ELSE PM.QTY END AS QTY, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN '' + WHEN PM.BOM_QTY_STATUS = 'beforeEdit' AND PM.QTY = PM.QTY_TEMP THEN '' + ELSE PM.QTY_TEMP END AS QTY_TEMP, + PM.CHANGE_TYPE, + CC_CHGTYPE.code_name AS CHANGE_TYPE_NAME, + PM.CHANGE_OPTION, + CC_CHGOPT.code_name AS CHANGE_OPTION_NAME, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.REVISION,'') || '->' || COALESCE(PM.CHG_PART_REV,'') + ELSE PM.REVISION END AS REVISION_DISP, + PM.REVISION, -- raw + PM.EO_DATE, + PM.PART_TYPE, + CC_PARTTYPE.code_name AS PART_TYPE_NAME, + PM.WRITER, + UI.user_name AS WRITER_NAME, + COALESCE(WTS.UNIT_NO || '-' || WTS.TASK_NAME, '') AS UNIT_NAME, + TO_CHAR(PM.HIS_REG_DATE, 'YYYY-MM-DD') AS HIS_REG_DATE_TITLE, + PM.BOM_DEPLOY_DATE, + TO_CHAR(PM.BOM_DEPLOY_DATE, 'YYYY-MM-DD') AS BOM_DEPLOY_DATE_TITLE + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN PART_BOM_REPORT B ON B.OBJID = PM.BOM_REPORT_OBJID + LEFT JOIN PROJECT_MGMT CM2 ON CM2.OBJID = B.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WTS ON WTS.OBJID = B.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE 1=1 + /* 파트 신규등록건 조회 제외 — wace 1:1 */ + AND NOT (PM.HIS_STATUS = 'DEPLOY' AND PM.CHANGE_TYPE IS NULL AND PM.REVISION = 'RE') + AND PM.REVISION IS NOT NULL + AND COALESCE(PM.BOM_STATUS, '') = 'deploy' + + 동적 (위 10 필터) + ORDER BY COALESCE(PM.HIS_REG_DATE, PM.REG_DATE) DESC, PM.PART_NO +``` + +**Response**: `{ rows: EoHistoryRow[], total, page, pageSize }` + +### 2.2 단건 — `GET /api/development/eo-history/:objid` + +`SELECT PM.* FROM PART_MNG_HISTORY PM WHERE PM.OBJID = $1::numeric` + comm_code JOIN (CHANGE_TYPE/OPTION/PART_TYPE 라벨). + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/devEoHistoryRoutes.ts // 2 endpoint + controllers/devEoHistoryController.ts + services/devEoHistoryService.ts // list + getByObjid +``` + +`app.ts`: `app.use("/api/development", devEoHistoryRoutes)`. + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/change-list/page.tsx + components/development/PartHisDetailDialog.tsx + lib/api/devEoHistory.ts +``` + +### 4.1 그리드 16셀 (wace partMngHisList.jsp:50~75 1:1) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| eo_no | EO No | left | 100 | +| project_no | 프로젝트번호 | left | 120 | +| project_name | 프로젝트명 | left | 180 | +| unit_name | 유닛명 | left | 160 | +| parent_part_info | 모품번 | left | 160 | +| part_no_disp | 품번 | left | 160 | +| part_name_disp | 품명 | left | flex | +| qty | 수량 | right | 70 | +| qty_temp | 변경수량 | right | 80 | +| change_type_name | EO구분 | center | 90 | +| change_option_name | EO사유 | center | 100 | +| revision_disp | Revision | center | 90 | +| eo_date | EO Date | center | 100 | +| part_type_name | PART구분 | center | 90 | +| writer_name | 담당자 | center | 90 | +| his_reg_date_title | 실행일 | center | 100 | + +### 4.2 검색 폼 (wace 10 필드 1:1) + +Year · contract_objid · unit_code · part_no · part_name · change_option · eo_start_date~end_date · change_type · part_type · writer_id + +본 PR 1차: Year · 프로젝트 OBJID · part_no · part_name · eo_start_date~end_date · change_type · change_option · part_type (Smart 폼). writer_id 는 UserSelect 별 PR. + +### 4.3 액션 + +- 조회 (only) — read-only 메뉴 +- 행 클릭 → PartHisDetailDialog (모든 필드 disabled) + +--- + +## 5. 검증 시나리오 + +1. M5 페이지 진입 → part_mng_history 빈 그리드 (시드 후 표시 확인) +2. PART 등록(M1) → 확정(deploy) 트랜잭션으로 part_mng_history INSERT (PR-A 의 deploy 경로) + - 단, deploy 시 `bom_status` 가 NULL 이므로 본 SQL의 `bom_status='deploy'` 필터에서 제외됨 + - 본 메뉴는 **BOM 변경 이력** 표시 목적 — 실제 데이터는 M3 BOM 배포 시 INSERT됨 (PR-B 범위 밖) +3. 검색 필터(year/part_no/change_type/eo_start_date) → 동적 WHERE 적용 확인 +4. 행 클릭 → 상세 다이얼로그에 모든 필드 표시 (raw + 라벨) + +--- + +## 6. 적응 사항 + +| # | 항목 | 변경 | +|---|---|---| +| 1 | `NVL()` → `COALESCE()` | wace는 Oracle 호환 NVL 사용, PG 표준은 COALESCE | +| 2 | `PART_MNG.OBJID = PM.PARENT_PART_NO` JOIN | OBJID bigint vs PARENT_PART_NO varchar → cast `::varchar` | +| 3 | `CODE_NAME()` 함수 | LEFT JOIN comm_code 별칭으로 풀어 쓰기 | +| 4 | wace `STATUS_NQ`/`SEARCH_TYPE=CHANGE_LIST` 필터 제거 | 본 PR 메뉴는 변경리스트 한 화면 — 필터 단순화 | diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx new file mode 100644 index 00000000..405f5d0a --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +// 개발관리 > 설계변경 리스트 (M5, read-only) — wace partMngHisList.jsp 1:1 +// 그리드: part_mng_history 16셀 +// 검색: 8 필드 (1차) — Year/contract_objid/part_no/part_name/eo_start~end/change_type/change_option/part_type +// 참조: docs/migration/development/03-eo-history.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Search, Loader2, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory"; +import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog"; + +// comm_code 그룹 (vexplor_rps) +const GROUP_PART_TYPE = "0000062"; +// change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영. +// (시드 후 그룹 ID 확인되면 SmartSelect 전환) + +const YEAR_OPTIONS = (() => { + const cur = new Date().getFullYear(); + const arr: string[] = []; + for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y)); + return arr; +})(); + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "eo_no", label: "EO No", width: "w-[100px]", frozen: true }, + { key: "project_no", label: "프로젝트번호", width: "w-[120px]" }, + { key: "project_name", label: "프로젝트명", width: "w-[180px]" }, + { key: "unit_name", label: "유닛명", width: "w-[160px]" }, + { key: "parent_part_info", label: "모품번", width: "w-[160px]" }, + { key: "part_no_disp", label: "품번", width: "w-[160px]" }, + { key: "part_name_disp", label: "품명", minWidth: "min-w-[180px]" }, + { key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true }, + { key: "qty_temp", label: "변경수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "change_type_name", label: "EO구분", width: "w-[90px]", align: "center" }, + { key: "change_option_name", label: "EO사유", width: "w-[100px]", align: "center" }, + { key: "revision_disp", label: "Revision", width: "w-[90px]", align: "center" }, + { key: "eo_date", label: "EO Date", width: "w-[100px]", align: "center" }, + { key: "part_type_name", label: "PART구분", width: "w-[90px]", align: "center" }, + { key: "writer_name", label: "담당자", width: "w-[90px]", align: "center" }, + { key: "his_reg_date_title", label: "실행일", width: "w-[100px]", align: "center" }, +]; + +const EMPTY_FILTER: EoHistoryListFilter = { + Year: "", contract_objid: "", + part_no: "", part_name: "", + change_option: "", change_type: "", part_type: "", + eo_start_date: "", eo_end_date: "", + page: 1, page_size: 50, +}; + +export default function EoHistoryPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devEoHistoryApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + // EO_NO 셀 클릭 → 상세 다이얼로그 + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "eo_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + return ( +
+
+
+ + + + + setFilter({ ...filter, contract_objid: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, part_name: e.target.value })} + placeholder="part_name LIKE" /> + + + + setFilter({ ...filter, eo_start_date: e.target.value })} /> + + + setFilter({ ...filter, eo_end_date: e.target.value })} /> + + + setFilter({ ...filter, part_type: v })} /> + + +
+ setFilter({ ...filter, change_type: e.target.value })} + placeholder="EO구분 code_id" /> + setFilter({ ...filter, change_option: e.target.value })} + placeholder="EO사유 code_id" /> +
+
+
+
+
총 {total.toLocaleString()}건 (read-only)
+
+ + +
+
+
+ +
+ +
+ + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/components/development/PartHisDetailDialog.tsx b/frontend/components/development/PartHisDetailDialog.tsx new file mode 100644 index 00000000..9323fb6a --- /dev/null +++ b/frontend/components/development/PartHisDetailDialog.tsx @@ -0,0 +1,148 @@ +"use client"; + +// 개발관리 > 설계변경 리스트 상세 다이얼로그 (read-only). +// wace partMngHisDetailPopUp.jsp 1:1 — 모든 필드 disabled. + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { devEoHistoryApi, EoHistoryDetail } from "@/lib/api/devEoHistory"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; +} + +export function PartHisDetailDialog({ open, onOpenChange, objid }: Props) { + const [row, setRow] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devEoHistoryApi.detail(objid) + .then((data) => { if (alive) setRow(data); }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + return ( + + + + 설계변경 상세 정보 (PART_MNG_HISTORY) + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + +
+ +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
+ )} + + + + +
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Row({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) { + const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : ""; + return ( +
+ {label && } +
+ {value != null && value !== "" ? value : } +
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 25684a8d..3418f123 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -111,6 +111,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/development/ebom-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-regist/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/development/ebom-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-search/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/change-list": dynamic(() => import("@/app/(main)/COMPANY_16/development/change-list/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/devEoHistory.ts b/frontend/lib/api/devEoHistory.ts new file mode 100644 index 00000000..f1f77d5b --- /dev/null +++ b/frontend/lib/api/devEoHistory.ts @@ -0,0 +1,106 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 설계변경 리스트 (M5, read-only) — wace partMngHistList 1:1 +// 라우트: /api/development/eo-history/* +// ============================================================ + +export interface EoHistoryListFilter { + Year?: string; + contract_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + change_option?: string; + eo_start_date?: string; + eo_end_date?: string; + change_type?: string; + part_type?: string; + writer_id?: string; + page?: number; + page_size?: number; +} + +export interface EoHistoryRow { + objid: string; + eo_no: string | null; + year: string | null; + project_no: string | null; + project_name: string | null; + unit_name: string | null; + parent_part_info: string | null; + part_no_disp: string | null; + part_name_disp: string | null; + part_no: string | null; + part_name: string | null; + bom_qty_status: string | null; + qty: string | null; + qty_temp: string | null; + change_type: string | null; + change_type_name: string | null; + change_option: string | null; + change_option_name: string | null; + revision_disp: string | null; + revision: string | null; + eo_date: string | null; + part_type: string | null; + part_type_name: string | null; + writer: string | null; + writer_name: string | null; + his_reg_date_title: string | null; + bom_deploy_date: string | null; + bom_deploy_date_title: string | null; +} + +export interface EoHistoryListResponse { + rows: EoHistoryRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface EoHistoryDetail extends EoHistoryRow { + // raw PART_MNG_HISTORY 추가 필드 + product_mgmt_objid?: string | null; + upg_no?: string | null; + unit?: string | null; + spec?: string | null; + material?: string | null; + weight?: string | null; + remark?: string | null; + es_spec?: string | null; + ms_spec?: string | null; + design_apply_point?: string | null; + management_flag?: string | null; + status?: string | null; + reg_date?: string | null; + is_last?: string | null; + sourcing_code?: string | null; + sub_material?: string | null; + thickness?: string | null; + width?: string | null; + height?: string | null; + out_diameter?: string | null; + in_diameter?: string | null; + length?: string | null; + supply_code?: string | null; + contract_objid?: string | null; + maker?: string | null; + his_status?: string | null; + bom_status?: string | null; + heat_treatment_hardness?: string | null; + heat_treatment_method?: string | null; + surface_treatment?: string | null; + customer_project_name?: string | null; +} + +export const devEoHistoryApi = { + async list(filter: EoHistoryListFilter = {}): Promise { + const res = await apiClient.get("/development/eo-history/list", { params: filter }); + return res.data?.data as EoHistoryListResponse; + }, + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/eo-history/${objid}`); + return res.data?.data ?? null; + }, +}; From 7779f37c1767b41964e43984f34c68ecc17f7fd3 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 17:55:17 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>PART?= =?UTF-8?q?=C2=B7E-BOM=20Excel=20Import=20=EB=A9=94=EB=89=B4=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20=E2=80=94=20wace=20partMng=201:1=20=EC=9D=B4?= =?UTF-8?q?=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PART Excel Import (M1·M2 공용): - /partMng/openPartExcelImportPopUp.do + partParsingExcelFile.do + partUploadSave.do 1:1 - 22컬럼 파싱 + NOTE 누적 검증 (품번 필수/중복, PART_TYPE/ACCTFG/UNIT_DC commCode 매핑, ODRFG/LOT_FG/USE_YN/QC_FG/SETITEM_FG/REQ_FG 한글 → 코드값) - 저장은 신규 PART_NO 만 mergePartMng INSERT (기존 IS_LAST='1' 행은 skip) - part-regist + part-search 페이지에 Excel Upload 버튼 + 다이얼로그 연결 BOM Report Excel Import (M3 = openBomReportExcelImportPopUp = "PART 및 구조등록 Excel upload"): - /partMng/parsingExcelFile.do + checkDuplicatePartNo.do + getBomDataForCopy.do + partBomApplySave.do (savePartBomMaster) 1:1 - 10컬럼 파싱 + 자품번/모품번/품명/수량 필수 검증, 모품번이 자품번 목록(Set)에 존재 검증, 수량 숫자+>0 검증, PART_TYPE='0001788'(구매품표준) part_mng 존재 검증 - 1레벨(모품번 없는 첫 행) → 헤더 PART_NO/PART_NAME 자동 채움 - 저장 트랜잭션 (wace 1:1): 헤더 part_bom_report INSERT(신규) / DELETE 자식트리+UPDATE(수정) 자식 PART: part_mng IS_LAST='1' 존재 시 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT 부모 PART: 존재 시 lookup, 없으면 "" (절대 INSERT 안 함 — wace 5359-5361) bom_part_qty INSERT (relatePartInfo) — 부모행 CHILD_OBJID 를 PARENT_OBJID 로 체인 - 헤더 PART_NO 중복 검사 (편집 중인 자신 제외) - E-BOM 복사 기능 (기존 BOM → 그리드 행) + Template Download - ebom-regist 페이지에 "E-BOM 등록(Excel)" 버튼 + 다이얼로그 연결 운영 템플릿 정적 자산: - frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx - frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx (.gitignore 우회 git add -f) wace structureExcelImportPopup.jsp 는 옛 차종/제품군/사양 도메인 화면으로 운영 메뉴 트리에 서 더이상 호출되지 않아 이식 대상 제외. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/devBomController.ts | 76 +++ .../src/controllers/devPartController.ts | 39 ++ backend-node/src/routes/devBomRoutes.ts | 13 + backend-node/src/routes/devPartRoutes.ts | 10 + .../src/services/devBomExcelImportService.ts | 476 ++++++++++++++++++ .../src/services/devPartExcelImportService.ts | 372 ++++++++++++++ .../development/ebom-regist/page.tsx | 14 +- .../development/part-regist/page.tsx | 12 +- .../development/part-search/page.tsx | 12 +- .../BomReportExcelImportDialog.tsx | 370 ++++++++++++++ .../development/PartExcelImportDialog.tsx | 280 +++++++++++ frontend/lib/api/devBom.ts | 84 ++++ frontend/lib/api/devPart.ts | 60 +++ .../BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx | Bin 0 -> 92438 bytes .../templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx | Bin 0 -> 127127 bytes 15 files changed, 1815 insertions(+), 3 deletions(-) create mode 100644 backend-node/src/services/devBomExcelImportService.ts create mode 100644 backend-node/src/services/devPartExcelImportService.ts create mode 100644 frontend/components/development/BomReportExcelImportDialog.tsx create mode 100644 frontend/components/development/PartExcelImportDialog.tsx create mode 100644 frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx create mode 100644 frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts index 50281618..2d88b5b7 100644 --- a/backend-node/src/controllers/devBomController.ts +++ b/backend-node/src/controllers/devBomController.ts @@ -12,6 +12,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/devBomService"; +import * as excelSvc from "../services/devBomExcelImportService"; import { logger } from "../utils/logger"; function parseListFilter(q: Record): svc.BomReportListFilter { @@ -78,6 +79,81 @@ export async function removeMany(req: AuthenticatedRequest, res: Response) { } } +// ─── Excel Import (M3) ──────────────────────────────────── +// POST /api/development/ebom/excel-parse (multipart, field: file) +// GET /api/development/ebom/excel-check-duplicate?partNo=&exclude= +// GET /api/development/ebom/excel-copy-source?productCd= +// GET /api/development/ebom/excel-copy/:objid (기존 BOM → 그리드 행) +// POST /api/development/ebom/excel-save (body: { bomReportObjid?, productCd, partNo, partName, version?, rows }) +// +// wace: parsingExcelFile.do + checkDuplicatePartNo.do + getBomDataForCopy.do + partBomApplySave.do +// + 메인의 code_map.bom_list (select 옵션) + +export async function excelParse(req: AuthenticatedRequest, res: Response) { + try { + const file = (req as any).file as Express.Multer.File | undefined; + if (!file) return res.status(400).json({ success: false, message: "엑셀 파일이 필요합니다." }); + const data = await excelSvc.parseAndValidate(file.buffer); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("BOM 엑셀 파싱 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelCheckDuplicate(req: AuthenticatedRequest, res: Response) { + try { + const partNo = String(req.query.partNo ?? "").trim(); + const exclude = String(req.query.exclude ?? "").trim() || undefined; + const isDuplicate = await excelSvc.checkDuplicateBomPartNo(partNo, exclude); + return res.json({ success: true, data: { isDuplicate } }); + } catch (e: any) { + logger.error("BOM 헤더 품번 중복 검사 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelCopySource(req: AuthenticatedRequest, res: Response) { + try { + const productCd = String(req.query.productCd ?? "").trim() || undefined; + const rows = await excelSvc.listForCopySelect(productCd); + return res.json({ success: true, data: rows }); + } catch (e: any) { + logger.error("BOM 복사 원본 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelCopy(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const rows = await excelSvc.copyBomForGrid(objid); + return res.json({ success: true, data: { rows } }); + } catch (e: any) { + logger.error("BOM 복사 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelSave(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const body = req.body as excelSvc.BomSaveInput; + if (!body || !Array.isArray(body.rows)) { + return res.status(400).json({ success: false, message: "잘못된 요청 본문입니다." }); + } + const result = await excelSvc.saveBomReport(userId, body); + return res.json({ + success: true, + data: result, + message: `${result.mode === "create" ? "등록" : "수정"} 완료 — BOM 행 ${result.bomRows}건 (PART 신규 ${result.insertedParts}건 / 업데이트 ${result.updatedParts}건)`, + }); + } catch (e: any) { + logger.error("BOM Excel 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // ─── M4 정전개 ───────────────────────────────────────────── export async function ascending(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts index ff550250..affa0977 100644 --- a/backend-node/src/controllers/devPartController.ts +++ b/backend-node/src/controllers/devPartController.ts @@ -13,6 +13,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/devPartService"; +import * as excelSvc from "../services/devPartExcelImportService"; import { logger } from "../utils/logger"; function parseListFilter(q: Record): svc.PartListFilter { @@ -109,6 +110,44 @@ export async function deploy(req: AuthenticatedRequest, res: Response) { } } +// ─── Excel Import (M1·M2 공용) ────────────────────────────── +// POST /api/development/part/excel-parse (multipart, field: file) +// POST /api/development/part/excel-save (body: { rows: [...] }) +// +// 운영판 wace: openPartExcelImportPopUp.jsp → partParsingExcelFile.do + partUploadSave.do +// 본 RPS 구현: 파일을 메모리 파싱 → 검증 결과(NOTE 포함) 반환 / 저장 시 신규 part_no 만 INSERT. + +export async function excelParse(req: AuthenticatedRequest, res: Response) { + try { + const file = (req as any).file as Express.Multer.File | undefined; + if (!file) return res.status(400).json({ success: false, message: "엑셀 파일이 필요합니다." }); + const data = await excelSvc.parseAndValidate(file.buffer); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("PART 엑셀 파싱 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelSave(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const rows = Array.isArray(req.body?.rows) ? (req.body.rows as excelSvc.SavePartExcelInput[]) : []; + if (rows.length === 0) { + return res.status(400).json({ success: false, message: "저장할 행이 없습니다." }); + } + const result = await excelSvc.saveExcelRows(userId, rows); + return res.json({ + success: true, + data: result, + message: `${result.inserted}건이 저장되었습니다.${result.skipped > 0 ? ` (중복 ${result.skipped}건 건너뜀)` : ""}`, + }); + } catch (e: any) { + logger.error("PART 엑셀 저장 실패", { 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/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts index 9e0676a5..8e3b892b 100644 --- a/backend-node/src/routes/devBomRoutes.ts +++ b/backend-node/src/routes/devBomRoutes.ts @@ -4,16 +4,29 @@ // ============================================================ import { Router } from "express"; +import multer from "multer"; import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/devBomController"; const router = Router(); router.use(authenticateToken); +const excelUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB +}); + // M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위) router.get("/ebom-tree/ascending", ctrl.ascending); router.get("/ebom-tree/descending", ctrl.descending); +// M3 Excel Import — /:objid 보다 위에 (라우트 충돌 방지) +router.post("/ebom/excel-parse", excelUpload.single("file"), ctrl.excelParse); +router.get("/ebom/excel-check-duplicate", ctrl.excelCheckDuplicate); +router.get("/ebom/excel-copy-source", ctrl.excelCopySource); +router.get("/ebom/excel-copy/:objid", ctrl.excelCopy); +router.post("/ebom/excel-save", ctrl.excelSave); + // M3 — 그리드 + CRUD router.get("/ebom/list", ctrl.getList); router.delete("/ebom", ctrl.removeMany); diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts index ee0db031..d7dbd428 100644 --- a/backend-node/src/routes/devPartRoutes.ts +++ b/backend-node/src/routes/devPartRoutes.ts @@ -4,12 +4,18 @@ // ============================================================ import { Router } from "express"; +import multer from "multer"; import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/devPartController"; const router = Router(); router.use(authenticateToken); +const excelUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB +}); + // M1 — 임시(등록) 그리드 router.get("/part-temp/list", ctrl.getTempList); router.post("/part-temp/deploy", ctrl.deploy); @@ -17,6 +23,10 @@ router.post("/part-temp/deploy", ctrl.deploy); // M2 — 릴리즈 그리드 router.get("/part/list", ctrl.getList); +// Excel Import (M1·M2 공용) — /:objid 보다 위에 위치 +router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse); +router.post("/part/excel-save", ctrl.excelSave); + // 다중 삭제 (body: { objids: string[] }) — /:objid 보다 위 router.delete("/part", ctrl.removeMany); diff --git a/backend-node/src/services/devBomExcelImportService.ts b/backend-node/src/services/devBomExcelImportService.ts new file mode 100644 index 00000000..9f6d0a6a --- /dev/null +++ b/backend-node/src/services/devBomExcelImportService.ts @@ -0,0 +1,476 @@ +// ============================================================ +// 개발관리 BOM Report Excel Import 서비스 — wace_plm PartMngService 1:1 +// +// 원본 흐름 (wace partMng/openBomReportExcelImportPopUp.jsp): +// 1) /partMng/parsingExcelFile.do → 업로드 파일 파싱 + 검증 (parsingExcelFile) +// 2) /partMng/checkDuplicatePartNo.do → PART_BOM_REPORT 헤더 PART_NO 중복 (수정 중인 자신 제외) +// 3) /partMng/partBomApplySave.do → savePartBomMaster() +// 3-1) 헤더 INSERT(신규) / UPDATE+자식트리 DELETE+STATUS reset(수정) +// 3-2) 행마다 (wace partBomMaster 1:1): +// ㄱ. partMng.getPartObjid(PART_NO, IS_LAST=1) +// → 있음: part_objid 재사용 + partMng.updatePartInfoFromCsv 로 기존 row UPDATE +// → 없음: createObjId + partMng.insertpartInfo (part_mng 신규 INSERT) +// ㄴ. partMng.getPartObjid(PARENT_PART_NO, IS_LAST=1) +// → 있음: parent_part_objid 사용 +// → 없음: parent_part_objid = "" (INSERT 절대 안 함 — wace 원본 5359-5361) +// ㄷ. partMng.getBomPartQtyObjid(PARENT_PART_NO, BOM_REPORT_OBJID) +// → bom_part_qty 부모행의 CHILD_OBJID 를 parent_objid 로 사용 +// ㄹ. partMng.relatePartInfo → bom_part_qty INSERT +// +// 검증 (wace parsingExcelFile 1:1): +// · 자품번 필수 / 엑셀 내 중복 +// · 모품번이 자품번 목록(allPartNumbers Set)에 존재해야 함 (1레벨 = 첫 데이터 행은 제외) +// · 품명 필수 +// · 수량 필수 + 숫자 + > 0 +// · PART_TYPE 코드명 → code_id (못 찾으면 NOTE "부품유형 확인") +// · PART_TYPE='0001788'(구매품표준) → part_mng.part_no 존재 검증 (NOTE "구매품표준 미등록") +// +// vexplor_rps 적응: +// · CUSTOMER_OBJID/CONTRACT_OBJID/UNIT_CODE 본 메뉴에서 입력받지 않음 → NULL +// · 운영판 BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx 10컬럼 그대로: +// 0:모품번 1:자품번 2:품명 3:수량 4:재질 5:사양(규격) 6:후처리 7:MAKER 8:부품유형 9:REMARK +// (자바 코드는 25컬럼까지 매핑하지만 실제 운영 템플릿은 10컬럼) +// ============================================================ + +import * as XLSX from "xlsx"; +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +const CODE_PARENT_PART_TYPE = "0000062"; + +export interface BomExcelRow { + NOTE: string; + PARENT_PART_NO: string; + PART_NO: string; + PART_NAME: string; + QTY: string; + ITEM_QTY: string; + MATERIAL: string; + SPEC: string; + POST_PROCESSING: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME?: string; + REMARK: string; +} + +function appendNote(r: BomExcelRow, msg: string) { + r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg; +} +function getCell(row: any[], idx: number): string { + const v = row?.[idx]; + if (v === undefined || v === null) return ""; + return String(v).trim(); +} + +async function fetchPartTypeMap(client: PoolClient): Promise> { + const r = await client.query( + `SELECT CODE_ID AS code_id, CODE_NAME AS code_name + FROM COMM_CODE WHERE PARENT_CODE_ID = $1`, + [CODE_PARENT_PART_TYPE] + ); + const m = new Map(); + for (const row of r.rows) if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row); + return m; +} + +// ─── 1) 파싱 + 검증 (parsingExcelFile 1:1) ────────────────── + +export async function parseAndValidate(buffer: Buffer): Promise<{ + rows: BomExcelRow[]; + hasError: boolean; + firstLevel: { part_no: string; part_name: string } | null; +}> { + const wb = XLSX.read(buffer, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const raw: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }); + + // wace 1: 모든 자품번을 먼저 Set 으로 수집 (모품번 검증용) + const allPartNumbers = new Set(); + for (let i = 2; i < raw.length; i++) { + const partNo = getCell(raw[i], 1); // column 1 = 자품번 (운영 템플릿) + if (partNo) allPartNumbers.add(partNo); + } + + const result: BomExcelRow[] = []; + let firstLevel: { part_no: string; part_name: string } | null = null; + + const client = await getPool().connect(); + try { + const partTypeMap = await fetchPartTypeMap(client); + const partNoSeenInFile = new Map(); + let emptyRowCnt = 0; + let dataRowIndex = 0; // 데이터 행 카운터 (1부터 — wace rowIndex 와 다름, 의미는 동일) + + for (let i = 2; i < raw.length; i++) { + const row = raw[i]; + if (!row) continue; + + const parentPartNo = getCell(row, 0); + const partNo = getCell(row, 1); + const partName = getCell(row, 2); + const qty = getCell(row, 3); + const material = getCell(row, 4); + const spec = getCell(row, 5); + const postProc = getCell(row, 6); + const maker = getCell(row, 7); + const partTypeName = getCell(row, 8); + const remark = getCell(row, 9); + + // wace: PART_NO+PART_NAME 둘 다 빈 행은 스킵 (3줄 연속이면 break) + if (!partNo && !partName) { + emptyRowCnt++; + if (emptyRowCnt > 3) break; + continue; + } + emptyRowCnt = 0; + dataRowIndex++; + + const cur: BomExcelRow = { + NOTE: "", + PARENT_PART_NO: parentPartNo, + PART_NO: partNo, + PART_NAME: partName, + QTY: qty, + ITEM_QTY: qty, // wace: ITEM_QTY 컬럼 없으면 QTY 와 동일 (savePartBomMaster 에서도 QTY 로 채움) + MATERIAL: material, + SPEC: spec, + POST_PROCESSING: postProc, + MAKER: maker, + PART_TYPE: "", + REMARK: remark, + }; + + // (1) 자품번 필수 + if (!partNo) { + appendNote(cur, "필수입력 - 품번"); + } else { + if (partNoSeenInFile.has(partNo)) { + appendNote(cur, `품번 중복: ${partNo}`); + } else { + partNoSeenInFile.set(partNo, dataRowIndex); + } + } + + // (2) 모품번 검증 (wace 1:1): 첫 데이터 행은 제외, 그 외는 자품번 목록에 있어야 함 + if (!parentPartNo && dataRowIndex > 1) { + appendNote(cur, "필수입력 - 모품번"); + } else if (parentPartNo && !allPartNumbers.has(parentPartNo)) { + appendNote(cur, `모품번이 자품번 목록에 없습니다: ${parentPartNo}`); + } + + // (3) 품명 필수 + if (!partName) appendNote(cur, "필수입력 - 품명"); + + // (4) 수량 필수 + 숫자 + > 0 (wace 1:1) + if (!qty) { + appendNote(cur, "필수입력 - 수량"); + } else { + const qtyValue = Number(qty); + if (!Number.isFinite(qtyValue)) { + appendNote(cur, `수량은 숫자여야 합니다: ${qty}`); + } else if (qtyValue <= 0) { + appendNote(cur, `수량은 0보다 커야 합니다: ${qty}`); + } + } + + // (5) PART_TYPE 코드 변환 + 구매품표준 검증 + if (partTypeName) { + const hit = partTypeMap.get(partTypeName.toUpperCase()); + if (hit) { + cur.PART_TYPE = hit.code_id; + cur.PART_TYPE_NAME = hit.code_name; + + // wace: rowIndex > 2 인 경우만 구매품표준 검증 (1~2 레벨은 면제) + if (hit.code_name === "구매품표준" && dataRowIndex > 2 && partNo) { + const exist = await client.query( + `SELECT 1 FROM PART_MNG WHERE PART_NO = $1 LIMIT 1`, + [partNo] + ); + if ((exist.rowCount ?? 0) === 0) { + appendNote(cur, `품번에 해당하는 구매품표준이 없습니다: ${partNo}`); + } + } + } else { + cur.PART_TYPE = partTypeName; + appendNote(cur, `부품유형 확인: ${partTypeName}`); + } + } + + // (6) 1레벨 (모품번 없는 첫 행)을 헤더 자동 채움 용도로 캡쳐 + if (!firstLevel && !parentPartNo && partNo) { + firstLevel = { part_no: partNo, part_name: partName }; + } + + result.push(cur); + } + } finally { + client.release(); + } + + const hasError = result.some((r) => r.NOTE); + return { rows: result, hasError, firstLevel }; +} + +// ─── 2) 헤더 part_no 중복 검사 (wace checkDuplicatePartNo) ── + +export async function checkDuplicateBomPartNo(partNo: string, excludeObjid?: string): Promise { + if (!partNo) return false; + const sql = excludeObjid + ? `SELECT 1 FROM PART_BOM_REPORT WHERE PART_NO = $1 AND OBJID <> $2 LIMIT 1` + : `SELECT 1 FROM PART_BOM_REPORT WHERE PART_NO = $1 LIMIT 1`; + const params = excludeObjid ? [partNo.trim(), excludeObjid] : [partNo.trim()]; + const r = await getPool().query(sql, params); + return (r.rowCount ?? 0) > 0; +} + +// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomExcelRow[] ────── + +export async function copyBomForGrid(sourceObjid: string): Promise { + const sql = ` + SELECT + Q.QTY, Q.ITEM_QTY, Q.SEQ, + PM.PART_NO AS PART_NO_REAL, + PM.PART_NAME AS PART_NAME, + PM.MATERIAL, PM.SPEC, PM.POST_PROCESSING, PM.MAKER, PM.REMARK, + PM.PART_TYPE, + CC.CODE_NAME AS PART_TYPE_NAME, + PM_PARENT.PART_NO AS PARENT_PART_NO_REAL + FROM BOM_PART_QTY Q + LEFT JOIN PART_MNG PM ON PM.OBJID::varchar = Q.PART_NO + LEFT JOIN PART_MNG PM_PARENT ON PM_PARENT.OBJID::varchar = Q.PARENT_PART_NO + LEFT JOIN COMM_CODE CC ON CC.CODE_ID = PM.PART_TYPE + WHERE Q.BOM_REPORT_OBJID = $1 + ORDER BY Q.SEQ + `; + const r = await getPool().query(sql, [sourceObjid]); + return r.rows.map((row): BomExcelRow => ({ + NOTE: "", + PARENT_PART_NO: row.parent_part_no_real ?? "", + PART_NO: row.part_no_real ?? "", + PART_NAME: row.part_name ?? "", + QTY: row.qty != null ? String(row.qty) : "", + ITEM_QTY: row.item_qty != null ? String(row.item_qty) : (row.qty != null ? String(row.qty) : ""), + MATERIAL: row.material ?? "", + SPEC: row.spec ?? "", + POST_PROCESSING: row.post_processing ?? "", + MAKER: row.maker ?? "", + PART_TYPE: row.part_type ?? "", + PART_TYPE_NAME: row.part_type_name ?? "", + REMARK: row.remark ?? "", + })); +} + +// ─── 4) BOM 저장 (savePartBomMaster 1:1) ──────────────────── + +export interface BomSaveInput { + bomReportObjid?: string; // 비어있으면 신규 + productCd: string; + partNo: string; // 헤더 품번 + partName: string; + version?: string; + rows: BomExcelRow[]; +} + +export interface BomSaveResult { + bomReportObjid: string; + insertedParts: number; + updatedParts: number; + bomRows: number; + mode: "create" | "update"; +} + +export async function saveBomReport(userId: string, input: BomSaveInput): Promise { + if (!input.productCd) throw new Error("제품구분은 필수입니다."); + if (!input.partNo) throw new Error("품번은 필수입니다."); + if (!input.partName) throw new Error("품명은 필수입니다."); + + let insertedParts = 0; + let updatedParts = 0; + let bomRows = 0; + let bomReportObjid = (input.bomReportObjid && input.bomReportObjid.trim()) ? input.bomReportObjid.trim() : ""; + const mode: "create" | "update" = bomReportObjid ? "update" : "create"; + + await transaction(async (client: PoolClient) => { + if (mode === "update") { + // wace: 기존 BOM 수정 시 자식 트리 DELETE + STATUS='N' reset (deleteBomPartQtyByBomObjid + resetBomReportStatus) + await client.query(`DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = $1`, [bomReportObjid]); + await client.query( + `UPDATE PART_BOM_REPORT + SET STATUS = 'N', WRITER = $1, edit_date = NOW(), + PRODUCT_CD = $2, PART_NO = $3, PART_NAME = $4, REVISION = $5 + WHERE OBJID = $6`, + [userId, input.productCd, input.partNo, input.partName, input.version ?? null, bomReportObjid] + ); + } else { + bomReportObjid = createObjId(); + await client.query( + `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 + ) VALUES ( + $1, NULL, NULL, NULL, + 'N', $2, NOW(), + 'N', 'N', NULL, NULL, + $3, $4, $5, $6 + )`, + [bomReportObjid, userId, input.productCd, input.partNo, input.partName, input.version ?? null] + ); + } + + if (!input.rows || input.rows.length === 0) return; // wace: 빈 BOM 허용 (헤더만 생성) + + // 자식 PART_NO → part_mng.objid 캐시 (행 처리 후) + const childPartObjIdCache = new Map(); + // bom_part_qty 부모행의 CHILD_OBJID 캐시 (다음 자식들이 이 값을 PARENT_OBJID 로 사용) + const childBomObjIdByPartNo = new Map(); + + // 자식 PART 처리: 있으면 UPDATE / 없으면 INSERT (wace 1:1) + async function upsertChildPart(r: BomExcelRow): Promise { + if (childPartObjIdCache.has(r.PART_NO)) return childPartObjIdCache.get(r.PART_NO)!; + + const exist = await client.query( + `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [r.PART_NO] + ); + if ((exist.rowCount ?? 0) > 0) { + const id = exist.rows[0].part_objid; + // wace updatePartInfoFromCsv 1:1 (BOM Excel 에 있는 컬럼만 — 나머지는 그대로) + // 단, 원본 매퍼는 빈값이어도 UPDATE 함. RPS 에서는 BOM 엑셀에 없는 컬럼은 NULL로 덮어쓰지 않도록 COALESCE 처리. + await client.query( + `UPDATE PART_MNG SET + PART_NAME = $1, + SPEC = $2, + MATERIAL = $3, + REMARK = $4, + PART_TYPE = COALESCE(NULLIF($5, ''), PART_TYPE), + MAKER = $6, + POST_PROCESSING = $7, + EDIT_DATE = NOW() + WHERE OBJID = $8::numeric`, + [ + r.PART_NAME ?? "", + r.SPEC ?? "", + r.MATERIAL ?? "", + r.REMARK ?? "", + r.PART_TYPE ?? "", + r.MAKER ?? "", + r.POST_PROCESSING ?? "", + id, + ] + ); + updatedParts++; + childPartObjIdCache.set(r.PART_NO, id); + return id; + } + + // 신규 INSERT (insertpartInfo 1:1, BOM Excel 컬럼만 채움) + const newId = createObjId(); + await client.query( + `INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, SPEC, MATERIAL, REMARK, + STATUS, REG_DATE, WRITER, IS_LAST, + PART_TYPE, MAKER, POST_PROCESSING, + LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG + ) VALUES ( + $1::numeric, $2, $3, $4, $5, $6, + 'create', NOW(), $7, '1', + NULLIF($8, ''), $9, $10, + '0', '1', '0', '0', '0' + )`, + [ + newId, r.PART_NO, + r.PART_NAME ?? "", r.SPEC ?? "", r.MATERIAL ?? "", r.REMARK ?? "", + userId, + r.PART_TYPE ?? "", r.MAKER ?? "", r.POST_PROCESSING ?? "", + ] + ); + insertedParts++; + childPartObjIdCache.set(r.PART_NO, newId); + return newId; + } + + // 부모 PART 처리: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) + async function lookupParentPart(partNo: string): Promise { + if (!partNo) return ""; + if (childPartObjIdCache.has(partNo)) return childPartObjIdCache.get(partNo)!; + + const exist = await client.query( + `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [partNo] + ); + if ((exist.rowCount ?? 0) > 0) { + const id = exist.rows[0].part_objid; + childPartObjIdCache.set(partNo, id); + return id; + } + return ""; // wace 원본 5359-5361: 부모 part_mng 없으면 parent_part_no="" (INSERT 안 함) + } + + for (const r of input.rows) { + if (!r.PART_NO) continue; + + const partObjid = await upsertChildPart(r); + const parentPartObjid = await lookupParentPart(r.PARENT_PART_NO); + + // wace: bom_part_qty 에서 부모 행의 CHILD_OBJID 조회 (PARENT_PART_NO + BOM_REPORT_OBJID). + // 행이 엑셀 순서대로 들어오므로 메모리 캐시면 동등 (자식이 부모보다 항상 뒤에 등장). + const parentBomObjid = r.PARENT_PART_NO ? (childBomObjIdByPartNo.get(r.PARENT_PART_NO) ?? "") : ""; + + const newBomObjid = createObjId(); + const newChildObjid = createObjId(); + + // wace relatePartInfo 1:1 — QTY/ITEM_QTY/QTY_TEMP 모두 COALESCE(NULLIF, '0')::numeric + await client.query( + `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 + ) VALUES ( + $1, $2, NULLIF($3, ''), $4, + $5, $6, + COALESCE(NULLIF($7, ''), '0')::numeric, + COALESCE(NULLIF($8, ''), '0')::numeric, + COALESCE(NULLIF($7, ''), '0')::numeric, + NOW(), $9, nextval('seq_bom_qty'), 'deploy', NULL + )`, + [ + bomReportObjid, newBomObjid, parentBomObjid, newChildObjid, + parentPartObjid, partObjid, + r.QTY ?? "", + r.ITEM_QTY ?? r.QTY ?? "", + userId, + ] + ); + + childBomObjIdByPartNo.set(r.PART_NO, newChildObjid); + bomRows++; + } + }); + + logger.info("BOM Excel Import 저장 완료", { userId, bomReportObjid, mode, insertedParts, updatedParts, bomRows }); + return { bomReportObjid, insertedParts, updatedParts, bomRows, mode }; +} + +// ─── 5) BOM 목록 (E-BOM 복사 select 옵션) ───────────────── + +export async function listForCopySelect(productCd?: string) { + const params: any[] = []; + let where = "1=1"; + if (productCd) { params.push(productCd); where = `T.PRODUCT_CD = $1`; } + const sql = ` + SELECT T.OBJID, T.PART_NO, T.PART_NAME, T.REVISION, T.PRODUCT_CD, + TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REGDATE + FROM PART_BOM_REPORT T + WHERE ${where} + ORDER BY T.REGDATE DESC NULLS LAST + LIMIT 200 + `; + const r = await getPool().query(sql, params); + return r.rows; +} diff --git a/backend-node/src/services/devPartExcelImportService.ts b/backend-node/src/services/devPartExcelImportService.ts new file mode 100644 index 00000000..f5d41a87 --- /dev/null +++ b/backend-node/src/services/devPartExcelImportService.ts @@ -0,0 +1,372 @@ +// ============================================================ +// 개발관리 PART Excel Import 서비스 — wace_plm PartMngService 1:1 +// +// 원본 흐름 (wace partMng/openPartExcelImportPopUp.jsp): +// 1) /partMng/partParsingExcelFile.do → 업로드 파일 파싱 + 검증 +// 엑셀 row[2..]부터 22컬럼 추출. 검증 결과는 행마다 NOTE 컬럼에 누적. +// 코드명 → 코드값 매핑 (PART_TYPE/ACCTFG/UNIT_DC/UNITMANG_DC, 한글 → 0/1/8/Y/N). +// 품번 중복 (PART_MNG 기존 + 엑셀 내 중복) → "품번중복" NOTE. +// 2) /partMng/partUploadSave.do → 그리드 데이터 INSERT +// partMng.getPartObjid 로 part_no 존재 여부 확인. 없으면 createObjId + mergePartMng INSERT. +// (NOTE에 에러 있는 행은 클라이언트가 차단) +// +// vexplor_rps 차이: +// · 운영판은 파일을 서버에 저장 → /partParsingExcelFile.do 가 그 파일을 읽음. +// · RPS는 multer memoryStorage 로 받은 Buffer 를 그 자리에서 파싱 (WbsTemplate parseExcel 패턴). +// · 결과 그리드는 클라이언트 메모리에만 유지 → 저장 호출 시 다시 검증 한 번 더 수행. +// ============================================================ + +import * as XLSX from "xlsx"; +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +// ─── wace 운영판 PARENT_CODE_ID (commCode) ────────────────── +const CODE_PARENT_PART_TYPE = "0000062"; // 범주 (PART_TYPE) +const CODE_PARENT_ACCTFG = "0900213"; // 계정구분 +const CODE_PARENT_UNIT_DC = "0001399"; // 단위 (UNIT_DC / UNITMANG_DC) + +// 엑셀 22컬럼 (1행 헤더 — wace JSP colModel 순서) +// 0:품번 1:품명 2:재료 3:열처리경도 4:열처리방법 5:표면처리 6:메이커 7:범주 이름 +// 8:규격 9:계정구분 10:조달구분 11:재고단위 12:관리단위 13:환산수량 +// 14:LOT구분 15:사용여부 16:검사여부 17:SET품여부 18:의뢰여부 19:개당길이 20:개당소요량 21:비고 + +export interface PartExcelRow { + NOTE: string; + PART_NO: string; + PART_NAME: string; + MATERIAL: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; + MAKER: string; + PART_TYPE: string; // 코드값(`code_id`) — 매핑 실패 시 원본 한글 + PART_TYPE_NAME?: string; // 화면용 라벨 + SPEC: string; + ACCTFG: string; + ACCTFG_NAME?: string; + ODRFG: string; + ODRFG_NAME?: string; + UNIT_DC: string; + UNIT_DC_NAME?: string; + UNITMANG_DC: string; + UNITMANG_DC_NAME?: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; + UNIT_LENGTH: string; + UNIT_QTY: string; + REMARK: string; +} + +function appendNote(r: PartExcelRow, msg: string) { + r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg; +} + +function getCell(row: any[], idx: number): string { + const v = row?.[idx]; + if (v === undefined || v === null) return ""; + return String(v).trim(); +} + +// ─── 코드명 → 코드값 매핑 (commCode 1:1) ──────────────────── + +async function fetchCodeMap( + client: PoolClient, + parentCodeId: string +): Promise> { + const r = await client.query( + `SELECT CODE_ID AS code_id, CODE_NAME AS code_name + FROM COMM_CODE + WHERE PARENT_CODE_ID = $1`, + [parentCodeId] + ); + const m = new Map(); + for (const row of r.rows) { + if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row); + } + return m; +} + +function mapKr01(value: string, t: string, f: string, defaultVal: string): string { + const v = value.trim(); + if (!v) return defaultVal; + if (v === t) return "1"; + if (v === f) return "0"; + return v; // 숫자 입력 시 그대로 +} +function mapOdrfg(value: string): string { + const v = value.trim(); + if (!v) return ""; + if (v === "구매") return "0"; + if (v === "생산") return "1"; + if (v.toUpperCase() === "PHANTOM") return "8"; + return v; +} + +// ─── 1) 파싱 + 검증 ───────────────────────────────────────── + +export async function parseAndValidate(buffer: Buffer): Promise<{ + rows: PartExcelRow[]; + hasError: boolean; +}> { + const wb = XLSX.read(buffer, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const raw: any[][] = XLSX.utils.sheet_to_json(sheet, { + header: 1, raw: false, defval: "", + }); + + const result: PartExcelRow[] = []; + const client = await getPool().connect(); + try { + const [partTypeMap, acctfgMap, unitDcMap] = await Promise.all([ + fetchCodeMap(client, CODE_PARENT_PART_TYPE), + fetchCodeMap(client, CODE_PARENT_ACCTFG), + fetchCodeMap(client, CODE_PARENT_UNIT_DC), + ]); + + const partNoSeenInFile = new Map(); + let emptyRowCnt = 0; + + // wace: rowIndex = 2 부터 (0=안내라벨 "입력", 1=헤더) + for (let i = 2; i < raw.length; i++) { + const row = raw[i]; + if (!row) continue; + + const cur: PartExcelRow = { + NOTE: "", + PART_NO: getCell(row, 0), + PART_NAME: getCell(row, 1), + MATERIAL: getCell(row, 2), + HEAT_TREATMENT_HARDNESS: getCell(row, 3), + HEAT_TREATMENT_METHOD: getCell(row, 4), + SURFACE_TREATMENT: getCell(row, 5), + MAKER: getCell(row, 6), + PART_TYPE: "", + SPEC: getCell(row, 8), + ACCTFG: "", + ODRFG: "", + UNIT_DC: "", + UNITMANG_DC: "", + UNITCHNG_NB: getCell(row, 13), + LOT_FG: "", + USE_YN: "", + QC_FG: "", + SETITEM_FG: "", + REQ_FG: "", + UNIT_LENGTH: getCell(row, 19), + UNIT_QTY: getCell(row, 20), + REMARK: getCell(row, 21), + }; + + // wace getCellValue 의 emptyColCnt(8 미만)로 행 채택. 단순화: 모든 컬럼 빈값이면 빈 행. + const nonEmptyCount = + (cur.PART_NO ? 1 : 0) + (cur.PART_NAME ? 1 : 0) + (cur.MATERIAL ? 1 : 0) + + (cur.HEAT_TREATMENT_HARDNESS ? 1 : 0) + (cur.HEAT_TREATMENT_METHOD ? 1 : 0) + + (cur.SURFACE_TREATMENT ? 1 : 0) + (cur.MAKER ? 1 : 0) + + (getCell(row, 7) ? 1 : 0) + (cur.SPEC ? 1 : 0) + + (getCell(row, 9) ? 1 : 0) + (getCell(row, 10) ? 1 : 0) + + (getCell(row, 11) ? 1 : 0) + (getCell(row, 12) ? 1 : 0) + (cur.UNITCHNG_NB ? 1 : 0) + + (getCell(row, 14) ? 1 : 0) + (getCell(row, 15) ? 1 : 0) + + (getCell(row, 16) ? 1 : 0) + (getCell(row, 17) ? 1 : 0) + + (getCell(row, 18) ? 1 : 0) + (cur.UNIT_LENGTH ? 1 : 0) + + (cur.UNIT_QTY ? 1 : 0) + (cur.REMARK ? 1 : 0); + + if (nonEmptyCount === 0) { + emptyRowCnt++; + if (emptyRowCnt > 3) break; // wace 동일 + continue; + } + + // 품번 필수 + 중복 검사 (DB + 엑셀 내) + if (!cur.PART_NO) { + appendNote(cur, "필수입력 - 품번"); + } else { + const dbRes = await client.query( + `SELECT 1 FROM PART_MNG WHERE PART_NO = $1 LIMIT 1`, + [cur.PART_NO] + ); + if ((dbRes.rowCount ?? 0) > 0) { + appendNote(cur, "품번중복"); + } + if (partNoSeenInFile.has(cur.PART_NO)) { + appendNote(cur, `엑셀 내 품번중복(row ${partNoSeenInFile.get(cur.PART_NO)})`); + } else { + partNoSeenInFile.set(cur.PART_NO, i + 1); + } + } + + if (!cur.PART_NAME) appendNote(cur, "필수입력 - 품명"); + + // PART_TYPE (범주 이름) — 코드명 → code_id + const partTypeIn = getCell(row, 7); + if (partTypeIn) { + const hit = partTypeMap.get(partTypeIn.toUpperCase()); + if (hit) { + cur.PART_TYPE = hit.code_id; + cur.PART_TYPE_NAME = hit.code_name; + } else { + cur.PART_TYPE = partTypeIn; + appendNote(cur, `범주 이름 확인:${partTypeIn}`); + } + } + + // ACCTFG (계정구분) + const acctfgIn = getCell(row, 9); + if (acctfgIn) { + if (/^\d+$/.test(acctfgIn)) { + cur.ACCTFG = acctfgIn; + } else { + const hit = acctfgMap.get(acctfgIn.toUpperCase()); + if (hit) { cur.ACCTFG = hit.code_id; cur.ACCTFG_NAME = hit.code_name; } + else cur.ACCTFG = acctfgIn; + } + } + + // ODRFG (조달구분) + const odrfgIn = getCell(row, 10); + cur.ODRFG = mapOdrfg(odrfgIn); + if (odrfgIn && cur.ODRFG === odrfgIn && !/^[018]$/.test(odrfgIn)) { + appendNote(cur, `조달구분 확인:${odrfgIn}`); + } + cur.ODRFG_NAME = cur.ODRFG === "0" ? "구매" + : cur.ODRFG === "1" ? "생산" + : cur.ODRFG === "8" ? "Phantom" : odrfgIn; + + // UNIT_DC (재고단위) + const unitDcIn = getCell(row, 11); + if (unitDcIn) { + if (/^\d+$/.test(unitDcIn)) cur.UNIT_DC = unitDcIn; + else { + const hit = unitDcMap.get(unitDcIn.toUpperCase()); + if (hit) { cur.UNIT_DC = hit.code_id; cur.UNIT_DC_NAME = hit.code_name; } + else cur.UNIT_DC = unitDcIn; + } + } + + // UNITMANG_DC (관리단위) — 동일 매핑 + const unitmangIn = getCell(row, 12); + if (unitmangIn) { + if (/^\d+$/.test(unitmangIn)) cur.UNITMANG_DC = unitmangIn; + else { + const hit = unitDcMap.get(unitmangIn.toUpperCase()); + if (hit) { cur.UNITMANG_DC = hit.code_id; cur.UNITMANG_DC_NAME = hit.code_name; } + else cur.UNITMANG_DC = unitmangIn; + } + } + + cur.LOT_FG = mapKr01(getCell(row, 14), "사용", "미사용", "0"); + cur.USE_YN = mapKr01(getCell(row, 15), "사용", "미사용", "1"); + cur.QC_FG = mapKr01(getCell(row, 16), "검사", "무검사", "0"); + cur.SETITEM_FG = mapKr01(getCell(row, 17), "여", "부", "0"); + cur.REQ_FG = mapKr01(getCell(row, 18), "여", "부", "0"); + + result.push(cur); + emptyRowCnt = 0; + } + } finally { + client.release(); + } + + const hasError = result.some((r) => r.NOTE); + return { rows: result, hasError }; +} + +// ─── 2) 저장 (mergePartMng 1:1, 신규만 INSERT) ────────────── + +export interface SavePartExcelInput { + PART_NO: string; + PART_NAME: string; + MATERIAL?: string; + HEAT_TREATMENT_HARDNESS?: string; + HEAT_TREATMENT_METHOD?: string; + SURFACE_TREATMENT?: string; + MAKER?: string; + PART_TYPE?: string; + SPEC?: string; + ACCTFG?: string; + ODRFG?: string; + UNIT_DC?: string; + UNITMANG_DC?: string; + UNITCHNG_NB?: string | number; + LOT_FG?: string; + USE_YN?: string; + QC_FG?: string; + SETITEM_FG?: string; + REQ_FG?: string; + UNIT_LENGTH?: string; + UNIT_QTY?: string; + REMARK?: string; +} + +export async function saveExcelRows( + userId: string, + rows: SavePartExcelInput[] +): Promise<{ inserted: number; skipped: number; skippedPartNos: string[] }> { + if (!rows || rows.length === 0) return { inserted: 0, skipped: 0, skippedPartNos: [] }; + + let inserted = 0; + let skipped = 0; + const skippedPartNos: string[] = []; + + await transaction(async (client: PoolClient) => { + for (const r of rows) { + const partNo = (r.PART_NO ?? "").trim(); + if (!partNo) { skipped++; continue; } + + // wace partMng.getPartObjid — IS_LAST='1' 인 동일 part_no 가 있으면 INSERT 스킵 + const existRes = await client.query( + `SELECT OBJID FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [partNo] + ); + if ((existRes.rowCount ?? 0) > 0) { + skipped++; + skippedPartNos.push(partNo); + continue; + } + + const objid = createObjId(); + // wace mergePartMng 1:1 (엑셀 임포트가 채우는 컬럼만) + await client.query( + `INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, SPEC, MATERIAL, PART_TYPE, REMARK, + STATUS, REG_DATE, WRITER, IS_LAST, + MAKER, + HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, + ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB, + LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG, + UNIT_LENGTH, UNIT_QTY + ) VALUES ( + $1::numeric, $2, $3, $4, $5, $6, $7, + 'create', NOW(), $8, '1', + $9, + $10, $11, $12, + $13, $14, $15, $16, + CASE WHEN $17 = '' OR $17 IS NULL THEN NULL ELSE $17::numeric END, + COALESCE($18, '0'), COALESCE($19, '1'), COALESCE($20, '0'), + COALESCE($21, '0'), COALESCE($22, '0'), + $23, $24 + )`, + [ + objid, partNo, r.PART_NAME ?? "", r.SPEC ?? null, r.MATERIAL ?? null, + r.PART_TYPE ?? null, r.REMARK ?? null, + userId, + r.MAKER ?? null, + r.HEAT_TREATMENT_HARDNESS ?? null, r.HEAT_TREATMENT_METHOD ?? null, r.SURFACE_TREATMENT ?? null, + r.ACCTFG ?? null, r.ODRFG ?? null, r.UNIT_DC ?? null, r.UNITMANG_DC ?? null, + r.UNITCHNG_NB === undefined || r.UNITCHNG_NB === null ? "" : String(r.UNITCHNG_NB), + r.LOT_FG ?? null, r.USE_YN ?? null, r.QC_FG ?? null, r.SETITEM_FG ?? null, r.REQ_FG ?? null, + r.UNIT_LENGTH ?? null, r.UNIT_QTY ?? null, + ] + ); + inserted++; + } + }); + + logger.info("PART 엑셀 임포트 저장 완료", { userId, inserted, skipped }); + return { inserted, skipped, skippedPartNos }; +} 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 52f17273..c6ea0fd1 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -10,13 +10,14 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Search, Loader2, RotateCcw, Trash2, Settings, + Search, Loader2, RotateCcw, Trash2, Settings, FileSpreadsheet, } from "lucide-react"; import { toast } from "sonner"; 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 { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog"; const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용) @@ -53,6 +54,7 @@ export default function EbomRegistPage() { const [statusOpen, setStatusOpen] = useState(false); const [statusObjid, setStatusObjid] = useState(null); + const [excelOpen, setExcelOpen] = useState(false); const fetchList = useCallback(async (override?: Partial) => { setLoading(true); @@ -137,6 +139,10 @@ export default function EbomRegistPage() { {loading ? : } 조회 + + +
@@ -190,6 +195,11 @@ export default function PartSearchPage() { objid={detailObjid} onEdit={handleEditFromDetail} /> +
); } diff --git a/frontend/components/development/BomReportExcelImportDialog.tsx b/frontend/components/development/BomReportExcelImportDialog.tsx new file mode 100644 index 00000000..e0f4d2fa --- /dev/null +++ b/frontend/components/development/BomReportExcelImportDialog.tsx @@ -0,0 +1,370 @@ +"use client"; + +// 개발관리 E-BOM 등록 Excel Import 다이얼로그 +// wace partMng/openBomReportExcelImportPopUp.jsp 1:1 +// - 헤더: 제품구분 / 품번(readonly, 1레벨 자동) / 품명(readonly, 1레벨 자동) / Version +// - E-BOM 복사 (기존 BOM 선택 → 그리드 채움) +// - Drag&Drop 엑셀 → 백엔드 파싱 + 검증 (NOTE 누적) +// - 저장: part_bom_report 헤더 + bom_part_qty 트리. NOTE 있는 행이 1건이라도 있으면 차단. + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { devBomApi, BomExcelRow, BomCopySourceRow } from "@/lib/api/devBom"; + +const PRODUCT_GROUP = "0000001"; +const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + // 수정 모드 시: 기존 BOM_REPORT_OBJID (메인 그리드 행 클릭 → "Excel로 재등록" 등의 진입점) + editObjid?: string | null; + initialProductCd?: string; + onSaved: () => void; +} + +interface Column { + key: keyof BomExcelRow; + label: string; + width: string; + align?: "left" | "center" | "right"; + showNameFor?: keyof BomExcelRow; +} + +const COLUMNS: Column[] = [ + { key: "NOTE", label: "결과", width: "min-w-[220px]", align: "left" }, + { key: "PARENT_PART_NO", label: "모품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NO", label: "자품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" }, + { key: "QTY", label: "수량", width: "min-w-[70px]", align: "right" }, + { key: "ITEM_QTY", label: "항목수량", width: "min-w-[80px]", align: "right" }, + { key: "MATERIAL", label: "재질", width: "min-w-[100px]" }, + { key: "SPEC", label: "사양(규격)", width: "min-w-[110px]" }, + { key: "POST_PROCESSING", label: "후처리", width: "min-w-[100px]" }, + { key: "MAKER", label: "MAKER", width: "min-w-[110px]" }, + { key: "PART_TYPE", label: "부품유형", width: "min-w-[100px]", align: "center", showNameFor: "PART_TYPE_NAME" }, + { key: "REMARK", label: "REMARK", width: "min-w-[130px]", align: "left" }, +]; + +function displayValue(r: BomExcelRow, col: Column): string { + if (col.showNameFor) { + const name = r[col.showNameFor]; + if (name) return String(name); + } + return String(r[col.key] ?? ""); +} + +export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, initialProductCd, onSaved }: Props) { + const fileInputRef = useRef(null); + + const [productCd, setProductCd] = useState(""); + const [bomPartNo, setBomPartNo] = useState(""); + const [bomPartName, setBomPartName] = useState(""); + const [version, setVersion] = useState(""); + + const [copyOptions, setCopyOptions] = useState([]); + const [copySelect, setCopySelect] = useState(""); + + const [rows, setRows] = useState([]); + const [hasError, setHasError] = useState(false); + const [fileName, setFileName] = useState(""); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [copying, setCopying] = useState(false); + const [dragOver, setDragOver] = useState(false); + + const reset = useCallback(() => { + setProductCd(initialProductCd ?? ""); + setBomPartNo(""); + setBomPartName(""); + setVersion(""); + setRows([]); + setHasError(false); + setFileName(""); + setCopySelect(""); + }, [initialProductCd]); + + // open 시 초기화 + 복사 옵션 로드 + useEffect(() => { + if (!open) return; + reset(); + devBomApi.excelCopySource().then(setCopyOptions).catch(() => setCopyOptions([])); + }, [open, reset]); + + const handleDialogChange = (v: boolean) => { + if (!v) reset(); + onOpenChange(v); + }; + + // wace gridFn.search().gridComplete — 1레벨(PARENT_PART_NO 없는 첫 행) → 헤더 자동 채움 + const applyFirstLevelToHeader = (first: { part_no: string; part_name: string } | null) => { + if (!first) return; + if (first.part_no) setBomPartNo(first.part_no); + if (first.part_name) setBomPartName(first.part_name); + }; + + const parseFile = useCallback(async (file: File) => { + if (!/\.xlsx?$/i.test(file.name)) { + toast.error("xlsx 또는 xls 파일만 업로드 가능합니다."); + return; + } + setParsing(true); + setFileName(file.name); + try { + const data = await devBomApi.excelParse(file); + setRows(data.rows ?? []); + setHasError(!!data.hasError); + applyFirstLevelToHeader(data.firstLevel); + if (!data.rows || data.rows.length === 0) { + toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요."); + } else if (data.hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + } else { + toast.success(`${data.rows.length}건 파싱 완료`); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); + setRows([]); setHasError(false); setFileName(""); + } finally { + setParsing(false); + } + }, []); + + const handleFileInput = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) parseFile(f); + e.target.value = ""; + }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) parseFile(f); + }; + + const handleCopy = async () => { + if (!copySelect) { toast.error("복사할 BOM을 선택하세요."); return; } + setCopying(true); + try { + const copied = await devBomApi.excelCopy(copySelect); + setRows(copied); + setHasError(false); + const first = copied.find((r) => !r.PARENT_PART_NO); + if (first) applyFirstLevelToHeader({ part_no: first.PART_NO, part_name: first.PART_NAME }); + toast.success(`BOM 데이터 ${copied.length}건 불러왔습니다.`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 복사 실패"); + } finally { + setCopying(false); + } + }; + + const handleSave = async () => { + if (!productCd) { toast.error("제품구분을 선택해 주세요."); return; } + if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; } + if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; } + if (hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + return; + } + // wace fn_checkDuplicatePartNo 1:1 — 헤더 PART_NO 가 다른 BOM 에 이미 있으면 거부 (편집 중 자신 제외) + try { + const dup = await devBomApi.excelCheckDuplicate(bomPartNo, editObjid ?? undefined); + if (dup) { + toast.error("입력한 품번이 이미 존재합니다. 다른 품번을 입력해주세요."); + return; + } + } catch { /* 중복 확인 실패는 비차단 */ } + + const confirmMsg = rows.length > 0 ? "저장 하시겠습니까?" : "품번, 품명으로 빈 E-BOM을 생성하시겠습니까?"; + if (!confirm(confirmMsg)) return; + + setSaving(true); + try { + const result = await devBomApi.excelSave({ + bomReportObjid: editObjid ?? undefined, + productCd, partNo: bomPartNo, partName: bomPartName, version, + rows, + }); + toast.success(`${result.mode === "create" ? "등록" : "수정"} 완료 — BOM ${result.bomRows}건 (PART 신규 ${result.insertedParts} / 수정 ${result.updatedParts})`); + onSaved(); + handleDialogChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const errorCount = useMemo(() => rows.filter((r) => r.NOTE).length, [rows]); + + return ( + + + + PART 및 구조등록 Excel upload + + + {/* 헤더 */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + setVersion(e.target.value)} placeholder="REV 등" /> +
+
+ + {/* E-BOM 복사 + 액션 버튼 */} +
+
+ + + +
+
+ + + + {rows.length > 0 && ( + + )} +
+
+ +
+ {fileName && {fileName}} + 총 {rows.length}건 + {errorCount > 0 && 에러 {errorCount}건} +
+ + {/* Drop Zone */} + {rows.length === 0 && !parsing && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +
+ Drag & Drop 또는 클릭하여 BOM 엑셀 템플릿 업로드 (.xlsx, .xls) +
+
+ )} + + {parsing && ( +
+ 파싱 중... +
+ )} + + {/* 결과 그리드 */} + {rows.length > 0 && !parsing && ( +
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {rows.map((r, i) => ( + + + {COLUMNS.map((c) => { + const value = displayValue(r, c); + const isNote = c.key === "NOTE"; + return ( + + ); + })} + + ))} + +
# + {c.label} +
{i + 1} + {value} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/development/PartExcelImportDialog.tsx b/frontend/components/development/PartExcelImportDialog.tsx new file mode 100644 index 00000000..df943cee --- /dev/null +++ b/frontend/components/development/PartExcelImportDialog.tsx @@ -0,0 +1,280 @@ +"use client"; + +// 개발관리 PART Excel Import 다이얼로그 +// wace partMng/openPartExcelImportPopUp.jsp 1:1 +// - Template Download / Drag & Drop / 파일선택 → 백엔드 파싱 + 검증 +// - 검증 그리드 22컬럼 + NOTE (에러는 빨강) — wace expenseDetailGrid 1:1 +// - NOTE 있는 행이 하나라도 있으면 저장 차단 (wace fn_save 1:1) + +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Download, Upload, Save, Loader2, FileX } from "lucide-react"; +import { toast } from "sonner"; +import { devPartApi, PartExcelRow } from "@/lib/api/devPart"; +import { cn } from "@/lib/utils"; + +const TEMPLATE_DOWNLOAD_URL = "/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +} + +interface Column { + key: keyof PartExcelRow; + label: string; + width: string; + align?: "left" | "center" | "right"; + showNameFor?: keyof PartExcelRow; +} + +const COLUMNS: Column[] = [ + { key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" }, + { key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" }, + { key: "MATERIAL", label: "재료", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[110px]" }, + { key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[110px]" }, + { key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" }, + { key: "MAKER", label: "메이커", width: "min-w-[110px]" }, + { key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" }, + { key: "SPEC", label: "규격", width: "min-w-[100px]" }, + { key: "ACCTFG", label: "계정구분", width: "min-w-[90px]", align: "center", showNameFor: "ACCTFG_NAME" }, + { key: "ODRFG", label: "조달구분", width: "min-w-[90px]", align: "center", showNameFor: "ODRFG_NAME" }, + { key: "UNIT_DC", label: "재고단위", width: "min-w-[80px]", align: "center", showNameFor: "UNIT_DC_NAME" }, + { key: "UNITMANG_DC", label: "관리단위", width: "min-w-[80px]", align: "center", showNameFor: "UNITMANG_DC_NAME" }, + { key: "UNITCHNG_NB", label: "환산수량", width: "min-w-[80px]", align: "right" }, + { key: "LOT_FG", label: "LOT구분", width: "min-w-[80px]", align: "center" }, + { key: "USE_YN", label: "사용여부", width: "min-w-[80px]", align: "center" }, + { key: "QC_FG", label: "검사여부", width: "min-w-[80px]", align: "center" }, + { key: "SETITEM_FG", label: "SET품여부", width: "min-w-[90px]", align: "center" }, + { key: "REQ_FG", label: "의뢰여부", width: "min-w-[80px]", align: "center" }, + { key: "UNIT_LENGTH", label: "개당길이", width: "min-w-[80px]", align: "right" }, + { key: "UNIT_QTY", label: "개당소요량", width: "min-w-[90px]", align: "right" }, + { key: "REMARK", label: "비고", width: "min-w-[130px]", align: "left" }, +]; + +const LABEL_LOT = { "0": "미사용", "1": "사용" } as Record; +const LABEL_USE = { "0": "미사용", "1": "사용" } as Record; +const LABEL_QC = { "0": "무검사", "1": "검사" } as Record; +const LABEL_YN = { "0": "부", "1": "여" } as Record; + +function displayValue(r: PartExcelRow, col: Column): string { + if (col.showNameFor) { + const name = r[col.showNameFor]; + if (name) return String(name); + return String(r[col.key] ?? ""); + } + const v = String(r[col.key] ?? ""); + if (col.key === "LOT_FG") return LABEL_LOT[v] ?? v; + if (col.key === "USE_YN") return LABEL_USE[v] ?? v; + if (col.key === "QC_FG") return LABEL_QC[v] ?? v; + if (col.key === "SETITEM_FG" || col.key === "REQ_FG") return LABEL_YN[v] ?? v; + return v; +} + +export function PartExcelImportDialog({ open, onOpenChange, onSaved }: Props) { + const fileInputRef = useRef(null); + const [parsedRows, setParsedRows] = useState([]); + const [hasError, setHasError] = useState(false); + const [fileName, setFileName] = useState(""); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [dragOver, setDragOver] = useState(false); + + const reset = useCallback(() => { + setParsedRows([]); + setHasError(false); + setFileName(""); + }, []); + + const handleDialogChange = (v: boolean) => { + if (!v) reset(); + onOpenChange(v); + }; + + const parseFile = useCallback(async (file: File) => { + if (!/\.xlsx?$/i.test(file.name)) { + toast.error("xlsx 또는 xls 파일만 업로드 가능합니다."); + return; + } + setParsing(true); + setFileName(file.name); + try { + const data = await devPartApi.excelParse(file); + setParsedRows(data.rows ?? []); + setHasError(!!data.hasError); + if (!data.rows || data.rows.length === 0) { + toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요."); + } else if (data.hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + } else { + toast.success(`${data.rows.length}건 파싱 완료`); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); + reset(); + } finally { + setParsing(false); + } + }, [reset]); + + const handleFileInput = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) parseFile(f); + e.target.value = ""; + }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) parseFile(f); + }; + + const handleSave = async () => { + if (parsedRows.length === 0) { toast.error("저장할 데이터가 없습니다."); return; } + if (hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + return; + } + if (!confirm("저장 하시겠습니까?")) return; + setSaving(true); + try { + const res = await devPartApi.excelSave(parsedRows); + toast.success(`${res.inserted}건이 저장되었습니다.${res.skipped > 0 ? ` (중복 ${res.skipped}건 건너뜀)` : ""}`); + onSaved(); + handleDialogChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const errorCount = useMemo(() => parsedRows.filter((r) => r.NOTE).length, [parsedRows]); + + return ( + + + + PART 등록 Excel upload + + +
+ + + + {fileName && ( + + {fileName} + + )} + {parsedRows.length > 0 && ( + + )} +
+ 총 {parsedRows.length}건 + {errorCount > 0 && 에러 {errorCount}건} +
+
+ + {/* Drop Zone — 파싱 전에만 노출 */} + {parsedRows.length === 0 && !parsing && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +
+ Drag & Drop 또는 클릭하여 엑셀 템플릿 업로드 (.xlsx, .xls) +
+
+ )} + + {parsing && ( +
+ 파싱 중... +
+ )} + + {/* 결과 그리드 */} + {parsedRows.length > 0 && !parsing && ( +
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {parsedRows.map((r, i) => ( + + + {COLUMNS.map((c) => { + const value = displayValue(r, c); + const isNote = c.key === "NOTE"; + return ( + + ); + })} + + ))} + +
# + {c.label} +
{i + 1} + {value} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index fff3363b..90fde805 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -143,4 +143,88 @@ export const devBomApi = { const res = await apiClient.get("/development/ebom-tree/descending", { params: filter }); return res.data?.data as BomTreeResponse; }, + + // Excel Import + async excelParse(file: File): Promise { + const fd = new FormData(); + fd.append("file", file); + const res = await apiClient.post("/development/ebom/excel-parse", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data?.data as BomExcelParseResponse; + }, + + async excelCheckDuplicate(partNo: string, exclude?: string): Promise { + const res = await apiClient.get("/development/ebom/excel-check-duplicate", { + params: { partNo, exclude }, + }); + return !!res.data?.data?.isDuplicate; + }, + + async excelCopySource(productCd?: string): Promise { + const res = await apiClient.get("/development/ebom/excel-copy-source", { + params: productCd ? { productCd } : undefined, + }); + return (res.data?.data as BomCopySourceRow[]) ?? []; + }, + + async excelCopy(objid: string): Promise { + const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`); + return ((res.data?.data?.rows as BomExcelRow[]) ?? []); + }, + + async excelSave(input: BomExcelSaveInput): Promise { + const res = await apiClient.post("/development/ebom/excel-save", input); + return res.data?.data as BomExcelSaveResult; + }, }; + +// ─── Excel Import 타입 ───────────────────────────────────── + +export interface BomExcelRow { + NOTE: string; + PARENT_PART_NO: string; + PART_NO: string; + PART_NAME: string; + QTY: string; + ITEM_QTY: string; + MATERIAL: string; + SPEC: string; + POST_PROCESSING: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME?: string; + REMARK: string; +} + +export interface BomExcelParseResponse { + rows: BomExcelRow[]; + hasError: boolean; + firstLevel: { part_no: string; part_name: string } | null; +} + +export interface BomCopySourceRow { + objid: string; + part_no: string; + part_name: string; + revision: string | null; + product_cd: string | null; + regdate: string | null; +} + +export interface BomExcelSaveInput { + bomReportObjid?: string; + productCd: string; + partNo: string; + partName: string; + version?: string; + rows: BomExcelRow[]; +} + +export interface BomExcelSaveResult { + bomReportObjid: string; + insertedParts: number; + updatedParts: number; + bomRows: number; + mode: "create" | "update"; +} diff --git a/frontend/lib/api/devPart.ts b/frontend/lib/api/devPart.ts index 3798a600..caea8991 100644 --- a/frontend/lib/api/devPart.ts +++ b/frontend/lib/api/devPart.ts @@ -203,6 +203,50 @@ export interface DeployResult { eo_nos: Record; } +// ─── Excel Import ──────────────────────────────────────────── + +export interface PartExcelRow { + NOTE: string; + PART_NO: string; + PART_NAME: string; + MATERIAL: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME?: string; + SPEC: string; + ACCTFG: string; + ACCTFG_NAME?: string; + ODRFG: string; + ODRFG_NAME?: string; + UNIT_DC: string; + UNIT_DC_NAME?: string; + UNITMANG_DC: string; + UNITMANG_DC_NAME?: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; + UNIT_LENGTH: string; + UNIT_QTY: string; + REMARK: string; +} + +export interface ExcelParseResponse { + rows: PartExcelRow[]; + hasError: boolean; +} + +export interface ExcelSaveResponse { + inserted: number; + skipped: number; + skippedPartNos: string[]; +} + // ─── API ──────────────────────────────────────────────────── export const devPartApi = { @@ -246,4 +290,20 @@ export const devPartApi = { const res = await apiClient.delete("/development/part", { data: { objids } }); return res.data; }, + + // Excel Import — 파싱 + 검증 + async excelParse(file: File): Promise { + const fd = new FormData(); + fd.append("file", file); + const res = await apiClient.post("/development/part/excel-parse", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data?.data as ExcelParseResponse; + }, + + // Excel Import — 저장 (신규 PART_NO 만 INSERT) + async excelSave(rows: PartExcelRow[]): Promise { + const res = await apiClient.post("/development/part/excel-save", { rows }); + return res.data?.data as ExcelSaveResponse; + }, }; diff --git a/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e763b48c94fa57951bc1515367717a75dd51458b GIT binary patch literal 92438 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 z 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 E15d)JX#fBK literal 0 HcmV?d00001 diff --git a/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ebf7d495bc1b4c3b0e6ebda9f29a0ceaecfb084c GIT binary patch literal 127127 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 z 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 literal 0 HcmV?d00001 From 0c791d21d63eaf7c6be9feccd55885b663db29bc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 18:02:15 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BO?= =?UTF-8?q?M=20=EB=93=B1=EB=A1=9D=20=E2=80=94=20XLSX=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=ED=8F=90=EA=B8=B0=20=ED=9B=84=20CSV(11=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=C2=B7=EC=88=98=EC=A4=80=20=EA=B8=B0=EB=B0=98)=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영판 wace 재확인 결과 BOM 등록은 XLSX가 아니라 CSV 가 진짜 입력 포맷이었음. 근거: · openBomReportExcelImportPopUp.jsp : 제목 "PART 및 구조등록 CSV upload", Drop Zone "Drag & Drop CSV 템플릿", fnc_setFileDropZone(..., "csv") 로 CSV 만 허용 · /partMng/parsingExcelFile.do 가 .csv 분기에서 별도 함수 parsingCsvFile() 호출 · CSV 11컬럼 : 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체(MAKER) / 범주이름(PART_TYPE) · CSV 는 PARENT_PART_NO 컬럼이 없고 "수준" 으로 부모-자식 자동 결정 · 숫자("1","2","3"): 깊이 D 의 부모 = D-1 의 최신 품번 · 점 표기법("1","1.1","1.4.1"): 마지막 점 이전 수준이 부모 backend (devBomExcelImportService.ts): · XLSX(xlsx 라이브러리) 파싱 → CSV(iconv-lite) 파싱으로 완전 재작성 · 인코딩 자동 감지 1:1 — CP949 → UTF-8 → EUC-KR → MS949 순서, � 카운트로 베스트 선택, UTF-8 BOM() 자동 제거 · CSV 영문 범주명 자동 변환 — Assy/ASSY→조립품, Buy/BUY→구매품, Make/MAKE→부품 · 범주별 ACCTFG/ODRFG 자동 설정 — 조립품(0001813)·부품(0001812) → ACCTFG=4·ODRFG=1, 구매품(0000063) → ACCTFG=7·ODRFG=0 · 기본값 일괄 — UNIT_DC=0001400(EA) · UNITMANG_DC=0001400 · UNITCHNG_NB=1 · LOT_FG=1 · USE_YN=1 · QC_FG=0 · SETITEM_FG=0 · REQ_FG=0 · 검증 1:1 — rowIndex > 2 에서만 모품번 자품번 목록 존재 검사, NOTE 누적 · 저장 로직(savePartBomMaster)은 동일 유지 — 자식 PART updatePartInfoFromCsv UPDATE / insertpartInfo INSERT, 부모 PART 존재 시 lookup·없으면 "" (INSERT 안 함), bom_part_qty INSERT 시 ACCTFG/ODRFG/UNIT_DC/UNITCHNG_NB 등 모든 컬럼 동기 frontend: · BomExcelRow → BomCsvRow (LEVEL 컬럼 + ACCTFG/ODRFG/UNIT_DC/UNITCHNG_NB/LOT_FG/USE_YN/ QC_FG/SETITEM_FG/REQ_FG 자동 채움 필드 추가). BomExcelRow 는 호환 type alias 로 보존 · BomReportExcelImportDialog : 제목 "PART 및 구조등록 CSV upload", accept=".csv", Drop Zone 안내 텍스트 변경, 그리드 컬럼 — 결과/수준/모품번(자동)/품번/품명/수량/항목수량/재료/ 열처리경도/열처리방법/표면처리/공급업체/범주/계정구분(자동)/조달구분(자동) · 파싱 결과의 encoding 정보 표시 (CP949/UTF-8 등) 정적 자산: · 운영 BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx 제거 (운영판도 실제 사용 안 함) · 신규 BOM_REPORT_CSV_IMPORT_TEMPLATE.csv 추가 (11컬럼 헤더 + 4행 샘플) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/devBomExcelImportService.ts | 571 +++++++++++------- .../BomReportExcelImportDialog.tsx | 106 ++-- frontend/lib/api/devBom.ts | 35 +- .../BOM_REPORT_CSV_IMPORT_TEMPLATE.csv | 6 + .../BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx | Bin 92438 -> 0 bytes 5 files changed, 453 insertions(+), 265 deletions(-) create mode 100644 frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv delete mode 100644 frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx diff --git a/backend-node/src/services/devBomExcelImportService.ts b/backend-node/src/services/devBomExcelImportService.ts index 9f6d0a6a..b118aced 100644 --- a/backend-node/src/services/devBomExcelImportService.ts +++ b/backend-node/src/services/devBomExcelImportService.ts @@ -1,68 +1,170 @@ // ============================================================ -// 개발관리 BOM Report Excel Import 서비스 — wace_plm PartMngService 1:1 +// 개발관리 BOM Report CSV Import 서비스 — wace_plm PartMngService.parsingCsvFile 1:1 // -// 원본 흐름 (wace partMng/openBomReportExcelImportPopUp.jsp): -// 1) /partMng/parsingExcelFile.do → 업로드 파일 파싱 + 검증 (parsingExcelFile) -// 2) /partMng/checkDuplicatePartNo.do → PART_BOM_REPORT 헤더 PART_NO 중복 (수정 중인 자신 제외) -// 3) /partMng/partBomApplySave.do → savePartBomMaster() -// 3-1) 헤더 INSERT(신규) / UPDATE+자식트리 DELETE+STATUS reset(수정) -// 3-2) 행마다 (wace partBomMaster 1:1): -// ㄱ. partMng.getPartObjid(PART_NO, IS_LAST=1) -// → 있음: part_objid 재사용 + partMng.updatePartInfoFromCsv 로 기존 row UPDATE -// → 없음: createObjId + partMng.insertpartInfo (part_mng 신규 INSERT) -// ㄴ. partMng.getPartObjid(PARENT_PART_NO, IS_LAST=1) -// → 있음: parent_part_objid 사용 -// → 없음: parent_part_objid = "" (INSERT 절대 안 함 — wace 원본 5359-5361) -// ㄷ. partMng.getBomPartQtyObjid(PARENT_PART_NO, BOM_REPORT_OBJID) -// → bom_part_qty 부모행의 CHILD_OBJID 를 parent_objid 로 사용 -// ㄹ. partMng.relatePartInfo → bom_part_qty INSERT +// 운영판 흐름 (wace partMng/openBomReportExcelImportPopUp.jsp): +// · Drop Zone: "Drag & Drop CSV 템플릿" + fnc_setFileDropZone(..., "csv") → CSV 전용 +// · /partMng/parsingExcelFile.do 의 .csv 분기에서 parsingCsvFile() 호출 +// · /partMng/partBomApplySave.do 의 savePartBomMaster() 로 저장 // -// 검증 (wace parsingExcelFile 1:1): -// · 자품번 필수 / 엑셀 내 중복 -// · 모품번이 자품번 목록(allPartNumbers Set)에 존재해야 함 (1레벨 = 첫 데이터 행은 제외) -// · 품명 필수 -// · 수량 필수 + 숫자 + > 0 -// · PART_TYPE 코드명 → code_id (못 찾으면 NOTE "부품유형 확인") -// · PART_TYPE='0001788'(구매품표준) → part_mng.part_no 존재 검증 (NOTE "구매품표준 미등록") +// CSV 컬럼 (11개, 헤더 1줄 후 데이터): +// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법 +// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE) +// (11번 이후 컬럼은 파싱하지 않음 — 범주이름에 따라 자동값 설정) // -// vexplor_rps 적응: -// · CUSTOMER_OBJID/CONTRACT_OBJID/UNIT_CODE 본 메뉴에서 입력받지 않음 → NULL -// · 운영판 BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx 10컬럼 그대로: -// 0:모품번 1:자품번 2:품명 3:수량 4:재질 5:사양(규격) 6:후처리 7:MAKER 8:부품유형 9:REMARK -// (자바 코드는 25컬럼까지 매핑하지만 실제 운영 템플릿은 10컬럼) +// 핵심: PARENT_PART_NO 컬럼이 없음. "수준(level)" 으로 부모-자식 자동 결정. +// · 숫자("1","2","3","4"): 깊이 D 행의 부모 = depthToPartNoMap[D-1] (마지막에 등록된 D-1 깊이 품번) +// · 점 표기법("1","1.1","1.4.1"): 마지막 점 이전이 부모 수준 → levelToPartNoMap[parentLevel] +// +// CSV 자동 변환: +// · Assy/ASSY → 조립품, Buy/BUY → 구매품, Make/MAKE → 부품 (영문 PART_TYPE → 한글) +// · 범주 → 계정구분/조달구분 자동 (조립품 0001813 / 부품 0001812 → ACCTFG=4, ODRFG=1; +// 구매품 0000063 → ACCTFG=7, ODRFG=0) +// · 기본값 일괄: UNIT_DC=0001400(EA), UNITMANG_DC=0001400, UNITCHNG_NB=1, +// LOT_FG=1(사용), USE_YN=1(사용), QC_FG=0(무검사), SETITEM_FG=0(부), REQ_FG=0(부) +// +// 검증 (wace 1:1): +// · 모품번 자품번 목록 존재 검증은 rowIndex > 2 일 때만 (1·2 레벨 면제) +// · PART_TYPE 매핑 실패: NOTE "부품유형 확인:..." +// · 결과 누적: emptyColCnt < 9 또는 NOTE 있으면 row 채택 +// +// 인코딩 자동 감지 (wace detectFileEncoding 1:1): +// CP949 → UTF-8 → EUC-KR → MS949 순서. 각각 디코딩 → 깨진 문자(�) 개수 세서 0이면 즉시 선택, +// 아니면 가장 적은 것. UTF-8 BOM(EF BB BF) 자동 제거. +// +// 저장 (savePartBomMaster 1:1): +// · 헤더 part_bom_report INSERT(신규) / DELETE 자식트리+UPDATE(수정) +// · 자식 PART: IS_LAST='1' 존재 시 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT +// · 부모 PART: 존재 시 lookup, 없으면 "" (절대 INSERT 안 함) +// · bom_part_qty INSERT (relatePartInfo) — 부모행 CHILD_OBJID 체인 // ============================================================ -import * as XLSX from "xlsx"; +import * as iconv from "iconv-lite"; import { PoolClient } from "pg"; import { getPool, transaction } from "../database/db"; import { logger } from "../utils/logger"; import { createObjId } from "../utils/objidUtil"; const CODE_PARENT_PART_TYPE = "0000062"; +const PART_TYPE_ASSEMBLY = "0001813"; // 조립품 +const PART_TYPE_PART = "0001812"; // 부품 +const PART_TYPE_BUY = "0000063"; // 구매품 -export interface BomExcelRow { +const DEFAULT_UNIT_DC = "0001400"; // EA +const DEFAULT_UNITMANG_DC = "0001400"; // EA +const DEFAULT_UNITCHNG_NB = "1"; +const DEFAULT_LOT_FG = "1"; // 사용 +const DEFAULT_USE_YN = "1"; // 사용 +const DEFAULT_QC_FG = "0"; // 무검사 +const DEFAULT_SETITEM_FG = "0"; // 부 +const DEFAULT_REQ_FG = "0"; // 부 + +export interface BomCsvRow { NOTE: string; - PARENT_PART_NO: string; + LEVEL: string; // 표시용 (CSV 수준) + PARENT_PART_NO: string; // 수준에서 계산된 부모 품번 PART_NO: string; PART_NAME: string; QTY: string; ITEM_QTY: string; MATERIAL: string; - SPEC: string; - POST_PROCESSING: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; MAKER: string; - PART_TYPE: string; - PART_TYPE_NAME?: string; - REMARK: string; + PART_TYPE: string; // code_id (자동 변환된 한글 → DB 조회 결과) + PART_TYPE_NAME: string; // 한글 (Assy→조립품 변환 적용) + ACCTFG: string; + ODRFG: string; + UNIT_DC: string; + UNITMANG_DC: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; } -function appendNote(r: BomExcelRow, msg: string) { +function appendNote(r: BomCsvRow, msg: string) { r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg; } -function getCell(row: any[], idx: number): string { - const v = row?.[idx]; - if (v === undefined || v === null) return ""; - return String(v).trim(); + +// ─── 헬퍼 ─────────────────────────────────────────────────── + +// wace getCsvValue 1:1 — 따옴표 제거 + 빈 카운트 +function getCsvValue(values: string[], idx: number, emptyCounter: { count: number }): string { + if (idx >= values.length) { + emptyCounter.count++; + return ""; + } + let v = (values[idx] ?? "").trim(); + if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { + v = v.substring(1, v.length - 1); + } + if (!v) emptyCounter.count++; + return v; +} + +// wace getParentLevel 1:1: "1.4.1" → "1.4" / "1.8" → "1" / "1" → "" +function getParentLevel(level: string): string { + if (!level) return ""; + const lastDot = level.lastIndexOf("."); + return lastDot > 0 ? level.substring(0, lastDot) : ""; +} + +// CSV 영문 범주명 → 한글 (wace 1:1) +function normalizePartTypeName(raw: string): string { + const upper = raw.toUpperCase(); + if (upper === "ASSY") return "조립품"; + if (upper === "BUY") return "구매품"; + if (upper === "MAKE") return "부품"; + return raw; +} + +// 범주 code_id → ACCTFG/ODRFG 자동 매핑 (wace 1:1) +function autoAcctfgOdrfg(partTypeCode: string): { acctfg: string; odrfg: string } { + if (partTypeCode === PART_TYPE_ASSEMBLY || partTypeCode === PART_TYPE_PART) { + return { acctfg: "4", odrfg: "1" }; // 반제품/생산 + } + if (partTypeCode === PART_TYPE_BUY) { + return { acctfg: "7", odrfg: "0" }; // 비용/구매 + } + return { acctfg: "", odrfg: "" }; +} + +// 인코딩 자동 감지 (wace detectFileEncoding 1:1) +function detectAndDecode(buffer: Buffer): { text: string; encoding: string } { + const encodings = ["cp949", "utf-8", "euc-kr", "ms949"]; + let bestEncoding = "utf-8"; + let bestText = ""; + let minReplacement = Number.POSITIVE_INFINITY; + + for (const enc of encodings) { + try { + const decoded = iconv.decode(buffer, enc); + let replacementCount = 0; + const sample = decoded.substring(0, 4096); + for (let i = 0; i < sample.length; i++) { + if (sample.charCodeAt(i) === 0xFFFD) replacementCount++; + } + if (replacementCount === 0) { + return { text: stripBom(decoded), encoding: enc }; + } + if (replacementCount < minReplacement) { + minReplacement = replacementCount; + bestEncoding = enc; + bestText = decoded; + } + } catch { + continue; + } + } + return { text: stripBom(bestText), encoding: bestEncoding }; +} + +function stripBom(s: string): string { + if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) return s.substring(1); + return s; } async function fetchPartTypeMap(client: PoolClient): Promise> { @@ -76,142 +178,151 @@ async function fetchPartTypeMap(client: PoolClient): Promise { - const wb = XLSX.read(buffer, { type: "buffer" }); - const sheet = wb.Sheets[wb.SheetNames[0]]; - const raw: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }); + const { text, encoding } = detectAndDecode(buffer); + const lines = text.split(/\r\n|\r|\n/); - // wace 1: 모든 자품번을 먼저 Set 으로 수집 (모품번 검증용) + // 1차 스캔: 모든 자품번 + 수준→품번 매핑 (wace 1:1) const allPartNumbers = new Set(); - for (let i = 2; i < raw.length; i++) { - const partNo = getCell(raw[i], 1); // column 1 = 자품번 (운영 템플릿) - if (partNo) allPartNumbers.add(partNo); + const allRows: string[][] = []; + const levelToPartNoMap = new Map(); + + for (let i = 0; i < lines.length; i++) { + if (lines[i] === undefined) continue; + const values = lines[i].split(","); + allRows.push(values); + if (i > 0 && values.length > 1) { + let level = (values[0] ?? "").trim(); + let partNo = (values[1] ?? "").trim(); + if (level.length > 1 && level.startsWith('"') && level.endsWith('"')) level = level.substring(1, level.length - 1); + if (partNo.length > 1 && partNo.startsWith('"') && partNo.endsWith('"')) partNo = partNo.substring(1, partNo.length - 1); + if (partNo) { + allPartNumbers.add(partNo); + if (level) levelToPartNoMap.set(level, partNo); + } + } } - const result: BomExcelRow[] = []; + const result: BomCsvRow[] = []; let firstLevel: { part_no: string; part_name: string } | null = null; + // 2차 스캔: 행 파싱 + 부모 결정 + 검증 + const currentDepthPartNoMap = new Map(); // 숫자 수준용 (wace 1:1) + const client = await getPool().connect(); try { const partTypeMap = await fetchPartTypeMap(client); - const partNoSeenInFile = new Map(); - let emptyRowCnt = 0; - let dataRowIndex = 0; // 데이터 행 카운터 (1부터 — wace rowIndex 와 다름, 의미는 동일) - for (let i = 2; i < raw.length; i++) { - const row = raw[i]; - if (!row) continue; + let rowIndex = 0; + for (const values of allRows) { + if (rowIndex === 0) { rowIndex++; continue; } // 헤더 skip + if (values.length < 11) { rowIndex++; continue; } // wace: 최소 11컬럼 (수준 포함) - const parentPartNo = getCell(row, 0); - const partNo = getCell(row, 1); - const partName = getCell(row, 2); - const qty = getCell(row, 3); - const material = getCell(row, 4); - const spec = getCell(row, 5); - const postProc = getCell(row, 6); - const maker = getCell(row, 7); - const partTypeName = getCell(row, 8); - const remark = getCell(row, 9); + const emptyCounter = { count: 0 }; + let colIndex = 0; + const level = getCsvValue(values, colIndex++, emptyCounter); + const partNo = getCsvValue(values, colIndex++, emptyCounter); + const partName = getCsvValue(values, colIndex++, emptyCounter); + const qty = getCsvValue(values, colIndex++, emptyCounter); + const itemQty = getCsvValue(values, colIndex++, emptyCounter); + const material = getCsvValue(values, colIndex++, emptyCounter); + const heatHardness = getCsvValue(values, colIndex++, emptyCounter); + const heatMethod = getCsvValue(values, colIndex++, emptyCounter); + const surfaceTreatment = getCsvValue(values, colIndex++, emptyCounter); + const supplier = getCsvValue(values, colIndex++, emptyCounter); + const partTypeRaw = getCsvValue(values, colIndex++, emptyCounter); - // wace: PART_NO+PART_NAME 둘 다 빈 행은 스킵 (3줄 연속이면 break) - if (!partNo && !partName) { - emptyRowCnt++; - if (emptyRowCnt > 3) break; - continue; + // 수준 → 부모 결정 (wace 1:1) + let parentPartNo = ""; + if (level) { + const asInt = Number(level); + if (Number.isInteger(asInt) && /^\d+$/.test(level)) { + // 숫자 수준: 깊이 D 행 → D-1 깊이의 최신 품번이 부모 + const currentDepth = asInt; + if (partNo) currentDepthPartNoMap.set(currentDepth, partNo); + if (currentDepth > 1) { + parentPartNo = currentDepthPartNoMap.get(currentDepth - 1) ?? ""; + } + } else { + // 점 표기법: 마지막 점 이전 = 부모 수준 + const parentLevel = getParentLevel(level); + if (parentLevel) parentPartNo = levelToPartNoMap.get(parentLevel) ?? ""; + } } - emptyRowCnt = 0; - dataRowIndex++; - const cur: BomExcelRow = { - NOTE: "", + let noteMsg = ""; + + // 모품번 자품번 목록 존재 검증 (wace: rowIndex > 2 일 때만) + if (parentPartNo && rowIndex > 2 && !allPartNumbers.has(parentPartNo)) { + noteMsg += `모품번 미존재: ${parentPartNo} ; `; + } + + // PART_TYPE 처리 (wace 1:1) + const partTypeNormalized = partTypeRaw ? normalizePartTypeName(partTypeRaw) : ""; + let partTypeCode = ""; + if (partTypeNormalized) { + const hit = partTypeMap.get(partTypeNormalized.toUpperCase()); + if (hit) { + partTypeCode = hit.code_id; + } else if (rowIndex > 2) { + noteMsg += `부품유형 확인: ${partTypeRaw} ; `; + } + } + + const { acctfg, odrfg } = autoAcctfgOdrfg(partTypeCode); + + const row: BomCsvRow = { + NOTE: noteMsg.trim().replace(/\s*;\s*$/, ""), + LEVEL: level, PARENT_PART_NO: parentPartNo, PART_NO: partNo, PART_NAME: partName, QTY: qty, - ITEM_QTY: qty, // wace: ITEM_QTY 컬럼 없으면 QTY 와 동일 (savePartBomMaster 에서도 QTY 로 채움) + ITEM_QTY: itemQty || qty, MATERIAL: material, - SPEC: spec, - POST_PROCESSING: postProc, - MAKER: maker, - PART_TYPE: "", - REMARK: remark, + HEAT_TREATMENT_HARDNESS: heatHardness, + HEAT_TREATMENT_METHOD: heatMethod, + SURFACE_TREATMENT: surfaceTreatment, + MAKER: supplier, + PART_TYPE: partTypeCode, + PART_TYPE_NAME: partTypeNormalized, + ACCTFG: acctfg, + ODRFG: odrfg, + UNIT_DC: DEFAULT_UNIT_DC, + UNITMANG_DC: DEFAULT_UNITMANG_DC, + UNITCHNG_NB: DEFAULT_UNITCHNG_NB, + LOT_FG: DEFAULT_LOT_FG, + USE_YN: DEFAULT_USE_YN, + QC_FG: DEFAULT_QC_FG, + SETITEM_FG: DEFAULT_SETITEM_FG, + REQ_FG: DEFAULT_REQ_FG, }; - // (1) 자품번 필수 - if (!partNo) { - appendNote(cur, "필수입력 - 품번"); - } else { - if (partNoSeenInFile.has(partNo)) { - appendNote(cur, `품번 중복: ${partNo}`); - } else { - partNoSeenInFile.set(partNo, dataRowIndex); - } + // wace: NOTE 있거나 emptyColCnt < 9 면 결과 채택 + if (row.NOTE || emptyCounter.count < 9) { + result.push(row); } - // (2) 모품번 검증 (wace 1:1): 첫 데이터 행은 제외, 그 외는 자품번 목록에 있어야 함 - if (!parentPartNo && dataRowIndex > 1) { - appendNote(cur, "필수입력 - 모품번"); - } else if (parentPartNo && !allPartNumbers.has(parentPartNo)) { - appendNote(cur, `모품번이 자품번 목록에 없습니다: ${parentPartNo}`); - } - - // (3) 품명 필수 - if (!partName) appendNote(cur, "필수입력 - 품명"); - - // (4) 수량 필수 + 숫자 + > 0 (wace 1:1) - if (!qty) { - appendNote(cur, "필수입력 - 수량"); - } else { - const qtyValue = Number(qty); - if (!Number.isFinite(qtyValue)) { - appendNote(cur, `수량은 숫자여야 합니다: ${qty}`); - } else if (qtyValue <= 0) { - appendNote(cur, `수량은 0보다 커야 합니다: ${qty}`); - } - } - - // (5) PART_TYPE 코드 변환 + 구매품표준 검증 - if (partTypeName) { - const hit = partTypeMap.get(partTypeName.toUpperCase()); - if (hit) { - cur.PART_TYPE = hit.code_id; - cur.PART_TYPE_NAME = hit.code_name; - - // wace: rowIndex > 2 인 경우만 구매품표준 검증 (1~2 레벨은 면제) - if (hit.code_name === "구매품표준" && dataRowIndex > 2 && partNo) { - const exist = await client.query( - `SELECT 1 FROM PART_MNG WHERE PART_NO = $1 LIMIT 1`, - [partNo] - ); - if ((exist.rowCount ?? 0) === 0) { - appendNote(cur, `품번에 해당하는 구매품표준이 없습니다: ${partNo}`); - } - } - } else { - cur.PART_TYPE = partTypeName; - appendNote(cur, `부품유형 확인: ${partTypeName}`); - } - } - - // (6) 1레벨 (모품번 없는 첫 행)을 헤더 자동 채움 용도로 캡쳐 + // 첫 1레벨(parent 없는 첫 행) → 헤더 자동 if (!firstLevel && !parentPartNo && partNo) { firstLevel = { part_no: partNo, part_name: partName }; } - result.push(cur); + rowIndex++; } } finally { client.release(); } const hasError = result.some((r) => r.NOTE); - return { rows: result, hasError, firstLevel }; + return { rows: result, hasError, firstLevel, encoding }; } // ─── 2) 헤더 part_no 중복 검사 (wace checkDuplicatePartNo) ── @@ -226,52 +337,79 @@ export async function checkDuplicateBomPartNo(partNo: string, excludeObjid?: str return (r.rowCount ?? 0) > 0; } -// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomExcelRow[] ────── +// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomCsvRow[] ──────── -export async function copyBomForGrid(sourceObjid: string): Promise { +export async function copyBomForGrid(sourceObjid: string): Promise { const sql = ` - SELECT - Q.QTY, Q.ITEM_QTY, Q.SEQ, - PM.PART_NO AS PART_NO_REAL, - PM.PART_NAME AS PART_NAME, - PM.MATERIAL, PM.SPEC, PM.POST_PROCESSING, PM.MAKER, PM.REMARK, - PM.PART_TYPE, - CC.CODE_NAME AS PART_TYPE_NAME, - PM_PARENT.PART_NO AS PARENT_PART_NO_REAL - FROM BOM_PART_QTY Q - LEFT JOIN PART_MNG PM ON PM.OBJID::varchar = Q.PART_NO - LEFT JOIN PART_MNG PM_PARENT ON PM_PARENT.OBJID::varchar = Q.PARENT_PART_NO + WITH RECURSIVE TREE AS ( + SELECT BP.OBJID, BP.PARENT_OBJID, BP.CHILD_OBJID, BP.PART_NO, BP.PARENT_PART_NO, BP.QTY, BP.ITEM_QTY, + 1 AS LEV, ARRAY[BP.SEQ] AS PATH + FROM BOM_PART_QTY BP + WHERE BP.BOM_REPORT_OBJID = $1 + AND (BP.PARENT_OBJID IS NULL OR BP.PARENT_OBJID = '') + UNION ALL + SELECT B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, B.PART_NO, B.PARENT_PART_NO, B.QTY, B.ITEM_QTY, + T.LEV + 1, T.PATH || B.SEQ + FROM BOM_PART_QTY B + JOIN TREE T ON B.PARENT_OBJID = T.CHILD_OBJID + WHERE B.BOM_REPORT_OBJID = $1 + ) + SELECT T.LEV, + PM.PART_NO AS PART_NO_REAL, + PM.PART_NAME AS PART_NAME, + T.QTY, T.ITEM_QTY, + PM.MATERIAL, PM.HEAT_TREATMENT_HARDNESS, PM.HEAT_TREATMENT_METHOD, PM.SURFACE_TREATMENT, + PM.MAKER, PM.PART_TYPE, + CC.CODE_NAME AS PART_TYPE_NAME, + PM_PARENT.PART_NO AS PARENT_PART_NO_REAL, + PM.ACCTFG, PM.ODRFG, + PM.UNIT_DC, PM.UNITMANG_DC, PM.UNITCHNG_NB, + PM.LOT_FG, PM.USE_YN, PM.QC_FG, PM.SETITEM_FG, PM.REQ_FG, + T.PATH + FROM TREE T + LEFT JOIN PART_MNG PM ON PM.OBJID::varchar = T.PART_NO + LEFT JOIN PART_MNG PM_PARENT ON PM_PARENT.OBJID::varchar = T.PARENT_PART_NO LEFT JOIN COMM_CODE CC ON CC.CODE_ID = PM.PART_TYPE - WHERE Q.BOM_REPORT_OBJID = $1 - ORDER BY Q.SEQ + ORDER BY T.PATH `; const r = await getPool().query(sql, [sourceObjid]); - return r.rows.map((row): BomExcelRow => ({ + return r.rows.map((row): BomCsvRow => ({ NOTE: "", + LEVEL: String(row.lev ?? ""), PARENT_PART_NO: row.parent_part_no_real ?? "", PART_NO: row.part_no_real ?? "", PART_NAME: row.part_name ?? "", QTY: row.qty != null ? String(row.qty) : "", ITEM_QTY: row.item_qty != null ? String(row.item_qty) : (row.qty != null ? String(row.qty) : ""), MATERIAL: row.material ?? "", - SPEC: row.spec ?? "", - POST_PROCESSING: row.post_processing ?? "", + HEAT_TREATMENT_HARDNESS: row.heat_treatment_hardness ?? "", + HEAT_TREATMENT_METHOD: row.heat_treatment_method ?? "", + SURFACE_TREATMENT: row.surface_treatment ?? "", MAKER: row.maker ?? "", PART_TYPE: row.part_type ?? "", PART_TYPE_NAME: row.part_type_name ?? "", - REMARK: row.remark ?? "", + ACCTFG: row.acctfg ?? "", + ODRFG: row.odrfg ?? "", + UNIT_DC: row.unit_dc ?? DEFAULT_UNIT_DC, + UNITMANG_DC: row.unitmang_dc ?? DEFAULT_UNITMANG_DC, + UNITCHNG_NB: row.unitchng_nb != null ? String(row.unitchng_nb) : DEFAULT_UNITCHNG_NB, + LOT_FG: row.lot_fg ?? DEFAULT_LOT_FG, + USE_YN: row.use_yn ?? DEFAULT_USE_YN, + QC_FG: row.qc_fg ?? DEFAULT_QC_FG, + SETITEM_FG: row.setitem_fg ?? DEFAULT_SETITEM_FG, + REQ_FG: row.req_fg ?? DEFAULT_REQ_FG, })); } // ─── 4) BOM 저장 (savePartBomMaster 1:1) ──────────────────── export interface BomSaveInput { - bomReportObjid?: string; // 비어있으면 신규 + bomReportObjid?: string; productCd: string; - partNo: string; // 헤더 품번 + partNo: string; partName: string; version?: string; - rows: BomExcelRow[]; + rows: BomCsvRow[]; } export interface BomSaveResult { @@ -295,7 +433,6 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis await transaction(async (client: PoolClient) => { if (mode === "update") { - // wace: 기존 BOM 수정 시 자식 트리 DELETE + STATUS='N' reset (deleteBomPartQtyByBomObjid + resetBomReportStatus) await client.query(`DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = $1`, [bomReportObjid]); await client.query( `UPDATE PART_BOM_REPORT @@ -322,16 +459,14 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis ); } - if (!input.rows || input.rows.length === 0) return; // wace: 빈 BOM 허용 (헤더만 생성) + if (!input.rows || input.rows.length === 0) return; - // 자식 PART_NO → part_mng.objid 캐시 (행 처리 후) - const childPartObjIdCache = new Map(); - // bom_part_qty 부모행의 CHILD_OBJID 캐시 (다음 자식들이 이 값을 PARENT_OBJID 로 사용) - const childBomObjIdByPartNo = new Map(); + const partObjIdCache = new Map(); // PART_NO → part_mng.objid (자식·부모 공용 캐시) + const childBomObjIdByPartNo = new Map(); // PART_NO → bom_part_qty.child_objid - // 자식 PART 처리: 있으면 UPDATE / 없으면 INSERT (wace 1:1) - async function upsertChildPart(r: BomExcelRow): Promise { - if (childPartObjIdCache.has(r.PART_NO)) return childPartObjIdCache.get(r.PART_NO)!; + // 자식 PART: 있으면 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT (wace 1:1) + async function upsertChildPart(r: BomCsvRow): Promise { + if (partObjIdCache.has(r.PART_NO)) return partObjIdCache.get(r.PART_NO)!; const exist = await client.query( `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, @@ -339,76 +474,89 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis ); if ((exist.rowCount ?? 0) > 0) { const id = exist.rows[0].part_objid; - // wace updatePartInfoFromCsv 1:1 (BOM Excel 에 있는 컬럼만 — 나머지는 그대로) - // 단, 원본 매퍼는 빈값이어도 UPDATE 함. RPS 에서는 BOM 엑셀에 없는 컬럼은 NULL로 덮어쓰지 않도록 COALESCE 처리. await client.query( `UPDATE PART_MNG SET - PART_NAME = $1, - SPEC = $2, - MATERIAL = $3, - REMARK = $4, - PART_TYPE = COALESCE(NULLIF($5, ''), PART_TYPE), - MAKER = $6, - POST_PROCESSING = $7, - EDIT_DATE = NOW() - WHERE OBJID = $8::numeric`, + PART_NAME = $1, + MATERIAL = $2, + HEAT_TREATMENT_HARDNESS = $3, + HEAT_TREATMENT_METHOD = $4, + SURFACE_TREATMENT = $5, + PART_TYPE = COALESCE(NULLIF($6, ''), PART_TYPE), + MAKER = $7, + ACCTFG = $8, + ODRFG = $9, + UNIT_DC = $10, + UNITMANG_DC = $11, + UNITCHNG_NB = COALESCE(NULLIF($12, ''), '0')::numeric, + LOT_FG = COALESCE(NULLIF($13, ''), LOT_FG), + USE_YN = COALESCE(NULLIF($14, ''), USE_YN), + QC_FG = COALESCE(NULLIF($15, ''), QC_FG), + SETITEM_FG = COALESCE(NULLIF($16, ''), SETITEM_FG), + REQ_FG = COALESCE(NULLIF($17, ''), REQ_FG), + EDIT_DATE = NOW() + WHERE OBJID = $18::numeric`, [ - r.PART_NAME ?? "", - r.SPEC ?? "", - r.MATERIAL ?? "", - r.REMARK ?? "", - r.PART_TYPE ?? "", - r.MAKER ?? "", - r.POST_PROCESSING ?? "", + r.PART_NAME, r.MATERIAL, + r.HEAT_TREATMENT_HARDNESS, r.HEAT_TREATMENT_METHOD, r.SURFACE_TREATMENT, + r.PART_TYPE, r.MAKER, + r.ACCTFG, r.ODRFG, + r.UNIT_DC, r.UNITMANG_DC, r.UNITCHNG_NB, + r.LOT_FG, r.USE_YN, r.QC_FG, r.SETITEM_FG, r.REQ_FG, id, ] ); updatedParts++; - childPartObjIdCache.set(r.PART_NO, id); + partObjIdCache.set(r.PART_NO, id); return id; } - // 신규 INSERT (insertpartInfo 1:1, BOM Excel 컬럼만 채움) const newId = createObjId(); await client.query( `INSERT INTO PART_MNG ( - OBJID, PART_NO, PART_NAME, SPEC, MATERIAL, REMARK, + OBJID, PART_NO, PART_NAME, MATERIAL, REMARK, STATUS, REG_DATE, WRITER, IS_LAST, - PART_TYPE, MAKER, POST_PROCESSING, + HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, + PART_TYPE, MAKER, + ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB, LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG ) VALUES ( - $1::numeric, $2, $3, $4, $5, $6, - 'create', NOW(), $7, '1', - NULLIF($8, ''), $9, $10, - '0', '1', '0', '0', '0' + $1::numeric, $2, $3, $4, '', + 'create', NOW(), $5, '1', + $6, $7, $8, + NULLIF($9, ''), $10, + $11, $12, $13, $14, + COALESCE(NULLIF($15, ''), '0')::numeric, + COALESCE($16, '0'), COALESCE($17, '1'), COALESCE($18, '0'), + COALESCE($19, '0'), COALESCE($20, '0') )`, [ - newId, r.PART_NO, - r.PART_NAME ?? "", r.SPEC ?? "", r.MATERIAL ?? "", r.REMARK ?? "", + newId, r.PART_NO, r.PART_NAME, r.MATERIAL, userId, - r.PART_TYPE ?? "", r.MAKER ?? "", r.POST_PROCESSING ?? "", + r.HEAT_TREATMENT_HARDNESS, r.HEAT_TREATMENT_METHOD, r.SURFACE_TREATMENT, + r.PART_TYPE, r.MAKER, + r.ACCTFG, r.ODRFG, r.UNIT_DC, r.UNITMANG_DC, r.UNITCHNG_NB, + r.LOT_FG, r.USE_YN, r.QC_FG, r.SETITEM_FG, r.REQ_FG, ] ); insertedParts++; - childPartObjIdCache.set(r.PART_NO, newId); + partObjIdCache.set(r.PART_NO, newId); return newId; } - // 부모 PART 처리: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) + // 부모 PART: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) async function lookupParentPart(partNo: string): Promise { if (!partNo) return ""; - if (childPartObjIdCache.has(partNo)) return childPartObjIdCache.get(partNo)!; - + if (partObjIdCache.has(partNo)) return partObjIdCache.get(partNo)!; const exist = await client.query( `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, [partNo] ); if ((exist.rowCount ?? 0) > 0) { const id = exist.rows[0].part_objid; - childPartObjIdCache.set(partNo, id); + partObjIdCache.set(partNo, id); return id; } - return ""; // wace 원본 5359-5361: 부모 part_mng 없으면 parent_part_no="" (INSERT 안 함) + return ""; } for (const r of input.rows) { @@ -416,15 +564,10 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis const partObjid = await upsertChildPart(r); const parentPartObjid = await lookupParentPart(r.PARENT_PART_NO); + const parentBomObjid = r.PARENT_PART_NO ? (childBomObjIdByPartNo.get(r.PARENT_PART_NO) ?? "") : ""; + const newBomObjid = createObjId(); + const newChildObjid = createObjId(); - // wace: bom_part_qty 에서 부모 행의 CHILD_OBJID 조회 (PARENT_PART_NO + BOM_REPORT_OBJID). - // 행이 엑셀 순서대로 들어오므로 메모리 캐시면 동등 (자식이 부모보다 항상 뒤에 등장). - const parentBomObjid = r.PARENT_PART_NO ? (childBomObjIdByPartNo.get(r.PARENT_PART_NO) ?? "") : ""; - - const newBomObjid = createObjId(); - const newChildObjid = createObjId(); - - // wace relatePartInfo 1:1 — QTY/ITEM_QTY/QTY_TEMP 모두 COALESCE(NULLIF, '0')::numeric await client.query( `INSERT INTO BOM_PART_QTY ( BOM_REPORT_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, @@ -453,7 +596,7 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis } }); - logger.info("BOM Excel Import 저장 완료", { userId, bomReportObjid, mode, insertedParts, updatedParts, bomRows }); + logger.info("BOM CSV Import 저장 완료", { userId, bomReportObjid, mode, insertedParts, updatedParts, bomRows }); return { bomReportObjid, insertedParts, updatedParts, bomRows, mode }; } diff --git a/frontend/components/development/BomReportExcelImportDialog.tsx b/frontend/components/development/BomReportExcelImportDialog.tsx index e0f4d2fa..fae60795 100644 --- a/frontend/components/development/BomReportExcelImportDialog.tsx +++ b/frontend/components/development/BomReportExcelImportDialog.tsx @@ -1,11 +1,16 @@ "use client"; -// 개발관리 E-BOM 등록 Excel Import 다이얼로그 +// 개발관리 E-BOM 등록 CSV Import 다이얼로그 // wace partMng/openBomReportExcelImportPopUp.jsp 1:1 -// - 헤더: 제품구분 / 품번(readonly, 1레벨 자동) / 품명(readonly, 1레벨 자동) / Version -// - E-BOM 복사 (기존 BOM 선택 → 그리드 채움) -// - Drag&Drop 엑셀 → 백엔드 파싱 + 검증 (NOTE 누적) -// - 저장: part_bom_report 헤더 + bom_part_qty 트리. NOTE 있는 행이 1건이라도 있으면 차단. +// +// 운영판 흐름: +// · Drop Zone: Drag & Drop CSV 템플릿 (fnc_setFileDropZone(..., "csv")) +// · 파싱: parsingExcelFile.do 의 .csv 분기 → parsingCsvFile (수준 기반 부모 자동 매핑) +// · 저장: partBomApplySave.do → savePartBomMaster +// +// CSV 컬럼 (11개, 헤더 1줄 후 데이터): +// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법 +// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE) import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -18,44 +23,58 @@ import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { devBomApi, BomExcelRow, BomCopySourceRow } from "@/lib/api/devBom"; +import { devBomApi, BomCsvRow, BomCopySourceRow } from "@/lib/api/devBom"; const PRODUCT_GROUP = "0000001"; -const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx"; +const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; - // 수정 모드 시: 기존 BOM_REPORT_OBJID (메인 그리드 행 클릭 → "Excel로 재등록" 등의 진입점) editObjid?: string | null; initialProductCd?: string; onSaved: () => void; } interface Column { - key: keyof BomExcelRow; + key: keyof BomCsvRow; label: string; width: string; align?: "left" | "center" | "right"; - showNameFor?: keyof BomExcelRow; + showNameFor?: keyof BomCsvRow; } +// 그리드 컬럼: 화면 표시는 핵심 + 자동 채움 컬럼 (운영 그리드 25컬럼 중 CSV 11컬럼 + 자동 ACCTFG/ODRFG) const COLUMNS: Column[] = [ - { key: "NOTE", label: "결과", width: "min-w-[220px]", align: "left" }, - { key: "PARENT_PART_NO", label: "모품번", width: "min-w-[140px]", align: "center" }, - { key: "PART_NO", label: "자품번", width: "min-w-[140px]", align: "center" }, + { key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" }, + { key: "LEVEL", label: "수준", width: "min-w-[60px]", align: "center" }, + { key: "PARENT_PART_NO", label: "모품번 (자동)", width: "min-w-[140px]", align: "center" }, + { key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" }, { key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" }, { key: "QTY", label: "수량", width: "min-w-[70px]", align: "right" }, { key: "ITEM_QTY", label: "항목수량", width: "min-w-[80px]", align: "right" }, - { key: "MATERIAL", label: "재질", width: "min-w-[100px]" }, - { key: "SPEC", label: "사양(규격)", width: "min-w-[110px]" }, - { key: "POST_PROCESSING", label: "후처리", width: "min-w-[100px]" }, - { key: "MAKER", label: "MAKER", width: "min-w-[110px]" }, - { key: "PART_TYPE", label: "부품유형", width: "min-w-[100px]", align: "center", showNameFor: "PART_TYPE_NAME" }, - { key: "REMARK", label: "REMARK", width: "min-w-[130px]", align: "left" }, + { key: "MATERIAL", label: "재료", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[100px]" }, + { key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" }, + { key: "MAKER", label: "공급업체", width: "min-w-[110px]" }, + { key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" }, + { key: "ACCTFG", label: "계정구분(자동)", width: "min-w-[90px]", align: "center" }, + { key: "ODRFG", label: "조달구분(자동)", width: "min-w-[90px]", align: "center" }, ]; -function displayValue(r: BomExcelRow, col: Column): string { +const ACCTFG_LABEL: Record = { "4": "반제품", "7": "비용" }; +const ODRFG_LABEL: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; + +function displayValue(r: BomCsvRow, col: Column): string { + if (col.key === "ACCTFG") { + const v = String(r.ACCTFG ?? ""); + return ACCTFG_LABEL[v] ?? v; + } + if (col.key === "ODRFG") { + const v = String(r.ODRFG ?? ""); + return ODRFG_LABEL[v] ?? v; + } if (col.showNameFor) { const name = r[col.showNameFor]; if (name) return String(name); @@ -74,9 +93,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init const [copyOptions, setCopyOptions] = useState([]); const [copySelect, setCopySelect] = useState(""); - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [hasError, setHasError] = useState(false); const [fileName, setFileName] = useState(""); + const [encoding, setEncoding] = useState(""); const [parsing, setParsing] = useState(false); const [saving, setSaving] = useState(false); const [copying, setCopying] = useState(false); @@ -90,10 +110,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init setRows([]); setHasError(false); setFileName(""); + setEncoding(""); setCopySelect(""); }, [initialProductCd]); - // open 시 초기화 + 복사 옵션 로드 useEffect(() => { if (!open) return; reset(); @@ -105,7 +125,6 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init onOpenChange(v); }; - // wace gridFn.search().gridComplete — 1레벨(PARENT_PART_NO 없는 첫 행) → 헤더 자동 채움 const applyFirstLevelToHeader = (first: { part_no: string; part_name: string } | null) => { if (!first) return; if (first.part_no) setBomPartNo(first.part_no); @@ -113,8 +132,8 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init }; const parseFile = useCallback(async (file: File) => { - if (!/\.xlsx?$/i.test(file.name)) { - toast.error("xlsx 또는 xls 파일만 업로드 가능합니다."); + if (!/\.csv$/i.test(file.name)) { + toast.error("CSV(.csv) 파일만 업로드 가능합니다."); return; } setParsing(true); @@ -123,17 +142,18 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init const data = await devBomApi.excelParse(file); setRows(data.rows ?? []); setHasError(!!data.hasError); + setEncoding(data.encoding ?? ""); applyFirstLevelToHeader(data.firstLevel); if (!data.rows || data.rows.length === 0) { - toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요."); + toast.warning("파싱된 데이터가 없습니다. CSV 형식을 확인해 주세요."); } else if (data.hasError) { - toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요."); } else { - toast.success(`${data.rows.length}건 파싱 완료`); + toast.success(`${data.rows.length}건 파싱 완료 (인코딩: ${data.encoding})`); } } catch (e: any) { - toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); - setRows([]); setHasError(false); setFileName(""); + toast.error(e?.response?.data?.message ?? e?.message ?? "CSV 파싱 실패"); + setRows([]); setHasError(false); setFileName(""); setEncoding(""); } finally { setParsing(false); } @@ -173,17 +193,16 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; } if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; } if (hasError) { - toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요."); return; } - // wace fn_checkDuplicatePartNo 1:1 — 헤더 PART_NO 가 다른 BOM 에 이미 있으면 거부 (편집 중 자신 제외) try { const dup = await devBomApi.excelCheckDuplicate(bomPartNo, editObjid ?? undefined); if (dup) { toast.error("입력한 품번이 이미 존재합니다. 다른 품번을 입력해주세요."); return; } - } catch { /* 중복 확인 실패는 비차단 */ } + } catch { /* 비차단 */ } const confirmMsg = rows.length > 0 ? "저장 하시겠습니까?" : "품번, 품명으로 빈 E-BOM을 생성하시겠습니까?"; if (!confirm(confirmMsg)) return; @@ -209,9 +228,9 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init return ( - + - PART 및 구조등록 Excel upload + PART 및 구조등록 CSV upload {/* 헤더 */} @@ -226,11 +245,11 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
- +
- +
)} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 90fde805..fc4ba30c 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -168,9 +168,9 @@ export const devBomApi = { return (res.data?.data as BomCopySourceRow[]) ?? []; }, - async excelCopy(objid: string): Promise { + async excelCopy(objid: string): Promise { const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`); - return ((res.data?.data?.rows as BomExcelRow[]) ?? []); + return ((res.data?.data?.rows as BomCsvRow[]) ?? []); }, async excelSave(input: BomExcelSaveInput): Promise { @@ -179,28 +179,43 @@ export const devBomApi = { }, }; -// ─── Excel Import 타입 ───────────────────────────────────── +// ─── CSV Import 타입 (wace parsingCsvFile 1:1) ───────────── -export interface BomExcelRow { +export interface BomCsvRow { NOTE: string; + LEVEL: string; PARENT_PART_NO: string; PART_NO: string; PART_NAME: string; QTY: string; ITEM_QTY: string; MATERIAL: string; - SPEC: string; - POST_PROCESSING: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; MAKER: string; PART_TYPE: string; - PART_TYPE_NAME?: string; - REMARK: string; + PART_TYPE_NAME: string; + ACCTFG: string; + ODRFG: string; + UNIT_DC: string; + UNITMANG_DC: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; } +// 기존 코드 호환용 별칭 (필요 시 마이그레이션) +export type BomExcelRow = BomCsvRow; + export interface BomExcelParseResponse { - rows: BomExcelRow[]; + rows: BomCsvRow[]; hasError: boolean; firstLevel: { part_no: string; part_name: string } | null; + encoding: string; } export interface BomCopySourceRow { @@ -218,7 +233,7 @@ export interface BomExcelSaveInput { partNo: string; partName: string; version?: string; - rows: BomExcelRow[]; + rows: BomCsvRow[]; } export interface BomExcelSaveResult { diff --git a/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv b/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv new file mode 100644 index 00000000..3d9d21c1 --- /dev/null +++ b/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv @@ -0,0 +1,6 @@ +수준,품번,품명,수량,항목수량,재료,열처리경도,열처리방법,표면처리,공급업체,범주이름 +1,RFX-140,Peak 소성로,1,1,재질1,,,,(주)배관랜드,구매품 +2,RFX-140-010,치환실 앗세이,1,1,재질2,,,,(주)네온테크,조립품 +3,RFX-140-010-001,치환실 앗세이_001,1,1,재질3,,,,(주)우리전열,구매품 +3,RFX-140-010-002,AC전원,1,1,,,,,ABB,구매품 +2,RFX-140-020,전원부 앗세이,1,1,,,,,,부품 diff --git a/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx deleted file mode 100644 index e763b48c94fa57951bc1515367717a75dd51458b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 92438 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 z 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 E15d)JX#fBK From 7d73d2ee5703e5e42a43b550c40641e96fc14394 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 18:07:27 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BO?= =?UTF-8?q?M=20=EC=A1=B0=ED=9A=8C=20M4=20=E2=80=94=20=EC=A0=95/=EC=97=AD?= =?UTF-8?q?=EC=A0=84=EA=B0=9C=20=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=8B=A0=EC=84=A4=20(wace=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영판: structureAscendingListExcel.jsp / structureDescendingListExcel.jsp backend (devBomService.ts): - ascendingForExcel / descendingForExcel — 그리드용 ascending/descending 보다 풀 컬럼 · 추가: P_QTY(bom_part_qty.item_qty), HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, MAKER, PART_TYPE_TITLE(comm_code.code_name) - devBomExcelExportService.ts 신규 (xlsx 라이브러리) · 헤더: 동적 L1..LMaxLevel + 품번 / 품명 / 수량 / 항목수량 / 3D / 2D / PDF / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 (wace 1:1) · LEVEL 셀: 행의 LEV 와 동일한 컬럼에 "*", 나머지는 공백 · 3D/2D/PDF: attach_file_info count > 0 → "Y", 0 → 공백 (wace 1:1) · 컬럼 너비: LEVEL 4, 품번/품명/재료/MAKER 등 적절히 조정 · 시트명: "BOM 정전개" / "BOM 역전개" - controllers/devBomController.ts: excelAscending / excelDescending 추가 · Content-Disposition 에 filename + filename*=UTF-8'' 둘 다 (한글 파일명 호환) - routes/devBomRoutes.ts: /ebom-tree/ascending/excel, /ebom-tree/descending/excel frontend: - lib/api/devBom.ts: excelAscending/excelDescending (responseType: "blob") · Content-Disposition 의 filename*=UTF-8'' 우선 파싱 헬퍼 추가 - app/.../ebom-search/page.tsx: "정전개 엑셀" / "역전개 엑셀" 버튼 추가 · 현재 검색 조건 그대로 다운로드, blob → anchor.click 으로 저장 · 파일명: 서버 응답 헤더의 "BOM 조회(정전개)_YYYY-MM-DD_HH-mm.xlsx" 그대로 사용 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/devBomController.ts | 33 +++++ backend-node/src/routes/devBomRoutes.ts | 2 + .../src/services/devBomExcelExportService.ts | 98 ++++++++++++ backend-node/src/services/devBomService.ts | 139 ++++++++++++++++++ .../development/ebom-search/page.tsx | 30 +++- frontend/lib/api/devBom.ts | 28 ++++ 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/services/devBomExcelExportService.ts diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts index 2d88b5b7..2b9a0f07 100644 --- a/backend-node/src/controllers/devBomController.ts +++ b/backend-node/src/controllers/devBomController.ts @@ -13,6 +13,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/devBomService"; import * as excelSvc from "../services/devBomExcelImportService"; +import * as excelExportSvc from "../services/devBomExcelExportService"; import { logger } from "../utils/logger"; function parseListFilter(q: Record): svc.BomReportListFilter { @@ -166,6 +167,38 @@ export async function ascending(req: AuthenticatedRequest, res: Response) { } } +// ─── M4 엑셀 다운로드 (정/역전개) ────────────────────────── +// GET /api/development/ebom-tree/ascending/excel +// GET /api/development/ebom-tree/descending/excel +// wace structureAscendingListExcel.jsp / structureDescendingListExcel.jsp 1:1 + +function sendExcel(res: Response, buffer: Buffer, fileName: string) { + const encoded = encodeURIComponent(fileName); + res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + res.setHeader("Content-Disposition", `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`); + res.send(buffer); +} + +export async function excelAscending(req: AuthenticatedRequest, res: Response) { + try { + const { buffer, fileName } = await excelExportSvc.generateAscendingExcel(req.query as svc.BomTreeFilter); + return sendExcel(res, buffer, fileName); + } catch (e: any) { + logger.error("E-BOM 정전개 엑셀 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelDescending(req: AuthenticatedRequest, res: Response) { + try { + const { buffer, fileName } = await excelExportSvc.generateDescendingExcel(req.query as svc.BomTreeFilter); + return sendExcel(res, buffer, fileName); + } catch (e: any) { + logger.error("E-BOM 역전개 엑셀 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // ─── M4 역전개 ───────────────────────────────────────────── export async function descending(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts index 8e3b892b..8756a24d 100644 --- a/backend-node/src/routes/devBomRoutes.ts +++ b/backend-node/src/routes/devBomRoutes.ts @@ -19,6 +19,8 @@ const excelUpload = multer({ // M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위) router.get("/ebom-tree/ascending", ctrl.ascending); router.get("/ebom-tree/descending", ctrl.descending); +router.get("/ebom-tree/ascending/excel", ctrl.excelAscending); +router.get("/ebom-tree/descending/excel", ctrl.excelDescending); // M3 Excel Import — /:objid 보다 위에 (라우트 충돌 방지) router.post("/ebom/excel-parse", excelUpload.single("file"), ctrl.excelParse); diff --git a/backend-node/src/services/devBomExcelExportService.ts b/backend-node/src/services/devBomExcelExportService.ts new file mode 100644 index 00000000..05167ffc --- /dev/null +++ b/backend-node/src/services/devBomExcelExportService.ts @@ -0,0 +1,98 @@ +// ============================================================ +// 개발관리 M4 E-BOM 엑셀 다운로드 (정/역전개) — wace 1:1 +// structureAscendingListExcel.jsp / structureDescendingListExcel.jsp +// +// 시트 구성 (wace 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량 / 항목수량(P_QTY) / +// 3D / 2D / PDF (Y/공백) / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / +// 범주 이름 / 비고 +// 헤더 행: 노란색 배경 + bold (운영판 스타일) +// +// 파일명: "BOM 조회(정전개)_YYYY-MM-DD_HH-mm.xlsx" / "BOM 조회(역전개)..." +// ============================================================ + +import * as XLSX from "xlsx"; +import { BomTreeFilter } from "./devBomService"; +import { ascendingForExcel, descendingForExcel } from "./devBomService"; + +type ExcelDirection = "ascending" | "descending"; + +function pad(n: number): string { return n < 10 ? `0${n}` : String(n); } +function nowStamp(): string { + const d = new Date(); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; +} + +function buildSheet(rows: any[], maxLevel: number): XLSX.WorkSheet { + const effectiveMax = Math.max(1, maxLevel); + + // 헤더 행 + const header: string[] = []; + for (let i = 1; i <= effectiveMax; i++) header.push(String(i)); + header.push("품번", "품명", "수량", "항목수량", "3D", "2D", "PDF", + "재료", "열처리경도", "열처리방법", "표면처리", "메이커", "범주 이름", "비고"); + + const aoa: any[][] = [header]; + + for (const r of rows) { + const lev = Number(r.lev ?? 1); + const row: any[] = []; + for (let i = 1; i <= effectiveMax; i++) row.push(i === lev ? "*" : ""); + row.push( + r.pm_part_no ?? "", + r.pm_part_name ?? "", + r.qty ?? "", + r.p_qty ?? "", + Number(r.cu01_cnt ?? 0) > 0 ? "Y" : "", + Number(r.cu02_cnt ?? 0) > 0 ? "Y" : "", + Number(r.cu03_cnt ?? 0) > 0 ? "Y" : "", + r.material ?? "", + r.heat_treatment_hardness ?? "", + r.heat_treatment_method ?? "", + r.surface_treatment ?? "", + r.maker ?? "", + r.part_type_title ?? "", + r.remark ?? "", + ); + aoa.push(row); + } + + const ws = XLSX.utils.aoa_to_sheet(aoa); + + // 컬럼 너비 (LEVEL 컬럼은 좁게, 텍스트 컬럼은 넓게) + const cols: XLSX.ColInfo[] = []; + for (let i = 0; i < effectiveMax; i++) cols.push({ wch: 4 }); + cols.push( + { wch: 18 }, { wch: 24 }, { wch: 8 }, { wch: 10 }, + { wch: 6 }, { wch: 6 }, { wch: 6 }, + { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 16 }, + ); + ws["!cols"] = cols; + + // 헤더 셀 노란 배경 + bold (XLSX 무료 라이브러리는 스타일 미저장. cellStyles:true 옵션과 함께 쓰면 일부만 동작) + // → 운영판 노란 배경은 시각적 효과. SheetJS Community 빌드는 스타일 무시. + // 필요 시 exceljs로 교체. 본 구현은 데이터 1:1 정확성 우선. + + return ws; +} + +export async function generateAscendingExcel(filter: BomTreeFilter): Promise<{ buffer: Buffer; fileName: string }> { + const { rows, max_level } = await ascendingForExcel(filter); + const ws = buildSheet(rows, max_level); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM 정전개"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { buffer: buf, fileName: `BOM 조회(정전개)_${nowStamp()}.xlsx` }; +} + +export async function generateDescendingExcel(filter: BomTreeFilter): Promise<{ buffer: Buffer; fileName: string }> { + const { rows, max_level } = await descendingForExcel(filter); + const ws = buildSheet(rows, max_level); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM 역전개"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { buffer: buf, fileName: `BOM 조회(역전개)_${nowStamp()}.xlsx` }; +} + +// 호환 export +export type { ExcelDirection }; diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts index e37c0fc0..8ce20a64 100644 --- a/backend-node/src/services/devBomService.ts +++ b/backend-node/src/services/devBomService.ts @@ -274,6 +274,145 @@ export async function ascending(filter: BomTreeFilter) { return { rows: r.rows, max_level }; } +// ─── M4 엑셀 다운로드용 풀 컬럼 (wace structureAscendingListExcel.jsp 1:1) ── +// 그리드 ascending/descending 보다 추가: P_QTY(=bom_part_qty.item_qty), MAKER, PART_TYPE_TITLE, +// HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT +// +// 엑셀 컬럼 (wace 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량(QTY) / 항목수량(P_QTY) / +// 3D / 2D / PDF (Y/공백) / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 + +function buildAscendingExcelSql(filter: BomTreeFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.bom_report_objid) { + conds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + 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[] = []; + if (filter.search_part_no) { + finalConds.push(`UPPER(PM.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++})`); + params.push(`%${filter.search_part_name}%`); + } + const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; + + return { params, startWhere, finalWhere }; +} + +export async function ascendingForExcel(filter: BomTreeFilter) { + const { params, startWhere, finalWhere } = buildAscendingExcelSql(filter, 1); + const sql = ` + 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.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.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.objid AND NOT T.cycle + ) + SELECT T.lev, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + T.qty, T.item_qty AS p_qty, + PM.material, 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, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (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 comm_code CC ON CC.code_id = PM.part_type + ${finalWhere} + ORDER BY T.path + `; + const r = await getPool().query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + +export async function descendingForExcel(filter: BomTreeFilter) { + const params: any[] = []; + const anchorConds: string[] = []; + let idx = 1; + + if (filter.search_part_no) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_no) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_name) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_name}%`); + } + if (filter.bom_report_objid) { + anchorConds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + anchorConds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + if (anchorConds.length === 0) return { rows: [], max_level: 0 }; + const anchorWhere = anchorConds.join(" AND "); + + const sql = ` + 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.item_qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE ${anchorWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + 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.objid = T.parent_objid AND NOT T.cycle + ) + SELECT T.lev, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + T.qty, T.item_qty AS p_qty, + PM.material, 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, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (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 comm_code CC ON CC.code_id = PM.part_type + ORDER BY T.path + `; + const r = await getPool().query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + // ─── M4 역전개 (재귀 CTE — parent 방향) ──────────────────── export async function descending(filter: BomTreeFilter) { 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 6e4bd229..3804d63b 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, + Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, FileSpreadsheet, } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; @@ -28,6 +28,7 @@ export default function EbomSearchPage() { const [rows, setRows] = useState([]); const [maxLevel, setMaxLevel] = useState(0); const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); const runQuery = useCallback(async (dir: Direction) => { setLoading(true); @@ -44,6 +45,25 @@ export default function EbomSearchPage() { } }, [filter]); + // wace structureAscending/DescendingListExcel.jsp 1:1 — 현재 검색 조건 그대로 .xlsx 다운로드 + const downloadExcel = useCallback(async (dir: Direction) => { + setExporting(true); + try { + const fn = dir === "ascending" ? devBomApi.excelAscending : devBomApi.excelDescending; + const { blob, fileName } = await fn(filter); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = fileName; + document.body.appendChild(a); a.click(); + a.remove(); URL.revokeObjectURL(url); + toast.success(`${fileName} 다운로드`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패"); + } finally { + setExporting(false); + } + }, [filter]); + // 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시 const columns: DataGridColumn[] = useMemo(() => { const levelCols: DataGridColumn[] = []; @@ -131,6 +151,14 @@ export default function EbomSearchPage() { : } 역전개 조회 + + {direction === "descending" && ( diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index fc4ba30c..6bd82a6e 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -1,5 +1,19 @@ import { apiClient } from "./client"; +// Content-Disposition 의 filename / filename* 파싱 (UTF-8 인코딩 우선) +function extractFileName(cd: string | undefined): string | null { + if (!cd) return null; + const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(cd); + if (utf8 && utf8[1]) { + try { return decodeURIComponent(utf8[1]); } catch { /* fallthrough */ } + } + const plain = /filename="?([^";]+)"?/i.exec(cd); + if (plain && plain[1]) { + try { return decodeURIComponent(plain[1]); } catch { return plain[1]; } + } + return null; +} + // ============================================================ // 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 // 라우트: /api/development/ebom/*, /api/development/ebom-tree/* @@ -144,6 +158,20 @@ export const devBomApi = { return res.data?.data as BomTreeResponse; }, + // M4 엑셀 다운로드 (정/역전개) — wace 1:1 + async excelAscending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { + const res = await apiClient.get("/development/ebom-tree/ascending/excel", { + params: filter, responseType: "blob", + }); + return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_ascending.xlsx" }; + }, + async excelDescending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { + const res = await apiClient.get("/development/ebom-tree/descending/excel", { + params: filter, responseType: "blob", + }); + return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_descending.xlsx" }; + }, + // Excel Import async excelParse(file: File): Promise { const fd = new FormData(); From 82a253ec6a388bbde9f8be46666f565f145a2348 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 18:18:39 +0900 Subject: [PATCH 07/11] =?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=20=ED=8F=BC=20=E2=80=94=20wace=20partMngF?= =?UTF-8?q?ormPopUp.jsp=201:1=20=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적으로 재발견: 기존 PartFormDialog 는 임의로 "기본정보/크기·형상/분류·단위/Y-N 플래그" 섹션 분리 + 운영판에 없는 필드(두께/너비/높이/외경/내경/길이/수량/단위/후가공/ 공급업체코드/제품OBJID) 추가 + Y/N 라디오로 작성되어 있었음. 운영판과 완전 다른 화면. 운영판 1:1 정정: - 단일 4-col table 레이아웃 (라벨 / input / 라벨 / input) - 필드 22개만 (운영판 그대로): ① 품번 | 품명 ② 재료 | 열처리경도 ③ 열처리방법 | 표면처리 ④ 메이커 | 범주이름* (PART_TYPE, comm_code 0000062) ⑤ 규격 (colspan=3) ⑥ 계정구분*(comm_code 0900213) | 조달구분*(0=구매/1=생산/8=Phantom 하드) ⑦ 재고단위*(comm_code 0001399) | 관리단위*(동일) ⑧ 환산수량*(숫자) | LOT구분*(0=미사용/1=사용) ⑨ 사용여부*(0=미사용/1=사용) | 검사여부*(0=무검사/1=검사) ⑩ SET품여부*(0=부/1=여) | 의뢰여부*(0=부/1=여) ⑪ 개당길이 | 개당소요량 ⑫ 비고 (colspan=3) ⑬ CAD Data : 3D / 2D(Drawing) / 2D(PDF) Drag&Drop placeholder (실제 업로드 기능은 DEV-7 별 PR — UI 영역만 추가) - LOT/USE_YN/QC_FG/SETITEM_FG/REQ_FG : Y/N 라디오 → 운영판 select 옵션 그대로 (4개씩 한글값) - 필수 검증 11개 추가 (wace fn_save 1:1) : 범주이름·계정구분·조달구분·재고단위·관리단위· 환산수량·LOT구분·사용여부·검사여부·SET품여부·의뢰여부 - DialogTitle "품목 등록" / "품목 수정" (운영판 헤더 그대로) + 파란색 헤더 바 - 저장 / 닫기 버튼 중앙 배치 (운영판 plm_btn_wrap_center) PartDetailDialog 도 동일하게 운영판 부적합 — 다음 단계에서 별도 재작성 예정. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/development/PartFormDialog.tsx | 588 +++++++++--------- 1 file changed, 286 insertions(+), 302 deletions(-) diff --git a/frontend/components/development/PartFormDialog.tsx b/frontend/components/development/PartFormDialog.tsx index db2224cf..bb585fd7 100644 --- a/frontend/components/development/PartFormDialog.tsx +++ b/frontend/components/development/PartFormDialog.tsx @@ -1,91 +1,82 @@ "use client"; -// 개발관리 > PART 등록/수정 통합 다이얼로그. -// wace partMngFormPopUp.jsp + partMngDetailPopUp.jsp 1:1 (mode 분기). +// 개발관리 > PART 등록/수정 다이얼로그 — wace partMng/partMngFormPopUp.jsp 1:1 // -// 신규: POST /api/development/part (38 컬럼) -// 수정: PUT /api/development/part/:objid (21 컬럼만 — wace updatePartDetail 1:1) +// 폼 필드 (운영판 그대로, 그 외 추가 없음): +// ① 품번 | 품명 +// ② 재료 | 열처리경도 +// ③ 열처리방법 | 표면처리 +// ④ 메이커 | 범주이름* (PART_TYPE, comm_code 0000062) +// ⑤ 규격 (1행) +// ⑥ 계정구분* (ACCTFG, comm_code 0900213) | 조달구분* (ODRFG, 0/1/8 하드) +// ⑦ 재고단위* (UNIT_DC, comm_code 0001399) | 관리단위* (UNITMANG_DC, 동일) +// ⑧ 환산수량* (UNITCHNG_NB, 숫자) | LOT구분* (0=미사용, 1=사용) +// ⑨ 사용여부* (0=미사용, 1=사용) | 검사여부* (0=무검사, 1=검사) +// ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여) +// ⑪ 개당길이 | 개당소요량 +// ⑫ 비고 (1행) +// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder // -// 그룹: -// ① 기본정보 (필수 ★: part_no, part_name, part_type) -// ② 크기/형상 -// ③ 분류/단위 (comm_code SmartSelect) -// ④ Y/N 플래그 (radio '1'/'0') +// 신규: POST /api/development/part (운영 폼 22컬럼) +// 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1) -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Loader2, Save } from "lucide-react"; +import { Loader2, Save, Upload } 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"; -// comm_code 그룹 ID (vexplor_rps DB 실재 그룹) -const GROUP_PART_TYPE = "0000062"; // PART TYPE (조립품/부품/구매품) -const GROUP_UNIT = "0001399"; // 단위 (m/Set/EA/식/BAG/kg/...) -const GROUP_ACCTFG = "0900213"; // 파트_계정구분 (원자재/제품/...) +// comm_code group ids (vexplor_rps DB) +const GROUP_PART_TYPE = "0000062"; +const GROUP_UNIT_DC = "0001399"; +const GROUP_ACCTFG = "0900213"; -// ODRFG: spec '0=구매/1=생산/8=Phantom' — 하드코딩 -const ODRFG_OPTIONS = [ - { code: "0", label: "구매" }, - { code: "1", label: "생산" }, - { code: "8", label: "Phantom" }, -]; +// 운영 1:1 하드코딩 옵션 +const OPT_ODRFG = [{ v: "0", t: "구매" }, { v: "1", t: "생산" }, { v: "8", t: "Phantom" }]; +const OPT_LOT_FG = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }]; +const OPT_USE_YN = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }]; +const OPT_QC_FG = [{ v: "0", t: "무검사" }, { v: "1", t: "검사" }]; +const OPT_YESNO = [{ v: "0", t: "부" }, { v: "1", t: "여" }]; interface FormState { - // 기본 part_no: string; part_name: string; - part_type: string; - unit: string; - qty: string; - spec: string; material: string; - remark: string; + heat_treatment_hardness: string; + heat_treatment_method: string; + surface_treatment: string; maker: string; - // 크기/형상 - thickness: string; - width: string; - height: string; - out_diameter: string; - in_diameter: string; - length: string; - // 분류/단위 + part_type: string; + spec: string; acctfg: string; odrfg: string; unit_dc: string; unitmang_dc: string; unitchng_nb: string; - unit_length: string; - unit_qty: string; - // 열처리/표면처리/후가공 - heat_treatment_hardness: string; - heat_treatment_method: string; - surface_treatment: string; - post_processing: string; - // 부속 (신규 시만 표시) - product_mgmt_objid: string; - supply_code: string; - contract_objid: string; - // Y/N lot_fg: string; use_yn: string; qc_fg: string; setitem_fg: string; req_fg: string; + unit_length: string; + unit_qty: string; + remark: string; } const EMPTY_FORM: FormState = { - part_no: "", part_name: "", part_type: "", unit: "", qty: "", spec: "", material: "", remark: "", maker: "", - thickness: "", width: "", height: "", out_diameter: "", in_diameter: "", length: "", - acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "", unit_length: "", unit_qty: "", - heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "", post_processing: "", - product_mgmt_objid: "", supply_code: "", contract_objid: "", - lot_fg: "1", use_yn: "1", qc_fg: "0", setitem_fg: "0", req_fg: "0", + part_no: "", part_name: "", + material: "", heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "", + maker: "", part_type: "", spec: "", + acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "", + lot_fg: "", use_yn: "", qc_fg: "", setitem_fg: "", req_fg: "", + unit_length: "", unit_qty: "", remark: "", }; interface Props { @@ -108,14 +99,10 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: [] ); - // 초기화/로드 useEffect(() => { if (!open) return; - if (isEdit && editObjid) { - loadDetail(editObjid); - } else { - setForm(EMPTY_FORM); - } + if (isEdit && editObjid) loadDetail(editObjid); + else setForm(EMPTY_FORM); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -137,10 +124,27 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: } }; + // wace fn_save 1:1 — 모든 required 검증 + const validate = (): string | null => { + if (!form.part_no.trim()) return "품번은 필수입니다."; + if (!form.part_name.trim()) return "품명은 필수입니다."; + if (!form.part_type) return "범주이름은 필수입니다."; + if (!form.acctfg) return "계정구분은 필수입니다."; + if (!form.odrfg) return "조달구분은 필수입니다."; + if (!form.unit_dc) return "재고단위는 필수입니다."; + if (!form.unitmang_dc) return "관리단위는 필수입니다."; + if (!form.unitchng_nb) return "환산수량은 필수입니다."; + if (!form.lot_fg) return "LOT구분은 필수입니다."; + if (!form.use_yn) return "사용여부는 필수입니다."; + if (!form.qc_fg) return "검사여부는 필수입니다."; + if (!form.setitem_fg) return "SET품여부는 필수입니다."; + if (!form.req_fg) return "의뢰여부는 필수입니다."; + return null; + }; + const handleSave = async () => { - if (!form.part_no.trim()) return toast.error("품번은 필수입니다."); - if (!form.part_name.trim()) return toast.error("품명은 필수입니다."); - if (!isEdit && !form.part_type.trim()) return toast.error("범주(PART TYPE)는 필수입니다."); + const err = validate(); + if (err) return toast.error(err); setSaving(true); try { @@ -175,25 +179,12 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: part_no: form.part_no, part_name: form.part_name, part_type: form.part_type, - unit: form.unit, - qty: form.qty, - spec: form.spec, material: form.material, - thickness: form.thickness, - width: form.width, - height: form.height, - out_diameter: form.out_diameter, - in_diameter: form.in_diameter, - length: form.length, - remark: form.remark, - product_mgmt_objid: form.product_mgmt_objid, - supply_code: form.supply_code, - maker: form.maker, - contract_objid: form.contract_objid, - post_processing: form.post_processing, heat_treatment_hardness: form.heat_treatment_hardness, heat_treatment_method: form.heat_treatment_method, surface_treatment: form.surface_treatment, + maker: form.maker, + spec: form.spec, acctfg: form.acctfg, odrfg: form.odrfg, unit_dc: form.unit_dc, @@ -206,6 +197,7 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: req_fg: form.req_fg, unit_length: form.unit_length, unit_qty: form.unit_qty, + remark: form.remark, }; await devPartApi.create(body); toast.success("PART가 등록되었습니다."); @@ -219,13 +211,13 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: } }; - const titleText = isEdit ? "PART 수정" : "PART 신규 등록"; + const titleText = isEdit ? "품목 수정" : "품목 등록"; return ( - - - {titleText} + + + {titleText} {loading ? ( @@ -233,179 +225,182 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: ) : ( -
- {/* ① 기본정보 */} -
- - - setField("part_no", e.target.value)} /> - - - setField("part_name", e.target.value)} /> - - - setField("part_type", v)} /> - - - - - setField("unit", v)} /> - - - setField("qty", e.target.value)} /> - - - setField("spec", e.target.value)} /> - - - - - setField("material", e.target.value)} /> - - - setField("maker", e.target.value)} /> - - - setField("remark", e.target.value)} /> - - -
+
+ + + + + + + + + {/* ① */} + + + + + + - {/* ② 크기/형상 */} -
- - setField("thickness", e.target.value)} /> - setField("width", e.target.value)} /> - setField("height", e.target.value)} /> - - - setField("out_diameter", e.target.value)} /> - setField("in_diameter", e.target.value)} /> - setField("length", e.target.value)} /> - -
+ {/* ② */} + + + + + + - {/* ③ 분류 / 단위 */} -
- - - setField("acctfg", v)} /> - - - - - - setField("unitchng_nb", e.target.value)} /> - - - - - setField("unit_dc", v)} /> - - - setField("unitmang_dc", v)} /> - - -
- setField("unit_length", e.target.value)} /> - / - setField("unit_qty", e.target.value)} /> -
-
-
- - - setField("heat_treatment_hardness", e.target.value)} /> - - - setField("heat_treatment_method", e.target.value)} /> - - - setField("surface_treatment", e.target.value)} /> - - - - - setField("post_processing", e.target.value)} /> - - {!isEdit && ( - - setField("supply_code", e.target.value)} - placeholder="admin_supply_mng.objid" /> - - )} - {!isEdit && ( - - setField("product_mgmt_objid", e.target.value)} - placeholder="product_mgmt.objid" /> - - )} - -
+ {/* ③ */} + + + + + + - {/* ④ Y/N 플래그 */} -
- - setField("lot_fg", v)} /> - setField("use_yn", v)} /> - setField("qc_fg", v)} /> - - - setField("setitem_fg", v)} /> - setField("req_fg", v)} /> - - -
+ {/* ④ */} + + + + + + + + {/* ⑤ 규격 (colspan=3) */} + + + + + + {/* ⑥ */} + + + + + + + + {/* ⑦ */} + + + + + + + + {/* ⑧ */} + + + + + + + + {/* ⑨ */} + + + + + + + + {/* ⑩ */} + + + + + + + + {/* ⑪ */} + + + + + + + + {/* ⑫ 비고 (colspan=3) */} + + + + + + {/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */} + + + + + + + + + + + + + + +
품번 setField("part_no", e.target.value)} />품명 setField("part_name", e.target.value)} />
재료 setField("material", e.target.value)} />열처리경도 setField("heat_treatment_hardness", e.target.value)} />
열처리방법 setField("heat_treatment_method", e.target.value)} />표면처리 setField("surface_treatment", e.target.value)} />
메이커 setField("maker", e.target.value)} />범주이름 + setField("part_type", v)} /> +
규격 setField("spec", e.target.value)} />
계정구분 + setField("acctfg", v)} /> + 조달구분 setField("odrfg", v)} />
재고단위 + setField("unit_dc", v)} /> + 관리단위 + setField("unitmang_dc", v)} /> +
환산수량 setField("unitchng_nb", e.target.value.replace(/[^0-9.]/g, ""))} />LOT구분 setField("lot_fg", v)} />
사용여부 setField("use_yn", v)} />검사여부 setField("qc_fg", v)} />
SET품여부 setField("setitem_fg", v)} />의뢰여부 setField("req_fg", v)} />
개당길이 setField("unit_length", e.target.value.replace(/[^0-9.]/g, ""))} />개당소요량 setField("unit_qty", e.target.value.replace(/[^0-9.]/g, ""))} />
비고 setField("remark", e.target.value)} />
+ CAD Data + 3D + +
2D(Drawing) + +
2D(PDF) + +
+ +
+ CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. +
)} - - + + @@ -415,85 +410,74 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: // ─── 보조 컴포넌트 ────────────────────────────────────────── -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
-
{children}
-
- ); +function Tr({ children }: { children: React.ReactNode }) { + return {children}; } - -function Row({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function Field({ label, required, children }: { label: string; required?: boolean; children?: React.ReactNode }) { +function Th({ children }: { children: React.ReactNode }) { return ( -
- {label && ( - - )} + {children} -
+ ); } - -function YNRadio({ value, onChange }: { value: string; onChange: (v: string) => void }) { +function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) { + return {children}; +} +function Req() { + return *; +} +function BasicSelect({ + value, options, onChange, +}: { + value: string; + options: { v: string; t: string }[]; + onChange: (v: string) => void; +}) { return ( -
- - + + ); +} +function DropPlaceholder({ label }: { label: string }) { + return ( +
+ + Drag & Drop Files Here ({label})
); } -// ─── PartRow → FormState 매핑 ────────────────────────────── +// ─── PartRow → FormState ──────────────────────────────────── function rowToForm(r: PartRow): FormState { return { part_no: r.part_no ?? "", part_name: r.part_name ?? "", - part_type: r.part_type ?? "", - unit: r.unit ?? "", - qty: r.qty ?? "", - spec: r.spec ?? "", material: r.material ?? "", - remark: r.remark ?? "", + heat_treatment_hardness: r.heat_treatment_hardness ?? "", + heat_treatment_method: r.heat_treatment_method ?? "", + surface_treatment: r.surface_treatment ?? "", maker: r.maker ?? "", - thickness: r.thickness ?? "", - width: r.width ?? "", - height: r.height ?? "", - out_diameter: r.out_diameter ?? "", - in_diameter: r.in_diameter ?? "", - length: r.length ?? "", + part_type: r.part_type ?? "", + spec: r.spec ?? "", acctfg: r.acctfg ?? "", odrfg: r.odrfg ?? "", unit_dc: r.unit_dc ?? "", unitmang_dc: r.unitmang_dc ?? "", unitchng_nb: r.unitchng_nb != null ? String(r.unitchng_nb) : "", + lot_fg: r.lot_fg ?? "", + use_yn: r.use_yn ?? "", + qc_fg: r.qc_fg ?? "", + setitem_fg: r.setitem_fg ?? "", + req_fg: r.req_fg ?? "", unit_length: r.unit_length ?? "", unit_qty: r.unit_qty ?? "", - heat_treatment_hardness: r.heat_treatment_hardness ?? "", - heat_treatment_method: r.heat_treatment_method ?? "", - surface_treatment: r.surface_treatment ?? "", - post_processing: r.post_processing ?? "", - product_mgmt_objid: r.product_mgmt_objid ?? "", - supply_code: r.supply_code ?? "", - contract_objid: r.contract_objid ?? "", - lot_fg: r.lot_fg ?? "1", - use_yn: r.use_yn ?? "1", - qc_fg: r.qc_fg ?? "0", - setitem_fg: r.setitem_fg ?? "0", - req_fg: r.req_fg ?? "0", + remark: r.remark ?? "", }; } From 5662fd291083165c6c5243a8edda3584c6a73cdf Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 18:32:23 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>PART?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=E2=80=94=20wace=20partMngDetailPopUp.j?= =?UTF-8?q?sp=201:1=20=EC=9E=AC=EC=9E=91=EC=84=B1=20+=20colgroup=20?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PartDetailDialog 재작성 (운영판 부적합 → 정정): - 기존: 임의 섹션 분리("기본정보/크기·형상/분류·단위/Y-N 플래그") + 운영판 없는 필드 (두께/너비/높이/외경/내경/길이/단위/수량/후가공/공급업체) 노출 - 정정: PartFormDialog 와 동일 레이아웃을 readonly 박스(Ro)로 표시 · 운영판 22필드 그대로 (운영판 없는 9필드 제거) · 부속 행 추가 (운영 detail 만 표시) : EO No / EO Date / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION) · CAD Data 3행 : 3D / 2D(Drawing) / 2D(PDF) — 첨부 카운트 표시 (실제 파일 다운로드는 DEV-7 별 PR) · 헤더: 파란색 바 "품목 상세" (운영판 헤더 1:1) · 코드값 → 라벨 매핑 : ODRFG (구매/생산/Phantom), LOT_FG (미사용/사용), USE_YN (미사용/사용), QC_FG (무검사/검사), SETITEM/REQ (부/여) colgroup 비율 정정 (Form + Detail 공통): - 기존: 4컬럼 단순 (라벨 110px / input / 라벨 110px / input) → 첫 input 25% 좁음 - 정정: 운영판 JSP colgroup 1:1 (12% / 12% / 25% / 12% / *) + 매 행 첫 Td 에 colSpan={2} · 결과 input1 ≈ 37%, input2 ≈ 39% — 양쪽 input 비율 거의 동일 (운영판 일치) · 규격/비고 행 colSpan={4} (운영 JSP colspan=4) · CAD Data 행 colSpan={3} (작은 라벨 1칸 + 컨텐츠 3칸) · Th 의 고정 너비 w-[110px] 제거 — colgroup 이 폭 결정 · table-fixed 추가로 colgroup 비율 강제 적용 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../development/PartDetailDialog.tsx | 252 +++++++++++------- .../components/development/PartFormDialog.tsx | 49 ++-- 2 files changed, 176 insertions(+), 125 deletions(-) diff --git a/frontend/components/development/PartDetailDialog.tsx b/frontend/components/development/PartDetailDialog.tsx index 6b9122cb..720d83af 100644 --- a/frontend/components/development/PartDetailDialog.tsx +++ b/frontend/components/development/PartDetailDialog.tsx @@ -1,24 +1,35 @@ "use client"; -// 개발관리 > PART 상세 조회 다이얼로그 (read-only). -// 행 더블클릭 진입. "수정" 버튼 클릭 시 PartFormDialog(mode='edit')로 전환은 -// 호출 페이지가 dispatch (open=false → 부모가 form dialog 오픈). +// 개발관리 > PART 상세 다이얼로그 — wace partMng/partMngDetailPopUp.jsp 1:1 +// +// 운영판은 form 과 동일 화면을 disabled 로 표시 후 "수정" 클릭 시 활성화. +// RPS 에서는 PartFormDialog 와 분리 유지 (호환). 본 다이얼로그는 Form 레이아웃 readonly + +// 부속 정보 행 추가 (EO_NO / EO_DATE / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION)) + +// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) 표시. +// +// "수정" 버튼: 부모가 본 다이얼로그를 닫고 PartFormDialog(mode='edit') 오픈하도록 onEdit 콜백 호출. import React, { useEffect, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Loader2, Pencil } from "lucide-react"; +import { Loader2, Pencil, FileText } from "lucide-react"; import { toast } from "sonner"; import { devPartApi, PartRow } from "@/lib/api/devPart"; +import { cn } from "@/lib/utils"; + +const LABEL_ODRFG: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; +const LABEL_LOT_FG: Record = { "0": "미사용", "1": "사용" }; +const LABEL_USE_YN: Record = { "0": "미사용", "1": "사용" }; +const LABEL_QC_FG: Record = { "0": "무검사", "1": "검사" }; +const LABEL_YESNO: Record = { "0": "부", "1": "여" }; interface Props { open: boolean; onOpenChange: (open: boolean) => void; objid: string | null; - /** "수정" 버튼 클릭 시 호출 — 부모는 본 다이얼로그 닫고 PartFormDialog(mode='edit')를 띄움 */ + /** "수정" 버튼 클릭 시 호출 — 부모는 본 다이얼로그 닫고 PartFormDialog(mode='edit') 오픈 */ onEdit?: (objid: string) => void; } @@ -44,9 +55,9 @@ export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) { return ( - - - PART 상세 정보 + + + 품목 상세 {loading || !row ? ( @@ -54,89 +65,107 @@ export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) {
) : ( -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
+
+ {/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
- - - - - - - - - - -
+ {/* 부속 — wace detail 만 표시 */} + + + + + + + + -
- - - - - - - - - - - - - - - - - - - - -
+ {/* CAD Data */} + + + + + + + + + + + + + + +
품번{row.part_no}품명{row.part_name}
재료{row.material}열처리경도{row.heat_treatment_hardness}
열처리방법{row.heat_treatment_method}표면처리{row.surface_treatment}
메이커{row.maker}범주 이름{row.part_type_title}
규격{row.spec}
계정구분{row.acctfg_nm}조달구분{LABEL_ODRFG[row.odrfg ?? ""] ?? row.odrfg_nm ?? ""}
재고단위{row.unit_dc_nm}관리단위{row.unitmang_dc_nm}
환산수량{row.unitchng_nb != null && row.unitchng_nb !== "" ? String(row.unitchng_nb) : ""}LOT구분{LABEL_LOT_FG[row.lot_fg ?? ""] ?? row.lot_fg_nm ?? ""}
사용여부{LABEL_USE_YN[row.use_yn ?? ""] ?? row.use_yn_nm ?? ""}검사여부{LABEL_QC_FG[row.qc_fg ?? ""] ?? row.qc_fg_nm ?? ""}
SET품여부{LABEL_YESNO[row.setitem_fg ?? ""] ?? row.setitem_fg_nm ?? ""}의뢰여부{LABEL_YESNO[row.req_fg ?? ""] ?? row.req_fg_nm ?? ""}
개당길이{row.unit_length}개당소요량{row.unit_qty}
비고{row.remark}
EO No{row.eo_no}EO Date{row.eo_date}
EO구분{row.change_type}EO사유{row.change_option_name ?? row.change_option}
+ CAD Data + 3D + +
2D(Drawing) + +
2D(PDF) + +
-
- - - - - - - - - - -
+
+ CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. +
)} - + {row && onEdit && ( + + +
+ ); +} + +function MetaRow({ label, value }: { label: string; value: any }) { + return ( +
+ {label} + {value != null && value !== "" ? value : "—"} +
+ ); +} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 6bd82a6e..6da36456 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -124,6 +124,30 @@ export interface BomTreeResponse { max_level: number; } +// 트리 풀 컬럼 (ascendingForExcel 1:1) — BomReportTreeDialog 용 +export interface BomTreeFullRow { + lev: number | string; + pm_part_no: string | null; + pm_part_name: string | null; + qty: string | number | null; + p_qty: string | number | null; + material: string | null; + remark: string | 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; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; +} +export interface BomTreeFullResponse { + rows: BomTreeFullRow[]; + max_level: number; +} + // ─── API ───────────────────────────────────────────────── export const devBomApi = { @@ -158,6 +182,12 @@ export const devBomApi = { return res.data?.data as BomTreeResponse; }, + // E-BOM 트리 (풀 컬럼) — M3 그리드 행 클릭 → BomReportTreeDialog + async treeFull(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/full", { params: filter }); + return res.data?.data as BomTreeFullResponse; + }, + // M4 엑셀 다운로드 (정/역전개) — wace 1:1 async excelAscending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { const res = await apiClient.get("/development/ebom-tree/ascending/excel", {
@@ -266,17 +285,18 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init {rows.length > 0 && ( - )} @@ -285,6 +305,7 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
{fileName && {fileName}} + {encoding && 인코딩: {encoding}} 총 {rows.length}건 {errorCount > 0 && 에러 {errorCount}건}
@@ -303,7 +324,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init >
- Drag & Drop 또는 클릭하여 BOM 엑셀 템플릿 업로드 (.xlsx, .xls) + Drag & Drop 또는 클릭하여 CSV 템플릿 업로드 (.csv) +
+
+ 컬럼: 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체 / 범주이름