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; + }, +};