개발관리>PART 등록·조회 메뉴 신설 (PR-A) — wace partMng 1:1 이식

backend (M1+M2):
- devPartService: listTemp/listRelease/getByObjid/create/update/deploy/removeMany
- partMngBaseSimple SELECT + 추가 15컬럼(acctfg/odrfg/unit_dc/unitmang_dc/lot_fg 등) 라벨/CASE
- deploy 트랜잭션 3단계 (isLastInit → part_mng_history INSERT → partMngDeploy + EO_NO 채번)
- EO_NO 분기: is_longd='1'→EOB{yy}-{seq} / else EO{yy}-{seq}
- objidUtil: wace CommonUtils.createObjId() 1:1 (bigint objid 채번)
- DDL: 9 신규 테이블 + part_mng 15컬럼 ALTER (운영판 1:1 추출)

frontend (M1+M2):
- part-regist (M1) / part-search (M2): 23셀 그리드 + 검색폼 + 액션
- PartFormDialog: 등록/수정 통합 (mode prop, 4 섹션)
- PartDetailDialog: 읽기 전용 + "수정" dispatch
- AdminPageRenderer dynamic 임포트 2건 + menu_info URL spec 정렬

본 PR 제외 (별 PR): 도면 다중 업로드, ERP 업로드, Excel Import, BOM_PART_QTY R/W

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-12 16:14:10 +09:00
parent d8d9ad3bcc
commit ea6606da0c
16 changed files with 3155 additions and 0 deletions
+2
View File
@@ -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); // 차량 운행 이력 관리
@@ -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<string, any>): 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<string, any>));
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<string, any>));
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 });
}
}
+28
View File
@@ -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;
+513
View File
@@ -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<PartListFilter>,
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<string> {
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<number> {
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<string, string> }> {
if (!objids || objids.length === 0) return { deployed: 0, eo_nos: {} };
const eoNos: Record<string, string> = {};
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<number> {
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;
}
@@ -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
`;
+24
View File
@@ -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, "")));
}
+197
View File
@@ -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
+313
View File
@@ -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 <partMngBaseSimple> 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 <partMngBaseSimple> 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 <partMngBaseSimple> 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<objid, eo_no> }`
### 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 우선 `<Input>` 2개로 시작, 추후 보강)
### 4.3 액션 버튼 (각 page 상단)
**M1**: 등록 · 수정 · 삭제 · 확정 · 조회
**M2**: 등록 · 수정 · 삭제 · 조회 (도면연동/ERP업로드/Excel은 본 PR 제외)
### 4.4 PartFormDialog (신규/수정 통합)
- mode: `'create' | 'edit'`
- 38 필드 — `<Input>` + `<CommCodeSelect>` 조합
- 검증: 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)
@@ -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
-- ============================================================
@@ -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;
@@ -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<PartRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PartListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 다이얼로그 상태
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [formObjid, setFormObjid] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailObjid, setDetailObjid] = useState<string | null>(null);
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
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 (
<div className="flex h-full flex-col">
{/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */}
<div className="border-b bg-card px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[200px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<Input
value={filter.search_part_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
placeholder="품번 LIKE"
/>
</div>
<div className="min-w-[200px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<Input
value={filter.search_part_name ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
placeholder="품명 LIKE"
/>
</div>
<div className="ml-auto flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" variant="default" onClick={handleCreate}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="secondary" onClick={handleEdit}
disabled={checkedIds.length !== 1}>
<Pencil className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={handleDeploy}
disabled={checkedIds.length === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white">
<CheckSquare className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{total.toLocaleString()} (M1: status 'release')
</div>
</div>
<div className="min-h-0 flex-1 p-2">
<DataGrid
columns={columns}
data={rows}
loading={loading}
showRowNumber
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage="조건에 맞는 PART가 없습니다."
gridId="development-part-regist"
/>
</div>
<PartFormDialog
open={formOpen}
onOpenChange={setFormOpen}
mode={formMode}
editObjid={formObjid}
onSaved={fetchList}
/>
<PartDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
objid={detailObjid}
onEdit={handleEditFromDetail}
/>
</div>
);
}
@@ -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<PartRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PartListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [formObjid, setFormObjid] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailObjid, setDetailObjid] = useState<string | null>(null);
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
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 (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[200px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<Input
value={filter.search_part_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
placeholder="품번 LIKE"
/>
</div>
<div className="min-w-[200px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<Input
value={filter.search_part_name ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
placeholder="품명 LIKE"
/>
</div>
<div className="ml-auto flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" variant="default" onClick={handleCreate}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="secondary" onClick={handleEdit}
disabled={checkedIds.length !== 1}>
<Pencil className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{total.toLocaleString()} (M2: status = 'release')
</div>
</div>
<div className="min-h-0 flex-1 p-2">
<DataGrid
columns={columns}
data={rows}
loading={loading}
showRowNumber
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage="조건에 맞는 PART가 없습니다."
gridId="development-part-search"
/>
</div>
<PartFormDialog
open={formOpen}
onOpenChange={setFormOpen}
mode={formMode}
editObjid={formObjid}
onSaved={fetchList}
/>
<PartDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
objid={detailObjid}
onEdit={handleEditFromDetail}
/>
</div>
);
}
@@ -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<PartRow | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>PART </DialogTitle>
</DialogHeader>
{loading || !row ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-2 text-sm">
<Section title="기본정보">
<Row>
<V label="품번" value={row.part_no} />
<V label="품명" value={row.part_name} />
<V label="범주" value={row.part_type_title} />
</Row>
<Row>
<V label="단위" value={row.unit_title} />
<V label="수량" value={row.qty} align="right" />
<V label="규격" value={row.spec} />
</Row>
<Row>
<V label="재료" value={row.material} />
<V label="메이커" value={row.maker} />
<V label="비고" value={row.remark} />
</Row>
<Row>
<V label="공급업체" value={row.supply_name} />
<V label="등록자" value={row.writer} />
<V label="등록일" value={row.part_regdate_title} />
</Row>
<Row>
<V label="REVISION" value={row.revision} />
<V label="EO_NO" value={row.eo_no} />
<V label="STATUS" value={row.status} />
</Row>
</Section>
<Section title="크기 / 형상">
<Row>
<V label="두께" value={row.thickness} align="right" />
<V label="너비(W)" value={row.width} align="right" />
<V label="높이(H)" value={row.height} align="right" />
</Row>
<Row>
<V label="외경" value={row.out_diameter} align="right" />
<V label="내경" value={row.in_diameter} align="right" />
<V label="길이(L)" value={row.length} align="right" />
</Row>
</Section>
<Section title="분류 / 단위">
<Row>
<V label="계정구분" value={row.acctfg_nm} />
<V label="조달구분" value={row.odrfg_nm} />
<V label="환산수량" value={row.unitchng_nb != null ? String(row.unitchng_nb) : ""} align="right" />
</Row>
<Row>
<V label="재고단위" value={row.unit_dc_nm} />
<V label="관리단위" value={row.unitmang_dc_nm} />
<V label="개당길이 / 개당수량"
value={[row.unit_length, row.unit_qty].filter(Boolean).join(" / ")} align="right" />
</Row>
<Row>
<V label="열처리경도" value={row.heat_treatment_hardness} />
<V label="열처리방법" value={row.heat_treatment_method} />
<V label="표면처리" value={row.surface_treatment} />
</Row>
<Row>
<V label="후가공" value={row.post_processing} />
<V label="첨부 (3D/2D/PDF)"
value={`${row.cu01_cnt ?? 0} / ${row.cu02_cnt ?? 0} / ${row.cu03_cnt ?? 0}`} align="center" />
<V label="" value="" />
</Row>
</Section>
<Section title="Y/N 플래그">
<Row>
<V label="LOT구분" value={row.lot_fg_nm} align="center" />
<V label="사용여부" value={row.use_yn_nm} align="center" />
<V label="검사여부" value={row.qc_fg_nm} align="center" />
</Row>
<Row>
<V label="SET품여부" value={row.setitem_fg_nm} align="center" />
<V label="의뢰여부" value={row.req_fg_nm} align="center" />
<V label="" value="" />
</Row>
</Section>
</div>
)}
<DialogFooter>
{row && onEdit && (
<Button onClick={() => onEdit(row.objid)}>
<Pencil className="h-4 w-4" /><span className="ml-1"></span>
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 보조 ─────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border bg-card p-3">
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
<div className="space-y-2">{children}</div>
</div>
);
}
function Row({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-3 gap-3">{children}</div>;
}
function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) {
const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : "";
return (
<div>
{label && <Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>}
<div className={`min-h-9 rounded-md border bg-muted/30 px-2 py-2 ${cls}`}>
{value != null && value !== "" ? value : <span className="text-muted-foreground"></span>}
</div>
</div>
);
}
@@ -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<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const setField = useCallback(
<K extends keyof FormState>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{titleText}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-2">
{/* ① 기본정보 */}
<Section title="기본정보">
<Row>
<Field label="품번" required>
<Input value={form.part_no} disabled={isEdit}
onChange={(e) => setField("part_no", e.target.value)} />
</Field>
<Field label="품명" required>
<Input value={form.part_name}
onChange={(e) => setField("part_name", e.target.value)} />
</Field>
<Field label="범주(PART TYPE)" required>
<CommCodeSelect groupId={GROUP_PART_TYPE} withAll={false}
value={form.part_type}
onValueChange={(v) => setField("part_type", v)} />
</Field>
</Row>
<Row>
<Field label="단위">
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
value={form.unit}
onValueChange={(v) => setField("unit", v)} />
</Field>
<Field label="수량">
<Input value={form.qty} className="text-right"
onChange={(e) => setField("qty", e.target.value)} />
</Field>
<Field label="규격">
<Input value={form.spec}
onChange={(e) => setField("spec", e.target.value)} />
</Field>
</Row>
<Row>
<Field label="재료">
<Input value={form.material}
onChange={(e) => setField("material", e.target.value)} />
</Field>
<Field label="메이커">
<Input value={form.maker}
onChange={(e) => setField("maker", e.target.value)} />
</Field>
<Field label="비고">
<Input value={form.remark}
onChange={(e) => setField("remark", e.target.value)} />
</Field>
</Row>
</Section>
{/* ② 크기/형상 */}
<Section title="크기 / 형상">
<Row>
<Field label="두께"><Input value={form.thickness} className="text-right"
onChange={(e) => setField("thickness", e.target.value)} /></Field>
<Field label="너비(W)"><Input value={form.width} className="text-right"
onChange={(e) => setField("width", e.target.value)} /></Field>
<Field label="높이(H)"><Input value={form.height} className="text-right"
onChange={(e) => setField("height", e.target.value)} /></Field>
</Row>
<Row>
<Field label="외경"><Input value={form.out_diameter} className="text-right"
onChange={(e) => setField("out_diameter", e.target.value)} /></Field>
<Field label="내경"><Input value={form.in_diameter} className="text-right"
onChange={(e) => setField("in_diameter", e.target.value)} /></Field>
<Field label="길이(L)"><Input value={form.length} className="text-right"
onChange={(e) => setField("length", e.target.value)} /></Field>
</Row>
</Section>
{/* ③ 분류 / 단위 */}
<Section title="분류 / 단위">
<Row>
<Field label="계정구분">
<CommCodeSelect groupId={GROUP_ACCTFG} withAll={false}
value={form.acctfg}
onValueChange={(v) => setField("acctfg", v)} />
</Field>
<Field label="조달구분">
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={form.odrfg}
onChange={(e) => setField("odrfg", e.target.value)}>
<option value=""></option>
{ODRFG_OPTIONS.map((o) =>
<option key={o.code} value={o.code}>{o.label}</option>)}
</select>
</Field>
<Field label="환산수량(UNITCHNG_NB)">
<Input value={form.unitchng_nb} className="text-right"
onChange={(e) => setField("unitchng_nb", e.target.value)} />
</Field>
</Row>
<Row>
<Field label="재고단위">
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
value={form.unit_dc}
onValueChange={(v) => setField("unit_dc", v)} />
</Field>
<Field label="관리단위">
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
value={form.unitmang_dc}
onValueChange={(v) => setField("unitmang_dc", v)} />
</Field>
<Field label="개당길이 / 개당수량">
<div className="flex items-center gap-1">
<Input value={form.unit_length} className="text-right"
placeholder="개당길이"
onChange={(e) => setField("unit_length", e.target.value)} />
<span className="text-xs text-muted-foreground">/</span>
<Input value={form.unit_qty} className="text-right"
placeholder="개당수량"
onChange={(e) => setField("unit_qty", e.target.value)} />
</div>
</Field>
</Row>
<Row>
<Field label="열처리경도">
<Input value={form.heat_treatment_hardness}
onChange={(e) => setField("heat_treatment_hardness", e.target.value)} />
</Field>
<Field label="열처리방법">
<Input value={form.heat_treatment_method}
onChange={(e) => setField("heat_treatment_method", e.target.value)} />
</Field>
<Field label="표면처리">
<Input value={form.surface_treatment}
onChange={(e) => setField("surface_treatment", e.target.value)} />
</Field>
</Row>
<Row>
<Field label="후가공">
<Input value={form.post_processing}
onChange={(e) => setField("post_processing", e.target.value)} />
</Field>
{!isEdit && (
<Field label="공급업체 코드">
<Input value={form.supply_code}
onChange={(e) => setField("supply_code", e.target.value)}
placeholder="admin_supply_mng.objid" />
</Field>
)}
{!isEdit && (
<Field label="제품 OBJID">
<Input value={form.product_mgmt_objid}
onChange={(e) => setField("product_mgmt_objid", e.target.value)}
placeholder="product_mgmt.objid" />
</Field>
)}
</Row>
</Section>
{/* ④ Y/N 플래그 */}
<Section title="Y/N 플래그">
<Row>
<Field label="LOT구분"><YNRadio value={form.lot_fg} onChange={(v) => setField("lot_fg", v)} /></Field>
<Field label="사용여부"><YNRadio value={form.use_yn} onChange={(v) => setField("use_yn", v)} /></Field>
<Field label="검사여부"><YNRadio value={form.qc_fg} onChange={(v) => setField("qc_fg", v)} /></Field>
</Row>
<Row>
<Field label="SET품여부"><YNRadio value={form.setitem_fg} onChange={(v) => setField("setitem_fg", v)} /></Field>
<Field label="의뢰여부"><YNRadio value={form.req_fg} onChange={(v) => setField("req_fg", v)} /></Field>
<Field label="" />
</Row>
</Section>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<span className="ml-1">{isEdit ? "수정" : "등록"}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 보조 컴포넌트 ──────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border bg-card p-3">
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
<div className="space-y-2">{children}</div>
</div>
);
}
function Row({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-3 gap-3">{children}</div>;
}
function Field({ label, required, children }: { label: string; required?: boolean; children?: React.ReactNode }) {
return (
<div>
{label && (
<Label className="mb-1 block text-xs text-muted-foreground">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</Label>
)}
{children}
</div>
);
}
function YNRadio({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<div className="flex h-9 items-center gap-3 rounded-md border bg-background px-3 text-sm">
<label className="flex items-center gap-1">
<input type="radio" checked={value === "1"} onChange={() => onChange("1")} />
<span></span>
</label>
<label className="flex items-center gap-1">
<input type="radio" checked={value === "0"} onChange={() => onChange("0")} />
<span></span>
</label>
</div>
);
}
// ─── 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",
};
}
@@ -107,6 +107,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/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 }),
+249
View File
@@ -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<string, string>;
}
// ─── API ────────────────────────────────────────────────────
export const devPartApi = {
// M1 그리드
async listTemp(filter: PartListFilter = {}): Promise<PartListResponse> {
const res = await apiClient.get("/development/part-temp/list", { params: filter });
return res.data?.data as PartListResponse;
},
// M2 그리드
async list(filter: PartListFilter = {}): Promise<PartListResponse> {
const res = await apiClient.get("/development/part/list", { params: filter });
return res.data?.data as PartListResponse;
},
// 단건 상세
async detail(objid: string): Promise<PartRow | null> {
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<DeployResult> {
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;
},
};