From 7e7c6a0ac03d7ece5afa10d8f9e39470ccc84763 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 10:04:37 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=98=81=EC=97=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9A=94=EC=B2=AD=EC=84=9C=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=C2=B7=ED=92=88=EC=9D=98=EC=84=9C=EA=B4=80=EB=A6=AC=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=202=EB=A9=94=EB=89=B4=20(wace=5Fplm=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend - services/salesPurchaseRequestService.ts: listPurchaseRequestReg(DOC_TYPE='PURCHASE_REG') + listPurchaseRegProposal(DOC_TYPE='PURCHASE_REG_PROPOSAL') · 구매요청서 상태 CASE: PURCHASE_REG_PROPOSAL 자식 존재 시 '품의서생성' → '확정'/'작성중' (wace 매퍼 1:1) · 품의서 결재상태: amaranth_approval(target_type='PROPOSAL') LEFT JOIN 우선순위 · sales_request_part 누락 → MBOM_DETAIL+PART_MNG fallback (구매관리 패턴 동일) - routes/salesPurchaseRequestRoutes.ts + app.ts: /api/sales/purchase-request, /api/sales/purchase-proposal frontend - lib/api/salesPurchaseRequest.ts - sales/purchase-request/page.tsx — 14컬럼, 구매요청서작성/품의서생성 액션 (placeholder 토스트) - sales/purchase-proposal/page.tsx — 10컬럼, 결재상신 액션 (placeholder 토스트) - PageHeader+CompactFilterBar+SmartSelect+DataGrid logicstudio 6종 패턴 일관 적용 구매관리>품의서관리 vs 영업관리>품의서관리 차이 - 구매관리: DOC_TYPE in ('PROPOSAL', 'PURCHASE_REG_PROPOSAL'(결재완료만)) → 발주서 생성 풀 - 영업관리: DOC_TYPE='PURCHASE_REG_PROPOSAL' 전용 → 결재상신 화면 (결재완료 시 구매관리로 자동 노출) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 2 + .../src/routes/salesPurchaseRequestRoutes.ts | 42 +++ .../services/salesPurchaseRequestService.ts | 296 ++++++++++++++++++ .../sales/purchase-proposal/page.tsx | 219 +++++++++++++ .../sales/purchase-request/page.tsx | 204 ++++++++++++ frontend/lib/api/salesPurchaseRequest.ts | 41 +++ 6 files changed, 804 insertions(+) create mode 100644 backend-node/src/routes/salesPurchaseRequestRoutes.ts create mode 100644 backend-node/src/services/salesPurchaseRequestService.ts create mode 100644 frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx create mode 100644 frontend/lib/api/salesPurchaseRequest.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f5c1022d..b4e393ad 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -179,6 +179,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리> import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식) import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인) import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers) +import salesPurchaseRequestRoutes from "./routes/salesPurchaseRequestRoutes"; // 영업관리>구매요청서관리·품의서관리 (wace_plm) import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식) @@ -433,6 +434,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인) app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인) app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers) +app.use("/api/sales", salesPurchaseRequestRoutes); // 영업관리>구매요청서관리·품의서관리 (wace_plm) 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 도메인) diff --git a/backend-node/src/routes/salesPurchaseRequestRoutes.ts b/backend-node/src/routes/salesPurchaseRequestRoutes.ts new file mode 100644 index 00000000..c7332071 --- /dev/null +++ b/backend-node/src/routes/salesPurchaseRequestRoutes.ts @@ -0,0 +1,42 @@ +// ============================================================ +// 영업관리 > 구매요청서관리 / 품의서관리 라우트 +// app.ts: app.use("/api/sales", salesPurchaseRequestRoutes) +// GET /api/sales/purchase-request — DOC_TYPE='PURCHASE_REG' 그리드 +// GET /api/sales/purchase-proposal — DOC_TYPE='PURCHASE_REG_PROPOSAL' 그리드 +// ============================================================ + +import { Router, Response } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/salesPurchaseRequestService"; +import { logger } from "../utils/logger"; + +const router = Router(); +router.use(authenticateToken); + +function parseFilter(q: Record): svc.SalesPurchaseRequestFilter { + const f: svc.SalesPurchaseRequestFilter = { ...q }; + if (q.page) f.page = Number(q.page); + if (q.page_size) f.page_size = Number(q.page_size); + return f; +} + +async function run( + fn: (f: svc.SalesPurchaseRequestFilter) => Promise, + req: AuthenticatedRequest, + res: Response, + name: string, +) { + try { + const data = await fn(parseFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error(`${name} 실패`, { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +router.get("/purchase-request", (req, res) => run(svc.listPurchaseRequestReg, req as AuthenticatedRequest, res, "구매요청서관리")); +router.get("/purchase-proposal", (req, res) => run(svc.listPurchaseRegProposal, req as AuthenticatedRequest, res, "영업>품의서관리")); + +export default router; diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts new file mode 100644 index 00000000..8d43c60e --- /dev/null +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -0,0 +1,296 @@ +// ============================================================ +// 영업관리 > 구매요청서관리 / 품의서관리 — wace_plm 1:1 +// +// wace 매핑: +// /salesMng/purchaseRequestRegList.do +// → salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG') +// /salesMng/purchaseRegProposalMngList.do +// → salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL') +// +// 데이터 소스(RPS 존재 테이블 기준): +// ✓ sales_request_master, project_mgmt, contract_mgmt +// ✓ comm_code, client_mng, supply_mng +// ✓ mbom_header / mbom_detail / part_mng (품번/품명 fallback) +// ✓ amaranth_approval (품의서 결재상태) +// ✗ sales_request_part (운영DB 추출 후 신설 필요 — PART_NO fallback: MBOM_DETAIL) +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +export interface SalesPurchaseRequestFilter { + project_no?: string; + part_no?: string; + part_name?: string; + purchase_type?: string; + writer?: string; + part_type?: string; + search_status?: string; + proposal_no?: string; + regdate_start?: string; + regdate_end?: string; + page?: number; + page_size?: number; +} + +interface ListResult { + rows: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +function clampPaging(f: SalesPurchaseRequestFilter) { + const page = Math.max(1, Number(f.page ?? 1)); + const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50))); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── 1) 구매요청서관리 ─────────────────────────────────────────── +// wace: salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG') +// +// 상태: 품의서생성/확정/작성중 (PURCHASE_REG_PROPOSAL 자식 존재 → '품의서생성') +// wace CASE: PROPOSAL 타입 SRM.PROJECT_NO=SRM.OBJID 존재 시 '품의서생성' +// RPS 보정: 동일 부모-자식 연결을 PURCHASE_REG_PROPOSAL.PROJECT_NO 로 추적 +export async function listPurchaseRequestReg( + filter: SalesPurchaseRequestFilter, +): Promise> { + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = [`SRM.DOC_TYPE = 'PURCHASE_REG'`]; + const params: any[] = []; + const p = (v: any) => { params.push(v); return `$${params.length}`; }; + + if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`); + if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`); + if (filter.writer) { + const ph = p(filter.writer); + where.push(`(SRM.REQUEST_USER_ID = ${ph} OR SRM.WRITER = ${ph})`); + } + if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`); + if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`); + if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`); + if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NO ILIKE ${p(`%${filter.part_no}%`)})`); + if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NAME ILIKE ${p(`%${filter.part_name}%`)})`); + + const whereSql = `WHERE ${where.join(" AND ")}`; + + const dataSql = ` + SELECT + SRM.OBJID AS objid, + SRM.REQUEST_MNG_NO AS request_mng_no, + SRM.STATUS AS status, + SRM.PURCHASE_TYPE AS purchase_type, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '') + AS purchase_type_name, + -- 주문유형: 프로젝트.CATEGORY_CD 우선 + COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1), + '' + ) AS order_type_name, + -- 제품구분: 프로젝트→계약 PRODUCT 우선 + COALESCE( + (SELECT CC.CODE_NAME + FROM CONTRACT_MGMT CM + LEFT JOIN COMM_CODE CC ON CC.CODE_ID = CM.PRODUCT + WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1), + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), + '' + ) AS product_name_full, + -- 고객사: 프로젝트→계약 CUSTOMER 우선 + COALESCE( + (SELECT CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1) + ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) + END + FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1), + '' + ) AS customer_name, + -- 유/무상 + CASE COALESCE( + (SELECT CM.PAID_TYPE FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1), + SRM.PAID_TYPE) + WHEN 'paid' THEN '유상' + WHEN 'free' THEN '무상' + ELSE '' + END AS paid_type_name, + PM.PROJECT_NO AS project_number, + -- 품번/품명 (MBOM 우선) + COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD + JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR + WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID + ORDER BY MD.REGDATE LIMIT 1), '') AS part_no, + COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD + JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR + WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID + ORDER BY MD.REGDATE LIMIT 1), '') AS part_name, + GREATEST( + (SELECT COUNT(DISTINCT PP.PART_NO)::int + FROM MBOM_DETAIL MD + JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR + WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) - 1, + 0 + ) AS part_extra_count, + -- 구매요청서 작성여부 (MBOM_HEADER 존재 시 Y) + CASE WHEN SRM.MBOM_HEADER_OBJID IS NOT NULL THEN 'Y' ELSE 'N' END + AS has_purchase_request, + -- 상태 라벨 (wace CASE 1:1) + CASE + WHEN EXISTS ( + SELECT 1 FROM SALES_REQUEST_MASTER P + WHERE P.DOC_TYPE = 'PURCHASE_REG_PROPOSAL' + AND P.PROJECT_NO = SRM.OBJID::VARCHAR + ) THEN '품의서생성' + WHEN SRM.STATUS = 'confirmed' THEN '확정' + WHEN SRM.STATUS = 'create' THEN '작성중' + ELSE COALESCE(SRM.STATUS, '') + END AS status_title, + COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER) AS request_user_id, + COALESCE(user_name(COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER)), + COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER), '') + AS request_user_name, + SRM.DELIVERY_REQUEST_DATE AS delivery_request_date, + TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title, + SRM.MBOM_HEADER_OBJID AS mbom_header_objid + FROM SALES_REQUEST_MASTER SRM + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO + ${whereSql} + ORDER BY SRM.REGDATE DESC + LIMIT ${p(limit)} OFFSET ${p(offset)} + `; + const countSql = ` + SELECT COUNT(*)::int AS cnt + FROM SALES_REQUEST_MASTER SRM + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO + ${whereSql} + `; + try { + const [d, c] = await Promise.all([ + pool.query(dataSql, params), + pool.query(countSql, params.slice(0, params.length - 2)), + ]); + return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize }; + } catch (e: any) { + logger.error("listPurchaseRequestReg 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } +} + +// ─── 2) 영업관리 > 품의서관리 (구매요청서 → 품의서) ───────────── +// wace: salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL') +// +// 결재상태 우선순위: AMR.STATUS > 내부 결재 > SRM.STATUS('create'→'등록중') +export async function listPurchaseRegProposal( + filter: SalesPurchaseRequestFilter, +): Promise> { + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = [ + `SRM.STATUS IN ('create','approvalRequest','approvalComplete','reject')`, + `SRM.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'`, + ]; + const params: any[] = []; + const p = (v: any) => { params.push(v); return `$${params.length}`; }; + + if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${p(`%${filter.proposal_no}%`)}`); + if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`); + if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`); + if (filter.writer) where.push(`SRM.WRITER = ${p(filter.writer)}`); + if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`); + if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`); + if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`); + if (filter.search_status) { + // amaranth/내부 결재 종합 status = SEARCH_STATUS + where.push(`( + CASE + WHEN AMR.STATUS = 'complete' THEN 'approvalComplete' + WHEN AMR.STATUS = 'inProcess' THEN 'inProcess' + WHEN AMR.STATUS = 'reject' THEN 'reject' + ELSE SRM.STATUS + END + ) = ${p(filter.search_status)}`); + } + + const whereSql = `WHERE ${where.join(" AND ")}`; + + const dataSql = ` + SELECT + SRM.OBJID AS objid, + SRM.REQUEST_MNG_NO AS proposal_no, + PM.PROJECT_NO AS project_number, + SRM.PURCHASE_TYPE AS purchase_type, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '') + AS purchase_type_name, + COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1), + '' + ) AS order_type_name, + SRM.PRODUCT_NAME AS product_name, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), '' + ) AS product_name_title, + COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD + JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR + WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID + ORDER BY MD.REGDATE LIMIT 1), '') AS part_no, + COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD + JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR + WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID + ORDER BY MD.REGDATE LIMIT 1), '') AS part_name, + GREATEST( + (SELECT COUNT(DISTINCT PP.PART_NO)::int + FROM MBOM_DETAIL MD + JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR + WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) - 1, + 0 + ) AS part_extra_count, + CASE + WHEN AMR.STATUS = 'complete' THEN 'approvalComplete' + WHEN AMR.STATUS = 'inProcess' THEN 'inProcess' + WHEN AMR.STATUS = 'reject' THEN 'reject' + ELSE SRM.STATUS + END AS status, + CASE + WHEN AMR.STATUS = 'complete' THEN '결재완료' + WHEN AMR.STATUS = 'inProcess' THEN '결재 상신중' + WHEN AMR.STATUS = 'reject' THEN '반려' + ELSE '등록중' + END AS status_title, + COALESCE(AMR.STATUS, '') AS amaranth_status, + SRM.WRITER AS writer, + COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name, + TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title, + SRM.MBOM_HEADER_OBJID AS mbom_header_objid + FROM SALES_REQUEST_MASTER SRM + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO + LEFT JOIN AMARANTH_APPROVAL AMR + ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID + AND AMR.TARGET_TYPE = 'PROPOSAL' + ${whereSql} + ORDER BY SRM.REGDATE DESC + LIMIT ${p(limit)} OFFSET ${p(offset)} + `; + const countSql = ` + SELECT COUNT(*)::int AS cnt + FROM SALES_REQUEST_MASTER SRM + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO + LEFT JOIN AMARANTH_APPROVAL AMR + ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID + AND AMR.TARGET_TYPE = 'PROPOSAL' + ${whereSql} + `; + try { + const [d, c] = await Promise.all([ + pool.query(dataSql, params), + pool.query(countSql, params.slice(0, params.length - 2)), + ]); + return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize }; + } catch (e: any) { + logger.error("listPurchaseRegProposal 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } +} diff --git a/frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx b/frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx new file mode 100644 index 00000000..f713bd58 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +// 영업관리 > 품의서관리 — wace salesMng/purchaseRegProposalMngList.jsp 1:1 +// 그리드: sales_request_master (doc_type='PURCHASE_REG_PROPOSAL') + mbom 품번/품명 +// 검색: 품의서No / 프로젝트번호 / 결재상태 / 작성일 / 구매유형 / 작성자 / 제품구분 +// 액션: 조회 / 결재상신 (Amaranth10 SSO 연동 — 기존 견적 패턴 재사용 예정) +// +// 구매관리>품의서관리 vs 영업관리>품의서관리: +// - 구매관리: DOC_TYPE in ('PROPOSAL', 'PURCHASE_REG_PROPOSAL'(결재완료만)) → 발주서 생성 풀 +// - 영업관리: DOC_TYPE = 'PURCHASE_REG_PROPOSAL' 만 → 구매요청서 → 품의서 결재상신 화면 +// 결재완료 시 구매관리>품의서관리에 자동 노출됨. + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Send } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { PageHeader } from "@/components/common/PageHeader"; +import { apiClient } from "@/lib/api/client"; +import { purchaseApi, OptionItem } from "@/lib/api/purchase"; +import { + salesPurchaseRequestApi, + SalesPurchaseRequestFilter, +} from "@/lib/api/salesPurchaseRequest"; +import { exportToExcel } from "@/lib/utils/excelExport"; + +const PARENT_PURCHASE_TYPE = "0001814"; +const PARENT_PART_TYPE = "0000001"; + +const STATUS_OPTS: SmartSelectOption[] = [ + { code: "create", label: "작성중" }, + { code: "inProcess", label: "결재중" }, + { code: "approvalComplete", label: "결재완료" }, + { code: "reject", label: "반려" }, +]; + +const EMPTY_FILTER: SalesPurchaseRequestFilter = { + proposal_no: "", project_no: "", search_status: "", + purchase_type: "", writer: "", part_type: "", + regdate_start: "", regdate_end: "", + page: 1, page_size: 50, +}; + +export default function PurchaseRegProposalPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); + const [partTypeOpts, setPartTypeOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await salesPurchaseRequestApi.listPurchaseRegProposal(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { + let dead = false; + (async () => { + try { + const [pt, ptt, u] = await Promise.all([ + apiClient.get(`/sales/codes/${PARENT_PURCHASE_TYPE}`), + apiClient.get(`/sales/codes/${PARENT_PART_TYPE}`), + purchaseApi.listUsers(), + ]); + if (dead) return; + setPurchaseTypeOpts(pt.data?.data ?? []); + setPartTypeOpts(ptt.data?.data ?? []); + setUserOpts(u); + } catch { /* skip */ } + })(); + fetchList(EMPTY_FILTER); + return () => { dead = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const gridRows = useMemo(() => rows.map((r, i) => ({ + ...r, + id: r.objid ?? `prp_${i}`, + part_display: r.part_extra_count > 0 ? `${r.part_no} 외 ${r.part_extra_count}건` : r.part_no, + part_name_display: r.part_extra_count > 0 ? `${r.part_name} 외 ${r.part_extra_count}건` : r.part_name, + })), [rows]); + + const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ + { key: "proposal_no", label: "품의서 No", width: "w-[140px]", align: "center" }, + { key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" }, + { key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" }, + { key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" }, + { key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" }, + { key: "part_display", label: "품번", width: "w-[160px]" }, + { key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" }, + { key: "status_title", label: "결재상태", width: "w-[120px]", align: "center" }, + { key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" }, + { key: "writer_name", label: "작성자", width: "w-[120px]", align: "center" }, + ]), []); + + const summary = useMemo(() => { + const approved = gridRows.filter((r: any) => r.status === "approvalComplete").length; + const inProc = gridRows.filter((r: any) => r.status === "inProcess").length; + return [ + { label: "전체 건수", value: total.toLocaleString(), suffix: "건" }, + { label: "결재완료(페이지)", value: approved.toLocaleString(), suffix: "건" }, + { label: "결재중(페이지)", value: inProc.toLocaleString(), suffix: "건" }, + { label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" }, + ]; + }, [gridRows, total, checkedIds]); + + const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); }; + const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }; + + const onApproval = () => { + if (checkedIds.length !== 1) { + toast.info("결재상신할 1건을 선택해주세요."); + return; + } + const sel = gridRows.find((r: any) => r.id === checkedIds[0]); + if (!sel) return; + if (sel.status === "inProcess") return toast.info("결재 진행중인 건은 상신할 수 없습니다."); + if (sel.status === "approvalComplete") return toast.info("결재 완료된 건은 상신할 수 없습니다."); + toast.info("결재상신 — Amaranth10 SSO 연동 후 활성 (sales/purchase-proposal/:id/amaranth-approval)"); + }; + + return ( +
+ + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, proposal_no: e.target.value })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, search_status: v })} /> + + + setFilter({ ...filter, regdate_start: v })} + to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })} + /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, part_type: v })} /> + + + + { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} + showColumnSettings + summaryStats={summary} + systemColumnKeys={["writer_name", "regdate_title"]} + onRefresh={() => fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "영업_품의서관리.xlsx", "품의서"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx b/frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx new file mode 100644 index 00000000..b682e616 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +// 영업관리 > 구매요청서관리 — wace salesMng/purchaseRequestRegList.jsp 1:1 +// 그리드: sales_request_master (doc_type='PURCHASE_REG') + mbom 품번/품명 +// 검색: 품번 / 품명 / 작성일 / 구매유형(single) / 작성자 / 제품구분 +// 액션: 조회 / 구매요청서작성(예정) / 품의서생성(예정) + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { FilePlus, ClipboardCheck } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { PageHeader } from "@/components/common/PageHeader"; +import { apiClient } from "@/lib/api/client"; +import { purchaseApi, OptionItem } from "@/lib/api/purchase"; +import { + salesPurchaseRequestApi, + SalesPurchaseRequestFilter, +} from "@/lib/api/salesPurchaseRequest"; +import { exportToExcel } from "@/lib/utils/excelExport"; + +const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 +const PARENT_PART_TYPE = "0000001"; // 제품구분 + +const EMPTY_FILTER: SalesPurchaseRequestFilter = { + project_no: "", part_no: "", part_name: "", + purchase_type: "", writer: "", part_type: "", + regdate_start: "", regdate_end: "", + page: 1, page_size: 50, +}; + +export default function PurchaseRequestRegPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); + const [partTypeOpts, setPartTypeOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await salesPurchaseRequestApi.listPurchaseRequestReg(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { + let dead = false; + (async () => { + try { + const [pt, ptt, u] = await Promise.all([ + apiClient.get(`/sales/codes/${PARENT_PURCHASE_TYPE}`), + apiClient.get(`/sales/codes/${PARENT_PART_TYPE}`), + purchaseApi.listUsers(), + ]); + if (dead) return; + setPurchaseTypeOpts(pt.data?.data ?? []); + setPartTypeOpts(ptt.data?.data ?? []); + setUserOpts(u); + } catch { /* skip */ } + })(); + fetchList(EMPTY_FILTER); + return () => { dead = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const gridRows = useMemo(() => rows.map((r, i) => ({ + ...r, + id: r.objid ?? `pr_${i}`, + part_display: r.part_extra_count > 0 ? `${r.part_no} 외 ${r.part_extra_count}건` : r.part_no, + part_name_display: r.part_extra_count > 0 ? `${r.part_name} 외 ${r.part_extra_count}건` : r.part_name, + has_purchase_request_label: r.has_purchase_request === "Y" ? "작성" : "미작성", + })), [rows]); + + const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ + { key: "request_mng_no", label: "요청번호", width: "w-[150px]", align: "center" }, + { key: "purchase_type_name", label: "구매유형", width: "w-[110px]", align: "center" }, + { key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" }, + { key: "order_type_name", label: "주문유형", width: "w-[110px]", align: "center" }, + { key: "product_name_full", label: "제품구분", width: "w-[110px]", align: "center" }, + { key: "customer_name", label: "고객사", width: "w-[160px]" }, + { key: "paid_type_name", label: "유/무상", width: "w-[90px]", align: "center" }, + { key: "part_display", label: "품번", width: "w-[160px]" }, + { key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" }, + { key: "has_purchase_request_label",label: "구매요청서", width: "w-[110px]", align: "center" }, + { key: "request_user_name", label: "작성자", width: "w-[120px]", align: "center" }, + { key: "delivery_request_date", label: "입고요청일", width: "w-[120px]", align: "center" }, + { key: "regdate_title", label: "작성일", width: "w-[110px]", align: "center" }, + { key: "status_title", label: "상태", width: "w-[110px]", align: "center" }, + ]), []); + + const summary = useMemo(() => { + const proposed = gridRows.filter((r: any) => r.status_title === "품의서생성").length; + const confirmed = gridRows.filter((r: any) => r.status_title === "확정").length; + return [ + { label: "전체 건수", value: total.toLocaleString(), suffix: "건" }, + { label: "품의서생성(페이지)", value: proposed.toLocaleString(), suffix: "건" }, + { label: "확정(페이지)", value: confirmed.toLocaleString(), suffix: "건" }, + { label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" }, + ]; + }, [gridRows, total, checkedIds]); + + const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); }; + const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }; + + return ( +
+ + + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, regdate_start: v })} + to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })} + /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, part_type: v })} /> + + + + { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} + showColumnSettings + summaryStats={summary} + systemColumnKeys={["request_user_name", "regdate_title"]} + onRefresh={() => fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "구매요청서관리.xlsx", "구매요청서"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/lib/api/salesPurchaseRequest.ts b/frontend/lib/api/salesPurchaseRequest.ts new file mode 100644 index 00000000..a64a2d56 --- /dev/null +++ b/frontend/lib/api/salesPurchaseRequest.ts @@ -0,0 +1,41 @@ +// ============================================================ +// 영업관리 > 구매요청서관리 / 품의서관리 (wace_plm 1:1) +// 백엔드: /api/sales/purchase-request, /api/sales/purchase-proposal +// ============================================================ + +import { apiClient } from "./client"; + +export interface SalesPurchaseRequestFilter { + project_no?: string; + part_no?: string; + part_name?: string; + purchase_type?: string; + writer?: string; + part_type?: string; + search_status?: string; + proposal_no?: string; + regdate_start?: string; + regdate_end?: string; + page?: number; + page_size?: number; +} + +export interface SalesPurchaseRequestListResponse { + rows: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +async function getList( + path: string, + filter: SalesPurchaseRequestFilter, +): Promise> { + const res = await apiClient.get(`/sales/${path}`, { params: filter }); + return res.data?.data as SalesPurchaseRequestListResponse; +} + +export const salesPurchaseRequestApi = { + listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f), + listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f), +}; From 6368258797fbae1fd860f240694b17368c90e7fa Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 10:53:35 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC=20M-BO?= =?UTF-8?q?M=20PR-B5+=20=E2=80=94=20BOM=20=EB=B3=B5=EC=82=AC=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20(=EC=9A=B4=EC=98=81=20st?= =?UTF-8?q?ructureBomCopyFormPopup=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5개 메뉴 1:1 검증(Agent 5병렬) 후 발견된 mbom 메인 그리드 [BOM 복사] 누락 보완. 저장 매퍼는 PR-B5 saveBomAssignment 재사용 — 백엔드는 EBOM/MBOM 분기 이미 지원, 이번에 MBOM UI 진입점도 첫 노출. 신규 컴포넌트: - BomCopyDialog.tsx — 품번/품명 readonly + E-BOM/M-BOM 셀렉트 상호배타 + 트리 미리보기 + AttachFileDropZone (docType=MBOM_DRAWING, accept=.stp,.step,.dwg,.dxf,.pdf) - 메인 page.tsx [BOM 복사] 버튼 추가 + 체크 단건 검증 + Machine(0000928) 외 동일 partNo 최신 M-BOM 자동 추천 신규 백엔드 (mbomService/Controller/Routes): - searchAssignableMboms — 매퍼 productionplanning.getMbomListForSelect2 (4007~4014) 1:1 - previewMbomTree — getStructureOnly + finalize("ASSIGNED_MBOM") - getLatestMbomByPartNo — 매퍼 getLatestMbomByPartNo (3426~3445) 1:1, Machine 외 자동 검색 - 라우트: GET /assignable-mboms, GET /mbom-preview/:objid, GET /latest-mbom-by-partno/:partNo 도면 업로드 차이: 운영 fn_uploadDrawingFiles 는 placeholder("구현 예정"). RPS 는 공용 AttachFileDropZone 재사용해 실구현 (target_objid=projectObjid). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/mbomController.ts | 44 +++ .../src/routes/productionMbomRoutes.ts | 3 + backend-node/src/services/mbomService.ts | 106 ++++++ .../COMPANY_16/production/mbom/page.tsx | 92 ++++- .../components/production/BomCopyDialog.tsx | 353 ++++++++++++++++++ frontend/lib/api/mbom.ts | 40 ++ 6 files changed, 625 insertions(+), 13 deletions(-) create mode 100644 frontend/components/production/BomCopyDialog.tsx diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 0af2f834..245b786a 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -86,6 +86,50 @@ export async function previewEbomTree(req: AuthenticatedRequest, res: Response) } } +// PR-B5+ — 할당 가능한 M-BOM 검색 (운영 BOM 복사 다이얼로그 M-BOM 셀렉트 옵션) +export async function searchAssignableMboms(req: AuthenticatedRequest, res: Response) { + try { + const q = req.query as Record; + const data = await svc.searchAssignableMboms({ + search_part_no: String(q.search_part_no ?? "").trim() || undefined, + search_part_name: String(q.search_part_name ?? "").trim() || undefined, + search_from_date: String(q.search_from_date ?? "").trim() || undefined, + search_to_date: String(q.search_to_date ?? "").trim() || undefined, + limit: q.limit ? Number(q.limit) : undefined, + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("할당 가능 M-BOM 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// PR-B5+ — M-BOM 미리보기 트리 (운영 BOM 복사 다이얼로그 M-BOM 선택 시) +export async function previewMbomTree(req: AuthenticatedRequest, res: Response) { + try { + const mbomHeaderObjid = String(req.params.mbomHeaderObjid ?? "").trim(); + if (!mbomHeaderObjid) return res.status(400).json({ success: false, message: "mbomHeaderObjid 누락" }); + const data = await svc.previewMbomTree(mbomHeaderObjid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 미리보기 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// PR-B5+ — 동일 partNo 의 최신 M-BOM 조회 (운영 getLatestMbomByPartNo, Machine 외 자동 추천) +export async function getLatestMbomByPartNo(req: AuthenticatedRequest, res: Response) { + try { + const partNo = String(req.params.partNo ?? "").trim(); + if (!partNo) return res.status(400).json({ success: false, message: "partNo 누락" }); + const data = await svc.getLatestMbomByPartNo(partNo); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("최신 M-BOM 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1) export async function assignBom(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts index 7c02a939..62e90798 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -19,5 +19,8 @@ router.post("/sales-request", ctrl.createSalesRequest); // PR-B3 구매리스트 router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색 router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기 router.post("/assign", ctrl.assignBom); // PR-B5 BOM 할당 +router.get("/assignable-mboms", ctrl.searchAssignableMboms); // PR-B5+ BOM 복사 - M-BOM 셀렉트 옵션 +router.get("/mbom-preview/:mbomHeaderObjid", ctrl.previewMbomTree); // PR-B5+ BOM 복사 - M-BOM 미리보기 +router.get("/latest-mbom-by-partno/:partNo", ctrl.getLatestMbomByPartNo); // PR-B5+ Machine 외 자동 추천 export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index 5bd95986..0b369c5b 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1357,6 +1357,112 @@ export async function assignBom( return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId }; } +// ─── BOM 복사 다이얼로그 보조 (PR-B5+) ───────────────────────── +// +// 운영판 partMng/structureBomCopyFormPopup.jsp 진입점 보조 함수. +// 저장은 위 assignBom 재사용 (sourceBomType='EBOM'|'MBOM'). + +export interface AssignableMbomFilter { + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + limit?: number; +} + +export interface AssignableMbomRow { + objid: string; + mbom_no: string | null; + part_no: string | null; + part_name: string | null; + reg_date: string | null; +} + +// 매퍼 productionplanning.getMbomListForSelect2 (4007~4014) 1:1 + 검색 필터 확장. +export async function searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise { + const pool = getPool(); + const conds: string[] = []; + const params: any[] = []; + let idx = 1; + if (filter.search_part_no) { + conds.push(`UPPER(MH.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`); + params.push(filter.search_part_no); + } + if (filter.search_part_name) { + conds.push(`UPPER(MH.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`); + params.push(filter.search_part_name); + } + if (filter.search_from_date) { + conds.push(`MH.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(filter.search_from_date); + } + if (filter.search_to_date) { + conds.push(`MH.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`); + params.push(filter.search_to_date); + } + const limit = Math.min(500, Math.max(1, Number(filter.limit) || 200)); + const sql = ` + SELECT + MH.OBJID::VARCHAR AS objid, + COALESCE(MH.MBOM_NO, '') AS mbom_no, + COALESCE(MH.PART_NO, '') AS part_no, + COALESCE(MH.PART_NAME, '') AS part_name, + TO_CHAR(MH.REGDATE, 'YYYY-MM-DD') AS reg_date + FROM MBOM_HEADER MH + WHERE MH.STATUS = 'Y' + ${conds.length ? "AND " + conds.join(" AND ") : ""} + ORDER BY MH.REGDATE DESC, MH.MBOM_NO + LIMIT ${limit} + `; + const r = await pool.query(sql, params); + return r.rows; +} + +// M-BOM 미리보기 트리 — mbom_header_objid 만으로 STRUCTURE_ONLY_SQL 호출. +// 운영판 structureBomCopyFormPopup 의 BOM 트리 미리보기 (M-BOM 선택 시). +export async function previewMbomTree(mbomHeaderObjid: string): Promise { + if (!mbomHeaderObjid) { + return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] }; + } + const rows = await getStructureOnly(mbomHeaderObjid); + return finalize("ASSIGNED_MBOM", mbomHeaderObjid, rows); +} + +export interface LatestMbomByPartNoRow { + template_header_objid: string; + mbom_no: string | null; + project_objid: string | null; + part_no: string | null; + part_name: string | null; + save_date: string | null; +} + +// 매퍼 productionplanning.getLatestMbomByPartNo (3426~3445) 1:1. +// Machine(0000928) 이외 제품 + 동일 partNo + STATUS='Y' 최신 1건. +export async function getLatestMbomByPartNo(partNo: string): Promise { + if (!partNo || !partNo.trim()) return null; + const pool = getPool(); + const sql = ` + SELECT + MH.OBJID::VARCHAR AS template_header_objid, + MH.MBOM_NO AS mbom_no, + MH.PROJECT_OBJID::VARCHAR AS project_objid, + MH.PART_NO AS part_no, + MH.PART_NAME AS part_name, + TO_CHAR(MH.REGDATE, 'YYYY-MM-DD') AS save_date + FROM MBOM_HEADER MH + INNER JOIN PROJECT_MGMT PM ON MH.PROJECT_OBJID = PM.OBJID + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE MH.PART_NO = $1 + AND MH.STATUS = 'Y' + AND CM.PRODUCT != '0000928' + ORDER BY MH.REGDATE DESC + LIMIT 1 + `; + const r = await pool.query(sql, [partNo.trim()]); + return r.rows[0] ?? null; +} + // ─── 변경이력 조회 (PR-B4) ────────────────────────────────── // // 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1. diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index 0ae507d4..e6300a4d 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -11,7 +11,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ShoppingCart, Loader2 } from "lucide-react"; +import { ShoppingCart, Loader2, Copy } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; @@ -22,6 +22,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { apiClient } from "@/lib/api/client"; import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; import { MbomDetailDialog } from "@/components/production/MbomDetailDialog"; +import { BomCopyDialog } from "@/components/production/BomCopyDialog"; import { exportToExcel } from "@/lib/utils/excelExport"; const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id @@ -78,6 +79,16 @@ export default function MbomMgmtPage() { const [checkedIds, setCheckedIds] = useState([]); const [creatingPurchaseList, setCreatingPurchaseList] = useState(false); + // PR-B5+: BOM 복사 — 단건 체크 + 다이얼로그 (wace fn_openBomCopyPopup 1:1) + const [bomCopyOpen, setBomCopyOpen] = useState(false); + const [bomCopyTarget, setBomCopyTarget] = useState<{ + projectObjid: string; + partNo: string; + partName: string; + productCode: string; + hasMbom: boolean; + } | null>(null); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -224,6 +235,32 @@ export default function MbomMgmtPage() { } }, [checkedIds, gridRows, confirm, fetchList]); + // PR-B5+ — BOM 복사 (wace fn_openBomCopyPopup 1:1) + // 검증: 단건 체크 + objid 확인. M-BOM 이미 있으면 다이얼로그 내부에서 "초기화" 경고. + const handleOpenBomCopy = useCallback(() => { + if (checkedIds.length === 0) { + toast.info("BOM 을 복사할 프로젝트를 선택해주세요."); + return; + } + if (checkedIds.length > 1) { + toast.info("한 번에 하나의 프로젝트만 선택해주세요."); + return; + } + const row = gridRows.find((r: any) => r.id === checkedIds[0]) as any; + if (!row?.objid) { + toast.error("프로젝트 OBJID를 찾을 수 없습니다."); + return; + } + setBomCopyTarget({ + projectObjid: String(row.objid), + partNo: String(row.part_no ?? ""), + partName: String(row.part_name ?? ""), + productCode: String(row.product ?? ""), + hasMbom: !!(row.mbom_header_objid && String(row.mbom_status ?? "") === "Y"), + }); + setBomCopyOpen(true); + }, [checkedIds, gridRows]); + return (
- {creatingPurchaseList - ? - : } - 구매리스트 생성 - + <> + + + } /> + { + setBomCopyOpen(v); + if (!v) setBomCopyTarget(null); + }} + projectObjid={bomCopyTarget?.projectObjid ?? null} + partNo={bomCopyTarget?.partNo ?? null} + partName={bomCopyTarget?.partName ?? null} + productCode={bomCopyTarget?.productCode ?? null} + hasMbom={bomCopyTarget?.hasMbom ?? false} + onSaved={() => { + setCheckedIds([]); + fetchList(); + }} + /> + {ConfirmDialogComponent}
); diff --git a/frontend/components/production/BomCopyDialog.tsx b/frontend/components/production/BomCopyDialog.tsx new file mode 100644 index 00000000..a18d9295 --- /dev/null +++ b/frontend/components/production/BomCopyDialog.tsx @@ -0,0 +1,353 @@ +"use client"; + +// 생산관리 > M-BOM 관리 — BOM 복사 다이얼로그 (PR-B5+). +// +// 운영판 partMng/structureBomCopyFormPopup.jsp (774 lines) 1:1 재구성. +// 진입: 메인 그리드 [BOM 복사] 버튼 (체크박스 1개 선택). +// 저장: /production/mbom/assign (saveBomAssignment) — MbomAssignDialog 와 동일 매퍼. +// 차이점: E-BOM/M-BOM 셀렉트 두 개 (상호배타) + 트리 미리보기 + 도면 다중 업로드. +// +// 운영판 fn_uploadDrawingFiles 는 placeholder ("구현 예정") — RPS 는 공용 AttachFileDropZone +// 재사용으로 실구현 (target_objid=projectObjid, docType="MBOM_DRAWING"). + +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2, Copy, Folder, X as XIcon } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + mbomApi, + AssignableEbomRow, + AssignableMbomRow, + MbomTreeResponse, + MbomTreeRow, + LatestMbomByPartNoRow, +} from "@/lib/api/mbom"; +import { AttachFileDropZone } from "@/components/common/AttachFileDropZone"; + +type SourceType = "EBOM" | "MBOM"; + +const MACHINE_PRODUCT_CD = "0000928"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + projectObjid: string | null; + partNo: string | null; + partName: string | null; + productCode?: string | null; // contract_mgmt.product — Machine 외 자동 추천 분기용 + hasMbom?: boolean; // 이미 저장된 M-BOM 이 있으면 "초기화" 경고 + onSaved: () => void; +} + +export function BomCopyDialog({ + open, onOpenChange, projectObjid, partNo, partName, productCode, hasMbom, onSaved, +}: Props) { + // 셀렉트 옵션 + 선택 상태 + const [ebomOpts, setEbomOpts] = useState([]); + const [mbomOpts, setMbomOpts] = useState([]); + const [ebomLoading, setEbomLoading] = useState(false); + const [mbomLoading, setMbomLoading] = useState(false); + const [selectedType, setSelectedType] = useState(null); + const [selectedObjid, setSelectedObjid] = useState(""); + + // 미리보기 트리 + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + // 저장 진행 + 자동 추천 안내 1회 가드 + const [saving, setSaving] = useState(false); + const autoCheckedRef = React.useRef(false); + + // ── 오픈 시 초기화 + 옵션 로드 + Machine 외 자동 추천 ── + useEffect(() => { + if (!open) { + // 닫힐 때 상태 초기화 + setSelectedType(null); + setSelectedObjid(""); + setPreview(null); + autoCheckedRef.current = false; + return; + } + // E-BOM 옵션 + setEbomLoading(true); + mbomApi.searchAssignableEboms({ limit: 500 }) + .then(setEbomOpts) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "E-BOM 옵션 조회 실패")) + .finally(() => setEbomLoading(false)); + // M-BOM 옵션 + setMbomLoading(true); + mbomApi.searchAssignableMboms({ limit: 500 }) + .then(setMbomOpts) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 옵션 조회 실패")) + .finally(() => setMbomLoading(false)); + }, [open]); + + // Machine 이외 제품 + partNo 있으면 자동 추천 (운영 fn_checkExistingMbom) + useEffect(() => { + if (!open) return; + if (autoCheckedRef.current) return; + if (!partNo || !partNo.trim()) return; + const isMachine = (productCode ?? "").trim() === MACHINE_PRODUCT_CD; + if (isMachine) return; + autoCheckedRef.current = true; + mbomApi.getLatestMbomByPartNo(partNo) + .then((row) => { + if (!row) return; + const ok = window.confirm( + `동일 품번(${partNo})의 M-BOM이 이미 존재합니다.\n` + + `M-BOM 품번: ${row.mbom_no ?? ""}\n저장일: ${row.save_date ?? ""}\n\n` + + `기존 M-BOM 을 자동으로 불러오시겠습니까?` + ); + if (ok) { + setSelectedType("MBOM"); + setSelectedObjid(String(row.template_header_objid)); + } + }) + .catch(() => { /* 실패 시 조용히 무시 — 자동 추천은 부가기능 */ }); + }, [open, partNo, productCode]); + + // ── 선택 변경 시 트리 미리보기 ──────────────────────────── + useEffect(() => { + if (!selectedType || !selectedObjid) { + setPreview(null); + return; + } + setPreviewLoading(true); + const fetcher = selectedType === "EBOM" + ? mbomApi.previewEbomTree(selectedObjid) + : mbomApi.previewMbomTree(selectedObjid); + fetcher + .then(setPreview) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "트리 미리보기 실패")) + .finally(() => setPreviewLoading(false)); + }, [selectedType, selectedObjid]); + + const previewTitle = useMemo(() => { + if (!selectedType || !selectedObjid) return "BOM 데이터를 조회하려면 E-BOM 또는 M-BOM 을 선택하세요."; + if (selectedType === "EBOM") { + const r = ebomOpts.find(o => String(o.objid) === String(selectedObjid)); + return r ? `${r.part_no ?? ""} - ${r.part_name ?? ""}${r.revision ? ` (Rev.${r.revision})` : ""}` : ""; + } + const r = mbomOpts.find(o => String(o.objid) === String(selectedObjid)); + return r ? `${r.mbom_no ?? ""}${r.part_name ? ` - ${r.part_name}` : ""}` : ""; + }, [selectedType, selectedObjid, ebomOpts, mbomOpts]); + + // ── 저장 (운영 fn_saveBomCopy → saveBomAssignment.do) ───── + const handleSave = async () => { + if (!projectObjid) { toast.error("프로젝트가 선택되지 않았습니다."); return; } + if (!partNo || !partName) { toast.error("품번과 품명이 비어 있습니다."); return; } + if (!selectedType || !selectedObjid) { toast.error("복사할 BOM 을 선택하세요."); return; } + if (!preview || preview.rows.length === 0) { toast.error("복사할 BOM 데이터가 없습니다."); return; } + + if (hasMbom) { + const ok = window.confirm("저장된 M-BOM 이 초기화 됩니다.\n계속하시겠습니까?"); + if (!ok) return; + } + const ok = window.confirm( + `선택한 ${selectedType} 을(를) M-BOM 기준으로 할당하시겠습니까?\n` + + `실제 M-BOM 생성은 M-BOM 상세 팝업에서 저장 시 이루어집니다.` + ); + if (!ok) return; + + try { + setSaving(true); + await mbomApi.assignBom({ + project_obj_id: projectObjid, + source_bom_type: selectedType, + source_bom_obj_id: selectedObjid, + }); + toast.success("BOM 할당 정보가 저장되었습니다."); + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 할당 실패"); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + BOM 복사 + + + +
+ {/* 상단: 품번/품명 readonly + [저장] [닫기] (운영 1:1) */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* 중단: E-BOM / M-BOM 셀렉트 (상호배타) */} +
+
E-BOM 선택
+
+ +
+
M-BOM 선택
+
+ +
+
+ + {/* 하단: 미리보기 트리 + 도면 업로드 */} +
+
+ {previewTitle} + + {previewLoading ? "로딩…" : preview ? `${preview.rows.length} 행 · 최대 ${preview.max_level} 레벨` : ""} + +
+ +
+ + {/* 도면 다중 업로드 (운영판 fn_uploadDrawingFiles placeholder → RPS 실구현) */} +
+
+ + 도면 다중 업로드 + + .stp .step .dwg .dxf .pdf +
+ {projectObjid ? ( + + ) : ( +
프로젝트 선택 후 도면 업로드가 가능합니다.
+ )} +
+
+
+
+ ); +} + +// ─── 트리 미리보기 (간단 그리드) ─────────────────────────────── +function PreviewTree({ + preview, loading, +}: { preview: MbomTreeResponse | null; loading: boolean }) { + if (loading) { + return ( +
+ 트리 로딩 중… +
+ ); + } + if (!preview || preview.rows.length === 0) { + return ( +
표시할 BOM 데이터가 없습니다.
+ ); + } + const maxLevel = preview.max_level || 1; + return ( +
+ + + + + {Array.from({ length: maxLevel }).map((_, i) => ( + + ))} + + + + + + + + + {preview.rows.map((r: MbomTreeRow, idx: number) => ( + + + {Array.from({ length: maxLevel }).map((_, i) => ( + + ))} + + + + + + + ))} + +
#{i + 1}품번품명수량규격소재
{idx + 1} + {Number(r.level) === i + 1 ? "*" : ""} + {r.part_no ?? ""}{r.part_name ?? ""}{r.item_qty ?? r.qty ?? ""}{r.spec ?? ""}{r.material ?? ""}
+
+ ); +} + +function levelBg(level: number | string | null | undefined): string { + const n = Number(level) || 0; + switch (n) { + case 1: return "bg-white"; + case 2: return "bg-blue-50"; + case 3: return "bg-amber-50"; + case 4: return "bg-emerald-50"; + case 5: return "bg-rose-50"; + default: return ""; + } +} diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts index 84c0a057..b96aa538 100644 --- a/frontend/lib/api/mbom.ts +++ b/frontend/lib/api/mbom.ts @@ -267,6 +267,18 @@ export const mbomApi = { const res = await apiClient.post("/production/mbom/assign", payload); return res.data?.data as AssignBomResult; }, + async searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise { + const res = await apiClient.get("/production/mbom/assignable-mboms", { params: filter }); + return (res.data?.data ?? []) as AssignableMbomRow[]; + }, + async previewMbomTree(mbomHeaderObjid: string): Promise { + const res = await apiClient.get(`/production/mbom/mbom-preview/${encodeURIComponent(mbomHeaderObjid)}`); + return res.data?.data as MbomTreeResponse; + }, + async getLatestMbomByPartNo(partNo: string): Promise { + const res = await apiClient.get(`/production/mbom/latest-mbom-by-partno/${encodeURIComponent(partNo)}`); + return (res.data?.data ?? null) as LatestMbomByPartNoRow | null; + }, }; // ─── BOM 할당 (PR-B5) ─────────────────────────────────────── @@ -310,6 +322,34 @@ export interface AssignBomResult { source_obj_id: string; } +// ─── BOM 복사 보조 (PR-B5+) ───────────────────────────────── +// 운영판 partMng/structureBomCopyFormPopup.jsp 의 셀렉트/자동검색 보조 API. + +export interface AssignableMbomFilter { + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + limit?: number; +} + +export interface AssignableMbomRow { + objid: string; + mbom_no: string | null; + part_no: string | null; + part_name: string | null; + reg_date: string | null; +} + +export interface LatestMbomByPartNoRow { + template_header_objid: string; + mbom_no: string | null; + project_objid: string | null; + part_no: string | null; + part_name: string | null; + save_date: string | null; +} + // ─── 변경이력 (PR-B4) ─────────────────────────────────────── export interface MbomHistoryRow { From 21cd81bd7938c4745eb34c19876e005eb4d79c1c Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 11:14:20 +0900 Subject: [PATCH 3/8] =?UTF-8?q?docs(production):=20=EC=83=9D=EC=82=B0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20README=20=EC=8B=A0=EA=B7=9C=20=E2=80=94=20?= =?UTF-8?q?5=EB=A9=94=EB=89=B4=20+=20PR-B5+=20BOM=20=EB=B3=B5=EC=82=AC=20+?= =?UTF-8?q?=20=EB=A7=A4=ED=8D=BC=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sales/README.md 패턴 따라 생산관리 도메인 전체 문서화: - 정책: 5개 메뉴(M-BOM/계획실적×2/소요량×2)가 전부, vexplor 잔재(bom/plan-management/process-info/result/work-instruction) 제외 - 메뉴 매핑표 + 도메인 테이블 + data-sync/ddl-extracted 인덱스 - productionplanning.xml 23개 매퍼 → mbomService/prodPlanResultService/mbomRequirementService 1:1 표 - PR-A0~B5+ 진행 흐름 + 대표 커밋 - 5메뉴 1:1 검증 결과(Agent 5병렬, 2026-05-15) - PR-B5+ BOM 복사 다이얼로그 상세 (BomCopyDialog, 도면업로드 차이 명시) - 다음 작업 후보 6종 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/migration/production/README.md | 188 ++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/migration/production/README.md diff --git a/docs/migration/production/README.md b/docs/migration/production/README.md new file mode 100644 index 00000000..5762ca86 --- /dev/null +++ b/docs/migration/production/README.md @@ -0,0 +1,188 @@ +# 생산관리 이식 (wace_plm → vexplor_rps) + +> 작성: 2026-05-15 / 작성자: hjjeong +> 대상: vexplor_rps (RPS 전용 분기, COMPANY_16 단독 운영) +> 원본: wace_plm (Java 7 / Spring 3.2.4 / JSP / MyBatis) + +## 0. 정책 (사용자 확정 사항) + +- **이식 방식**: JSP → Next.js 리라이트 (백엔드도 Java→Node 재작성, vexplor_rps `backend-node` 패턴) +- **스키마 정책**: wace_plm 도메인 테이블(`mbom_header`/`mbom_detail`/`mbom_history`/`sales_request_master`/`project_mgmt`/`contract_mgmt`/`part_bom_report` 등) **그대로 이식**. 마스터(`customer_mng`/`item_info`/`user_info` 등)는 RPS 기존 사용. +- **이식 대상 메뉴 = 5개** (2026-05-15 사용자 확정): + 1. M-BOM 관리 (`/productionplanning/mBomMgmtList.do`) + 2. 생산계획&실적관리 (`/productionplanning/prodPlanResultMgmtList.do`) + 3. 생산계획&실적관리(장비) (`/productionplanning/prodPlanResultMgmtEquipList.do`) + 4. 반제품소요량 (`/productionplanning/semiProductRequirementList.do`) + 5. 원자재소요량 (`/productionplanning/rawMaterialRequirementList.do`) +- ⚠️ `production/{bom,plan-management,process-info,result,work-instruction}` 디렉토리는 **vexplor 잔재** — wace_plm 매뉴얼 5개에 없음, 이식 대상 아님. +- **메뉴 노출**: M-BOM 관리는 생산관리 + 구매관리 트리 양쪽 진입 허용 (`menu_info` 100016 + 100032 동시 활성). 구매관리 페이지는 production/mbom re-export. + +## 1. 메뉴 매핑표 + +| # | 메뉴명 | wace_plm URL | wace_plm JSP | wace_plm Controller | RPS 신규 위치 | +|---|---|---|---|---|---| +| 1 | M-BOM 관리 | `/productionplanning/mBomMgmtList.do` | `productionplanning/mBomMgmtList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/mbom/page.tsx` + `backend-node/src/{routes,services,controllers}/mbom*` (PR-A0~B5+) | +| 2 | 생산계획&실적관리 | `/productionplanning/prodPlanResultMgmtList.do` | `productionplanning/prodPlanResultMgmtList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/plan-result/page.tsx` + `backend-node/src/{routes,services,controllers}/prodPlanResult*` | +| 3 | 생산계획&실적관리(장비) | `/productionplanning/prodPlanResultMgmtEquipList.do` | `productionplanning/prodPlanResultMgmtEquipList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/plan-result-equip/page.tsx` + 위와 동일 service (listEquip 분기) | +| 4 | 반제품소요량 | `/productionplanning/semiProductRequirementList.do` | `productionplanning/semiProductRequirementList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/semi-product-req/page.tsx` + `backend-node/src/services/mbomRequirementService.ts` (listSemi) | +| 5 | 원자재소요량 | `/productionplanning/rawMaterialRequirementList.do` | `productionplanning/rawMaterialRequirementList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/raw-material-req/page.tsx` + 위와 동일 service (listRaw) | +| ─ | BOM 복사 다이얼로그 (M-BOM 보조) | `/partMng/structureBomCopyFormPopup.do` | `partMng/structureBomCopyFormPopup.jsp` | `PartMngController` | `components/production/BomCopyDialog.tsx` (PR-B5+) | + +## 2. 도메인 테이블 + +이식 대상 — RPS DB에 CREATE 그대로 적용. DDL은 [ddl-extracted/](./ddl-extracted/), 동기화 SQL은 [data-sync/](./data-sync/). + +| 우선순위 | 테이블 | 용도 | DDL 파일 | +|---|---|---|---| +| ★★★ | `mbom_header` | M-BOM 헤더 (project_objid 별 1+ 버전, status='Y' 가 활성) | [400_mbom.sql](./ddl-extracted/400_mbom.sql) | +| ★★★ | `mbom_detail` | M-BOM 상세 (parent/child 트리, qty/required_qty/order_qty/production_qty) | [400_mbom.sql](./ddl-extracted/400_mbom.sql) | +| ★★★ | `mbom_history` | M-BOM 변경이력 (CHANGE_TYPE = CREATE/UPDATE) | [401_mbom_dependencies.sql](./ddl-extracted/401_mbom_dependencies.sql) | +| ★★★ | `sales_request_master` | 구매리스트(R-YYYYMMDD-NNN), M-BOM 으로부터 단건 생성 | [401_mbom_dependencies.sql](./ddl-extracted/401_mbom_dependencies.sql) | +| ★★★ | `project_mgmt` | 프로젝트 헤더 (source_bom_type/source_ebom_objid/source_mbom_objid) | (sales 도메인 기존 테이블 재사용) | +| ★★★ | `contract_mgmt` / `contract_item` | 수주/계약 정보 (product != '0000928' = Machine 외 판별) | (sales 도메인 기존 테이블 재사용) | +| ★★ | `part_bom_report` / `bom_part_qty` | E-BOM 참조 (BOM 할당/복사 소스) | (개발관리 도메인 기존 테이블) | +| ★★ | `production_plan` | 생산계획(plan-result 보조) | [402_production_plan.sql](./ddl-extracted/402_production_plan.sql) | +| ★ | `attach_file_info` | 도면 다중 업로드 (PR-B5+ BomCopyDialog, doc_type='MBOM_DRAWING') | (공용 테이블) | +| ★ | `client_mng` | 고객사 (`user_name()` fn 동반) | (sales 기존) | + +### 데이터 동기화 SQL ([data-sync/](./data-sync/)) + +| 파일 | 용도 | +|---|---| +| `01_mbom_sync.sql` | 운영 DB → RPS mbom_header/detail 데이터 이주 | +| `02_mbom_dependencies_sync.sql` | mbom_history + sales_request_master 동기화 | +| `03_mbom_menu_dedup.sql` | menu_info 중복 정리 (M-BOM 100016/100032) | +| `04_production_plan_sync.sql` | production_plan 동기화 | +| `05_mbom_menu_desc.sql` | M-BOM 관리 메뉴명 sync | + +## 3. 매퍼 매핑표 (`productionplanning.xml` 1:1) + +| wace 매퍼 ID | wace 라인 | RPS 함수 (mbomService) | PR | +|---|---|---|---| +| `mBomMgmtGridList` | 2874-3119 | `list()` | A1 | +| `getProjectMgmtDetail` | 3150-3218 | `getDetail()` | A2 | +| `getLatestMbomByProjectId` | 3555-3570 | `getLatestSavedMbom()` | A2 | +| `getLatestMbomTemplateByPartNo` | 3573-3591 | `getLatestTemplate()` | A2 | +| `getMbomTemplateDetails` | 3594-3794 | `getTemplateDetails()` | A2 | +| `getSavedMbomTreeList` | 4114-4359 | `getSavedTree()` | A2 | +| `getMbomStructureOnly` | 4362-4538 | `getStructureOnly()` | A2 | +| `insertMbomHeader/Detail` | 3873/3886 | `save()` CREATE | B1 | +| `updateMbomHeader/Detail` | 3917/3940 | `save()` UPDATE | B1 | +| `deleteMbomDetailByObjid` | 3934 | `save()` UPDATE 누락 행 | B1 | +| `insertMbomHistory` | 3973 | `insertHistory()` helper | B1/B4 | +| `updateProjectMbomStatus` | 3949 | `save()` CREATE | B1 | +| `getMbomHistory` | 3448-3470 | `getHistory()` | B4 | +| `partMng.getBOMTreeList` | partMng.xml 3289-3549 | `getEbomWorkingTree()` / `previewEbomTree()` | A2/B5 | +| `getEbomList` | 3221-3265 | `searchAssignableEboms()` | B5 | +| `saveBomAssignment` | 3545-3553 | `assignBom()` (EBOM/MBOM 둘 다) | B5 | +| `getLatestMbomByPartNo` | 3426-3445 | `getLatestMbomByPartNo()` | B5+ | +| `getMbomListForSelect2` | 4007-4014 | `searchAssignableMboms()` | B5+ | +| `salesMng.insertSalesRequestMasterFromMBom` | ~3975 | `createSalesRequest()` | B3 | +| `prodPlanResultMgmtGridList` | 4550~ | `prodPlanResultService.listGeneral()` | — | +| `prodPlanResultMgmtEquipGridList` | 4887~ | `prodPlanResultService.listEquip()` | — | +| `semiProductRequirementList` (Java LinkedHashMap) | ~5252 | `mbomRequirementService.listSemi()` | — | +| `rawMaterialRequirementList` (Java LinkedHashMap) | — | `mbomRequirementService.listRaw()` | — | + +## 4. PR 진행 흐름 + +| PR | 내용 | 대표 커밋 | +|---|---|---| +| A0 | 의존 테이블 (mbom_history/sales_request_master/client_mng + user_name fn) | `7af366c5` | +| A0' | M-BOM 테이블 신설 (mbom_header/detail + 운영 sample) | `04cfac6e` | +| A1 | 메인 그리드/검색 (mBomMgmtGridList 1:1) | `66cee22b` | +| A2 | 단건 상세 + read-only 트리 4분기 (SAVED/ASSIGNED_EBOM/ASSIGNED_MBOM/TEMPLATE/NONE) | `dd88dc6e` | +| B1 | 본 편집 + 폴더 컬럼 + DataGrid 서버 페이지네이션 + bigint=varchar fix | `7a7f4f03` | +| B2 | 본 편집 행 추가/삭제 (팝업 방식, MbomAddPartDialog, temp-objid remap) | `dee03f60` | +| B3 | 구매리스트 생성 (createPurchaseListFromMBom 1:1) | `b38f5957` | +| B4 | 변경이력 다이얼로그 (getMbomHistory 1:1) | `8dd5f184` | +| B5 | BOM 할당 베이스 (mBomEbomSelectPopup 진입점) | `b38f5957` | +| B5 | BOM 할당 운영판 1:1 재구성 (카드+토글+미리보기 트리+제품구분/날짜 검색) | `bd47ca80` | +| 보정 | 4개 메뉴 디렉토리 rename(URL 일치) + 그리드 헤더 보정 + 소요량 numeric 캐스팅 fix | `c83a73a1` | +| **B5+** | **BOM 복사 다이얼로그 (structureBomCopyFormPopup 1:1) + 5메뉴 1:1 검증** | `63682587` | + +## 5. 5개 메뉴 1:1 검증 결과 (2026-05-15) + +Agent 5병렬 (read-only) 검증. + +| 메뉴 | 판정 | 비고 | +|---|---|---| +| M-BOM 관리 | PASS (B5+ 후) | 검색 폼 / 그리드 16컬럼 / SQL 100% 일치. 품번/품명만 wace `Select2-part`(AJAX) → Input text. **B5+ 로 누락된 [BOM 복사] 보완**. | +| 생산계획&실적관리 | MINOR_DIFF | 검색 13필드 / 그리드 18컬럼 / SQL 100% 일치. **[생산계획 생성]·[생산실적 등록]** 액션 모달은 미구현(프로토타입). 품번/품명 Select2-part → Input text. | +| 생산계획&실적관리(장비) | PASS | 검색 9필드 / 그리드 14컬럼 / SQL 100% 일치. WBS할당 모달은 의도적 미구현 (주석 명시). | +| 반제품소요량 | MINOR_DIFF | 입력 폼 / 결과 4컬럼 / SQL / 누적 로직 100% 일치. RPS만 단위/소재/규격 3컬럼 추가 노출 (UX 향상). c83a73a1 에서 numeric 캐스팅 fix. | +| 원자재소요량 | PASS | 입력 / 8컬럼 / 구매품·원소재 SQL / Math.ceil 누적 / 숫자 포맷 모두 100% 일치. | + +## 6. PR-B5+ BOM 복사 다이얼로그 (2026-05-15) + +운영판 `partMng/structureBomCopyFormPopup.jsp` (774 lines) 1:1. + +**진입**: 메인 그리드 [BOM 복사] 버튼 — 체크박스 단건 선택 + Machine(0000928) 외 동일 partNo 매칭 시 기존 M-BOM 자동 추천(`getLatestMbomByPartNo` 호출). + +**레이아웃**: +- 상단: 품번 / 품명 readonly + [저장] [닫기] +- 중단: E-BOM 셀렉트 / M-BOM 셀렉트 (상호배타, 한쪽 선택 시 다른쪽 disable) +- 하단: 트리 미리보기 + **도면 다중 업로드** (공용 `AttachFileDropZone`, target=projectObjid, docType=`MBOM_DRAWING`, accept=`.stp,.step,.dwg,.dxf,.pdf`) + +**저장**: `assignBom` 재사용 (PR-B5 매퍼 `saveBomAssignment` 1:1). 백엔드는 EBOM/MBOM 분기 이미 지원, 이번에 MBOM UI 진입점도 첫 노출. + +**도면 업로드 차이**: 운영판 `fn_uploadDrawingFiles`는 placeholder ("구현 예정" 토스트). RPS는 공용 `AttachFileDropZone` 재사용해 wace 보다 앞서 실구현. + +**신규 산출물**: + +| 위치 | 역할 | +|---|---| +| `backend-node/src/services/mbomService.ts` | `searchAssignableMboms` / `previewMbomTree` / `getLatestMbomByPartNo` 3 함수 | +| `backend-node/src/controllers/mbomController.ts` | 3 핸들러 추가 | +| `backend-node/src/routes/productionMbomRoutes.ts` | `GET /assignable-mboms`, `GET /mbom-preview/:objid`, `GET /latest-mbom-by-partno/:partNo` | +| `frontend/lib/api/mbom.ts` | 3 메서드 + `AssignableMbomRow` / `LatestMbomByPartNoRow` 타입 | +| `frontend/components/production/BomCopyDialog.tsx` | 신규 다이얼로그 | +| `frontend/app/(main)/COMPANY_16/production/mbom/page.tsx` | [BOM 복사] 버튼 + Dialog 연결 | + +## 7. 백엔드 산출물 ([backend-node/src/](../../../backend-node/src/)) + +| 파일 | 역할 | +|---|---| +| `services/mbomService.ts` | list / getDetail / getTree(4분기) / save / getHistory / searchAssignableEboms / previewEbomTree / assignBom / searchAssignableMboms / previewMbomTree / getLatestMbomByPartNo / createSalesRequest | +| `controllers/mbomController.ts` | 위 모든 핸들러 | +| `routes/productionMbomRoutes.ts` | 위 모든 라우트 | +| `services/prodPlanResultService.ts` | listGeneral (plan-result) + listEquip (plan-result-equip), buildWhere 공유 | +| `controllers/prodPlanResultController.ts` | 위 2 핸들러 | +| `routes/productionPlanResultRoutes.ts` | GET /list, GET /equip/list | +| `services/mbomRequirementService.ts` | listSemi (반제품) + listRaw (원자재), getOptions (M-BOM 셀렉트 + 품명) | + +## 8. 프론트엔드 산출물 ([frontend/](../../../frontend/)) + +| 파일 | 역할 | +|---|---| +| `lib/api/mbom.ts` | 타입 + mbomApi 14 메서드 | +| `lib/api/prodPlanResult.ts` | 일반/장비 list 타입 | +| `components/production/MbomDetailDialog.tsx` | 단건 상세 + 트리 + 편집 + toolbar(변경이력/BOM 할당/본 편집/행 add-del) | +| `components/production/MbomHistoryDialog.tsx` | 변경이력 그리드 | +| `components/production/MbomAddPartDialog.tsx` | 행 추가 시 PART 검색 | +| `components/production/MbomAssignDialog.tsx` | BOM 할당 (운영 mBomEbomSelectPopup 1:1) | +| `components/production/BomCopyDialog.tsx` | **BOM 복사 (PR-B5+ 신규)** | +| `app/(main)/COMPANY_16/production/mbom/page.tsx` | 메인 리스트 + 폴더 컬럼 + 서버 페이지네이션 + [구매리스트 생성] + [BOM 복사] | +| `app/(main)/COMPANY_16/purchase/mbom/page.tsx` | re-export (구매관리 트리 노출) | +| `app/(main)/COMPANY_16/production/plan-result/page.tsx` | 생산계획&실적 | +| `app/(main)/COMPANY_16/production/plan-result-equip/page.tsx` | 장비 | +| `app/(main)/COMPANY_16/production/semi-product-req/page.tsx` | 반제품 소요량 | +| `app/(main)/COMPANY_16/production/raw-material-req/page.tsx` | 원자재 소요량 | + +## 9. 작업 원칙 (도메인 공통) + +- 운영판 1:1 정확 일치 — `waceplm.esgrin.com` 진실의 기준 +- JSP/매퍼/Java `/* */` · `` · `//` 비활성 보존 — 이식 대상 아님 +- bigint=varchar 함정: `ATTACH_FILE_INFO` 서브쿼리는 `P.OBJID::varchar = F.TARGET_OBJID` 캐스트 필수 +- numeric 캐스팅: RPS `mbom_detail.qty/required_qty` 는 `numeric(15,4)`. wace 의 `NULLIF(x,'')::INTEGER` 패턴 적용 시 "invalid input syntax" 발생 → `COALESCE(x, 0)::INTEGER` 패턴 사용 (c83a73a1) +- 메뉴 등록은 menu_info 직접 UPDATE (data-sync 스크립트 동반) +- 다른 세션 작업물은 따로 커밋 — working tree 에 보여도 묶지 말 것 +- 공용 컴포넌트 의무: PageHeader + CompactFilterBar + SmartSelect/CustomerSelect + DataGrid + +## 10. 다음 작업 후보 + +1. **plan-result 액션 모달** — [생산계획 생성](`prodPlanFormPopup.jsp`) / [생산실적 등록](`prodResultFormPopup.jsp`) 1:1 이식 +2. **공통 PartSelect 컴포넌트** — wace `Select2-part`(AJAX 자동완성) 복원, 4개 메뉴 공통 적용 +3. **PR-B6** — Excel Upload/Download (운영판 mBomFormPopup 의 Excel 버튼 2종) +4. **PR-B7** — 행 순서 변경 (up/down) + 부모 변경 (drag drop) +5. **품질관리 후속** — chpark 가 베이스만 짠 4개 메뉴(incoming/process/semi-product inspection) 상세화 (별 PR) +6. **자재관리 도메인 진입** — 별 PR From 748fde3d3dbff131ecfbd74ce31f13867229093d Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 11:57:33 +0900 Subject: [PATCH 4/8] =?UTF-8?q?docs(migration):=2027=EA=B0=9C=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=ED=86=B5=ED=95=A9=20=EC=A7=84=ED=96=89=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20README=20=E2=80=94=206=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=A7=A4=ED=8A=B8=EB=A6=AD=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영업4 + 구매요청2 + 프로젝트2 + 개발5 + 구매9 + 생산5 = 27개 메뉴 한눈에: - 매트릭스: ✅완료17 / 🟡베이스7 / 🟠빈그리드3 - 도메인별 진행 상태 + 대표 커밋 + 상세문서 링크 - 공통 인프라(공용 컴포넌트/마이그레이션 패턴/결재/채번) - 핵심 정책 8종 + 도메인 함정 8종 - 다음 작업 우선순위 6종 + 인덱스 품질관리(chpark 베이스, d7c645d2)는 부록으로만 언급. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/migration/README.md | 140 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/migration/README.md diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 00000000..fdcba2ed --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,140 @@ +# vexplor_rps 이식 통합 진행 상태 + +> 작성: 2026-05-15 / 작성자: hjjeong +> 대상: vexplor_rps (RPS 전용 분기, COMPANY_16 단독) +> 원본: wace_plm (Java 7 / Spring 3.2.4 / JSP / MyBatis) — `waceplm.esgrin.com` 운영판이 1순위 진실 + +## 0. 전체 한눈에 보기 + +**6개 도메인 / 27개 메뉴** 진행 중. (영업 4 + 구매요청 2 + 프로젝트 2 + 개발 5 + 구매 9 + 생산 5). + +| 상태 | 메뉴 수 | 의미 | +|---|---|---| +| ✅ 완료 | 17 | wace 1:1 검증 PASS 또는 마이너 차이만 (기능/SQL 일치) | +| 🟡 베이스 | 7 | 그리드/검색 완료, 액션 모달 또는 detail SQL 일부 미진 | +| 🟠 빈 그리드 | 3 | 화면은 있으나 데이터 SQL 미연결 (의존 테이블 DDL 추출 선행 필요) | +| 🔴 미진 | 0 | — | + +## 1. 27개 메뉴 매트릭스 + +| # | 도메인 | 메뉴명 | RPS 위치 | 상태 | 핵심 미진 | +|---|---|---|---|---|---| +| 1 | 영업관리 | 견적관리 | `sales/estimate` | ✅ | — | +| 2 | 영업관리 | 주문서관리 | `sales/order` | ✅ | — | +| 3 | 영업관리 | 판매관리 | `sales/sale` | ✅ | — | +| 4 | 영업관리 | 매출관리 | `sales/revenue` | ✅ | — | +| 5 | 영업/구매요청 | 구매요청서관리 | `purchase-request/request` (이동 중) | 🟡 | 구매요청서작성 다이얼로그 · 품의서생성 액션 | +| 6 | 영업/구매요청 | 품의서관리(영업) | `purchase-request/proposal` (이동 중) | 🟡 | Amaranth 결재상신 (`target_type='PROPOSAL'`, `formId='1163'`) | +| 7 | 프로젝트관리 | 진행관리 | `project/progress` | ✅ | — | +| 8 | 프로젝트관리 | 제품구분_WBS관리 | `project/wbs-template` | ✅ | — | +| 9 | 개발관리 | PART 등록 | `development/part-regist` | ✅ | — | +| 10 | 개발관리 | PART 조회 | `development/part-search` | ✅ | — | +| 11 | 개발관리 | E-BOM 등록 | `development/ebom-regist` | ✅ | — | +| 12 | 개발관리 | E-BOM 조회 | `development/ebom-search` | ✅ | — | +| 13 | 개발관리 | 설계변경 리스트 | `development/change-list` | ✅ | — | +| 14 | 구매관리 | 구매리스트 | `purchase/list` | 🟡 | detail SQL 보강 | +| 15 | 구매관리 | 품의서(구매) | `purchase/proposal` | 🟡 | 발주서 생성 액션 | +| 16 | 구매관리 | 프로젝트 현황 | `purchase/project-status` | 🟡 | — | +| 17 | 구매관리 | 견적요청 | `purchase/quote-request` | 🟠 | `sales_request_part` DDL 추출 + detail SQL | +| 18 | 구매관리 | 입고관리 | `purchase/inbound` | 🟠 | inbound detail SQL | +| 19 | 구매관리 | 입고관리-일자별 | `purchase/inbound-by-date` | 🟠 | inbound detail SQL | +| 20 | 구매관리 | 입고관리-품목별 | `purchase/inbound-by-item` | 🟠 | inbound detail SQL | +| 21 | 구매관리 | 발주관리 | `purchase/order` | ✅ | — | +| 22 | 구매관리 | M-BOM (중복) | `purchase/mbom` | ✅ | `production/mbom` re-export | +| 23 | 생산관리 | M-BOM 관리 | `production/mbom` | ✅ PR-B5+ | PR-B6 Excel / PR-B7 행이동 (후순위) | +| 24 | 생산관리 | 생산계획&실적 | `production/plan-result` | 🟡 | [생산계획 생성] · [생산실적 등록] 모달 | +| 25 | 생산관리 | 생산계획&실적(장비) | `production/plan-result-equip` | ✅ | WBS할당 모달 의도적 보류 | +| 26 | 생산관리 | 반제품 소요량 | `production/semi-product-req` | ✅ | 단위/소재/규격 3컬럼 UX 추가 (의도적) | +| 27 | 생산관리 | 원자재 소요량 | `production/raw-material-req` | ✅ | — | + +## 2. 도메인별 진행 상태 + +| 도메인 | 메뉴 | 마감도 | 대표 커밋 | 상세 문서 | +|---|---|---|---|---| +| **영업관리** | 4 | 100% (G6 메일 발송까지) | (다수) | [sales/README.md](./sales/README.md) | +| **구매요청** (영업↔구매 교차) | 2 | 베이스 (액션 모달 미진) | `7e7c6a0a` | [sales/09-purchase-request.md](./sales/09-purchase-request.md) | +| **프로젝트관리** | 2 | 100% | `a1ace226` / `332688a4` / `7c4817b0` / `50669a66` | [project/00-gap.md](./project/00-gap.md) | +| **개발관리** | 5 | 100% + Import + 도면 다중 업로드 (16 커밋) | (PR-A/B/C 다수) | [development/00-gap.md](./development/00-gap.md) | +| **구매관리** | 9 | 1차 스캐폴드 (마스터 3 데이터 노출 / detail 4 빈 그리드 / 발주·M-BOM 완료) | `b38f5957` | (메모리만) | +| **생산관리** | 5 | 100% + PR-B5+ BOM 복사 다이얼로그 | `bd47ca80` / `63682587` | [production/README.md](./production/README.md) | + +부록 — **품질관리**: chpark 가 베이스 4메뉴(`incoming-request/incoming-mgmt/process-inspection/semi-product-inspection`) + 고객CS/ECR 리팩토링 (`d7c645d2`). 후속 상세화 별 PR. + +## 3. 공통 인프라 (도메인 가로지름) + +### 3.1 공용 컴포넌트 (의무 사용) +- **PageHeader** — 메뉴명 자동 매칭, 우측 액션 영역, 조회/초기화 표준 위치 +- **CompactFilterBar** + **CompactFilterField** + **CompactDateRange** — 컴팩트 검색 그리드 +- **SmartSelect** / **CustomerSelect** — native ` setForm({ ...form, delivery_request_date: e.target.value })} /> + + + +
+
+
+ 품목 ({parts.length}건) + {form.project_no ? ( + — 선택 가능 품번 {partOpts.length}건 + ) : null} +
+
+ +
+
+
+ + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((r) => ( + + + + + + + + + ))} + +
품번품명수량공급업체단가
+ {form.project_no + ? "[행추가] 버튼을 눌러 품번을 선택해주세요." + : "먼저 프로젝트번호를 선택해주세요."} +
+ onPartSelect(r.rowKey, v)} + placeholder={partOpts.length === 0 ? "M-BOM 품목 없음" : "선택"} /> + {r.part_name || ""} + updateRow(r.rowKey, { qty: e.target.value })} /> + + updateRow(r.rowKey, { partner_objid: v })} /> + + updateRow(r.rowKey, { partner_price: e.target.value })} /> + + +
+
+
+ + + + + + + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function toSmart(o: OptionItem): SmartSelectOption { + return { code: o.code, label: o.label }; +} diff --git a/frontend/lib/api/salesPurchaseRequest.ts b/frontend/lib/api/salesPurchaseRequest.ts index a64a2d56..2b77b8d5 100644 --- a/frontend/lib/api/salesPurchaseRequest.ts +++ b/frontend/lib/api/salesPurchaseRequest.ts @@ -35,7 +35,114 @@ async function getList( return res.data?.data as SalesPurchaseRequestListResponse; } +export interface PurchaseRequestPartInput { + objid?: string; + part_objid: string; + part_name?: string; + qty?: string | number; + org_qty?: string | number; + partner_objid?: string; + partner_price?: string | number; + delivery_request_date?: string; + status?: string; +} + +export interface SavePurchaseRequestPayload { + objid?: string; + project_no?: string; + mbom_header_objid?: string; + purchase_type?: string; + order_type?: string; + product_name?: string; + area_cd?: string; + customer_objid?: string; + paid_type?: string; + delivery_request_date?: string; + parts: PurchaseRequestPartInput[]; +} + +export interface MbomPartItem { + mbom_detail_objid: string; + part_objid: string; + mbom_header_objid: string; + part_no: string; + part_name: string; + unit: string; + qty: number; + unit_price: number; + vendor_objid: string; + vendor_name: string; +} + +export interface ProjectAutoFillInfo { + objid: string; + project_no: string; + project_name: string; + category_cd: string | null; + category_name: string | null; + customer_objid: string | null; + customer_name: string | null; + product: string | null; + product_name: string | null; + area_cd: string | null; + area_name: string | null; + paid_type: string | null; + contract_objid: string | null; + mbom_header_objid: string | null; +} + +export interface ProposalTargetPart { + objid: string; + part_objid: string; + part_no: string; + part_name: string; + qty: string; + unit_price: number; + total_price: number | null; + vendor_pm: string; + vendor_name: string; +} + export const salesPurchaseRequestApi = { listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f), listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f), + + async getProjectAutoFill(projectObjid: string): Promise { + const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`); + return (res.data?.data ?? null) as ProjectAutoFillInfo | null; + }, + + async listMbomParts(projectObjid: string): Promise { + const res = await apiClient.get("/sales/purchase-request/mbom-parts", { + params: { project_objid: projectObjid }, + }); + return (res.data?.data ?? []) as MbomPartItem[]; + }, + + async getDetail(objid: string): Promise<{ header: any; parts: any[] }> { + const res = await apiClient.get(`/sales/purchase-request/${objid}`); + return res.data?.data as { header: any; parts: any[] }; + }, + + async getProposalTargets(objid: string): Promise<{ targets: ProposalTargetPart[]; excluded: ProposalTargetPart[] }> { + const res = await apiClient.get(`/sales/purchase-request/${objid}/proposal-targets`); + return res.data?.data as { targets: ProposalTargetPart[]; excluded: ProposalTargetPart[] }; + }, + + async save(payload: SavePurchaseRequestPayload): Promise<{ objid: string; request_mng_no: string | null; isNew: boolean }> { + const res = await apiClient.post("/sales/purchase-request", payload); + return res.data?.data; + }, + + async createProposal(srmObjid: string): Promise<{ proposal_objid: string; proposal_no: string; part_count: number }> { + const res = await apiClient.post(`/sales/purchase-request/${srmObjid}/proposal`, {}); + return res.data?.data; + }, + + async startApproval(proposalObjid: string, opts: { approvalTitle?: string; subjectStr?: string } = {}): Promise<{ + fullUrl: string; approKey: string; status: string; proposalObjid: string; + }> { + const res = await apiClient.post(`/sales/purchase-proposal/${proposalObjid}/approval`, opts); + return res.data?.data; + }, }; From 1fb438bdcb4cc069128edba31e14912841084ebc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 14:40:28 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20DateInput=20+=20DataGr?= =?UTF-8?q?id=20=ED=97=A4=EB=8D=94=20=EA=B0=80=EB=8F=85=EC=84=B1=20+=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9A=94=EC=B2=AD=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EB=AA=A8=EB=93=9C/=EA=B3=B5=EA=B8=89=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=EC=98=B5=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 DateInput (YYYY-MM-DD 통일): text input + Popover Calendar, 숫자 8자리 자동 - 삽입. CompactDateRange / 다이얼로그 입고요청일 적용. - DataGrid 헤더 라벨 truncate + TableHead 패딩 축소(!px-1.5): 좁은 컬럼에서 라벨 겹침/잘림 해소. - 구매요청서관리 그리드 컬럼 너비 합리화 (총 ~300px 절감)로 품명까지 화면 안에 표시. - 구매요청서 수정모드: 선택 1건 시 [구매요청서수정] 분기 → getDetail 로 헤더/라인 채워 다이얼로그 오픈. 확정·품의서생성 가드. - 공급업체 옵션을 client_mng 기반 listVendorOptions 로 신설 (운영 supply_mng=0 / client_mng=8946, M-BOM vendor 매칭). - 주문유형 CommCodeSelect groupId 0000005 → 0000167 (계약구분). - 고객사 셀렉트 → CustomerSelect 공용 컴포넌트로 교체. - 그리드 delivery_request_date 점 형식 → YYYY-MM-DD 정규화. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/salesPurchaseRequestRoutes.ts | 10 ++ .../services/salesPurchaseRequestService.ts | 21 +++ .../purchase-request/request/page.tsx | 54 ++++-- .../components/common/CompactFilterBar.tsx | 17 +- frontend/components/common/DataGrid.tsx | 8 +- frontend/components/common/DateInput.tsx | 165 ++++++++++++++++++ .../sales/PurchaseRequestFormDialog.tsx | 81 +++++++-- frontend/lib/api/salesPurchaseRequest.ts | 5 + 8 files changed, 312 insertions(+), 49 deletions(-) create mode 100644 frontend/components/common/DateInput.tsx diff --git a/backend-node/src/routes/salesPurchaseRequestRoutes.ts b/backend-node/src/routes/salesPurchaseRequestRoutes.ts index 606029ca..9362d410 100644 --- a/backend-node/src/routes/salesPurchaseRequestRoutes.ts +++ b/backend-node/src/routes/salesPurchaseRequestRoutes.ts @@ -54,6 +54,16 @@ function handleError(res: Response, e: any, label: string) { router.get("/purchase-request", (req, res) => run(svc.listPurchaseRequestReg, req as AuthenticatedRequest, res, "구매요청서관리")); router.get("/purchase-proposal", (req, res) => run(svc.listPurchaseRegProposal, req as AuthenticatedRequest, res, "영업>품의서관리")); +// 공급업체 옵션 (client_mng 기반 — vendor/partner 직접 OBJID) +router.get("/purchase-request/vendors", async (_req, res) => { + try { + const data = await svc.listVendorOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + return handleError(res, e, "공급업체 옵션"); + } +}); + // 프로젝트 자동채움 정보 (주문유형/제품구분/국내외/고객사/유무상 + mbom_header_objid) router.get("/purchase-request/project-info/:projectObjid", async (req, res) => { try { diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts index 34286926..f61c5388 100644 --- a/backend-node/src/services/salesPurchaseRequestService.ts +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -336,6 +336,27 @@ export async function getPurchaseRequestDetail(srmObjid: string) { return { header: headRes.rows[0], parts: partRes.rows }; } +// ─── 3-0) 공급업체 옵션 — client_mng 기반 ────────────────────── +// wace partMng 의 fnc_getClientMngListAppend 와 동일. +// M-BOM.vendor / sales_request_part.partner_objid 는 'C_' prefix 없이 client_mng.OBJID 직접 저장. +// → 옵션 코드는 OBJID 그대로(접두 X). 기존 listSupplierOptions(supply_mng)는 다른 메뉴 호환용으로 유지. +export async function listVendorOptions(): Promise<{ code: string; label: string }[]> { + const pool = getPool(); + try { + const r = await pool.query( + `SELECT OBJID::VARCHAR AS code, CLIENT_NM AS label + FROM CLIENT_MNG + WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE') + AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> '' + ORDER BY CLIENT_NM`, + ); + return r.rows; + } catch (e: any) { + logger.error("listVendorOptions 실패", { error: e.message }); + return []; + } +} + // ─── 3-1) 프로젝트 자동채움 정보 (wace purchaseOrderAdminSupplyInfo 1:1) ─ // 프로젝트 선택 시 주문유형(CATEGORY_CD) · 제품구분(PRODUCT) · 국내/해외(AREA_CD) · // 고객사(CUSTOMER_OBJID) · 유/무상(PAID_TYPE) 자동 채움 + M-BOM 헤더. diff --git a/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx b/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx index 60c2b1d9..c53ff04c 100644 --- a/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx @@ -41,6 +41,7 @@ export default function PurchaseRequestRegPage() { const [filter, setFilter] = useState(EMPTY_FILTER); const [checkedIds, setCheckedIds] = useState([]); const [formOpen, setFormOpen] = useState(false); + const [editObjid, setEditObjid] = useState(undefined); const [proposalOpen, setProposalOpen] = useState(false); const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); @@ -90,20 +91,20 @@ export default function PurchaseRequestRegPage() { })), [rows]); const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ - { key: "request_mng_no", label: "요청번호", width: "w-[150px]", align: "center" }, - { key: "purchase_type_name", label: "구매유형", width: "w-[110px]", align: "center" }, - { key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" }, - { key: "order_type_name", label: "주문유형", width: "w-[110px]", align: "center" }, - { key: "product_name_full", label: "제품구분", width: "w-[110px]", align: "center" }, - { key: "customer_name", label: "고객사", width: "w-[160px]" }, - { key: "paid_type_name", label: "유/무상", width: "w-[90px]", align: "center" }, - { key: "part_display", label: "품번", width: "w-[160px]" }, - { key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" }, - { key: "has_purchase_request_label",label: "구매요청서", width: "w-[110px]", align: "center" }, - { key: "request_user_name", label: "작성자", width: "w-[120px]", align: "center" }, - { key: "delivery_request_date", label: "입고요청일", width: "w-[120px]", align: "center" }, - { key: "regdate_title", label: "작성일", width: "w-[110px]", align: "center" }, - { key: "status_title", label: "상태", width: "w-[110px]", align: "center" }, + { key: "request_mng_no", label: "요청번호", width: "w-[130px]", align: "center" }, + { key: "purchase_type_name", label: "구매유형", width: "w-[90px]", align: "center" }, + { key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" }, + { key: "order_type_name", label: "주문유형", width: "w-[80px]", align: "center" }, + { key: "product_name_full", label: "제품구분", width: "w-[90px]", align: "center" }, + { key: "customer_name", label: "고객사", width: "w-[140px]" }, + { key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" }, + { key: "part_display", label: "품번", width: "w-[140px]" }, + { key: "part_name_display", label: "품명", minWidth: "min-w-[180px]" }, + { key: "has_purchase_request_label",label: "구매요청서", width: "w-[80px]", align: "center" }, + { key: "request_user_name", label: "작성자", width: "w-[100px]", align: "center" }, + { key: "delivery_request_date", label: "입고요청일", width: "w-[100px]", align: "center" }, + { key: "regdate_title", label: "작성일", width: "w-[100px]", align: "center" }, + { key: "status_title", label: "상태", width: "w-[80px]", align: "center" }, ]), []); const summary = useMemo(() => { @@ -131,14 +132,30 @@ export default function PurchaseRequestRegPage() { setProposalOpen(true); }; + // 선택 1건 + 미확정·미상신 → 수정모드 / 그 외(미선택) → 신규 + const handleOpenForm = () => { + if (selectedSrm) { + if (selectedSrm.status_title === "품의서생성") { + return toast.info("이미 품의서가 생성된 항목은 수정할 수 없습니다."); + } + if (selectedSrm.status_title === "확정") { + return toast.info("확정된 구매요청서는 수정할 수 없습니다."); + } + setEditObjid(selectedSrm.objid); + } else { + setEditObjid(undefined); + } + setFormOpen(true); + }; + return (
+ )} + + + + + + + + +
+ ); +} diff --git a/frontend/components/sales/PurchaseRequestFormDialog.tsx b/frontend/components/sales/PurchaseRequestFormDialog.tsx index 10b6bb2c..f3432364 100644 --- a/frontend/components/sales/PurchaseRequestFormDialog.tsx +++ b/frontend/components/sales/PurchaseRequestFormDialog.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; +import { DateInput } from "@/components/common/DateInput"; import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { salesPurchaseRequestApi, @@ -26,10 +27,14 @@ interface Props { open: boolean; onClose: () => void; onSaved: () => void; + /** 수정 모드 시 기존 SRM OBJID — 미지정이면 신규 등록 */ + srmObjid?: string; } interface FormState { - project_no: string; // PROJECT_MGMT.OBJID + objid?: string; // 수정 모드 시 기존 OBJID + request_mng_no?: string; // 수정 모드 표시용 + project_no: string; // PROJECT_MGMT.OBJID mbom_header_objid: string; purchase_type: string; order_type: string; @@ -55,7 +60,8 @@ const EMPTY_FORM: FormState = { let _rk = 0; const nextKey = () => `r${++_rk}_${Date.now()}`; -export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { +export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: Props) { + const isEdit = !!srmObjid; const [form, setForm] = useState(EMPTY_FORM); const [parts, setParts] = useState([]); const [saving, setSaving] = useState(false); @@ -67,7 +73,7 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { // 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션) const [mbomItems, setMbomItems] = useState([]); - // 모달 열릴 때 옵션 1회 로드 + 폼 초기화 + // 모달 열릴 때 옵션 로드 + (수정모드면) 상세 로드, (신규면) 폼 초기화 useEffect(() => { if (!open) return; setForm(EMPTY_FORM); @@ -75,17 +81,51 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { setMbomItems([]); (async () => { try { - const [proj, suppliers] = await Promise.all([ + const [proj, vendors] = await Promise.all([ purchaseApi.listProjects(), - purchaseApi.listSuppliers(), + salesPurchaseRequestApi.listVendors(), // client_mng 기반 (M-BOM vendor / partner 매칭용) ]); setProjectOpts(proj.map(toSmart)); - setSupplierOpts(suppliers.map(toSmart)); + setSupplierOpts(vendors.map((v) => ({ code: v.code, label: v.label }))); + + if (srmObjid) { + const detail = await salesPurchaseRequestApi.getDetail(srmObjid); + const h = detail.header ?? {}; + const projectObjid = String(h.project_no ?? ""); + // 수정 모드 → M-BOM 풀도 함께 로드 (품번 셀렉트 옵션) + const items = projectObjid + ? await salesPurchaseRequestApi.listMbomParts(projectObjid) + : []; + setMbomItems(items ?? []); + setForm({ + objid: String(h.objid ?? ""), + request_mng_no: h.request_mng_no ?? "", + project_no: projectObjid, + mbom_header_objid: String(h.mbom_header_objid ?? items?.[0]?.mbom_header_objid ?? ""), + purchase_type: h.purchase_type ?? "", + order_type: h.order_type ?? h.category_cd ?? "", + product_name: h.product_name ?? "", + area_cd: h.area_cd ?? "", + customer_objid: h.customer_objid ?? "", + paid_type: h.paid_type ?? "", + delivery_request_date: normalizeDate(h.delivery_request_date), + }); + setParts((detail.parts ?? []).map((p: any) => ({ + rowKey: nextKey(), + objid: p.objid, + part_objid: p.part_objid, + part_no: p.part_no, + part_name: p.part_name, + qty: p.qty, + partner_objid: p.partner_objid ?? "", + partner_price: p.partner_price ?? p.unit_price ?? "", + }))); + } } catch (e: any) { toast.error(`옵션 로드 실패: ${e?.message ?? ""}`); } })(); - }, [open]); + }, [open, srmObjid]); // 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + M-BOM 품번 풀 갱신 const onProjectChange = useCallback(async (newProjectObjid: string) => { @@ -176,12 +216,14 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { } setSaving(true); try { + const { request_mng_no, ...rest } = form; // request_mng_no 는 표시 전용, 백엔드 미전송 + void request_mng_no; const payload = { - ...form, - parts: parts.map(({ rowKey, part_no, ...rest }) => rest), // eslint-disable-line @typescript-eslint/no-unused-vars + ...rest, + parts: parts.map(({ rowKey, part_no, ...partRest }) => partRest), // eslint-disable-line @typescript-eslint/no-unused-vars }; const res = await salesPurchaseRequestApi.save(payload); - toast.success(`저장되었습니다. (${res.request_mng_no ?? res.objid})`); + toast.success(`${isEdit ? "수정" : "저장"}되었습니다. (${res.request_mng_no ?? form.request_mng_no ?? res.objid})`); onSaved(); onClose(); } catch (e: any) { @@ -195,7 +237,7 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { { if (!o) onClose(); }}> - 구매요청서 작성 + {isEdit ? `구매요청서 수정${form.request_mng_no ? ` — ${form.request_mng_no}` : ""}` : "구매요청서 작성"} 프로젝트 선택 시 주문유형/제품구분/국내외/고객사/유무상이 자동 채워집니다. 품번은 행추가에서 선택하세요. @@ -233,8 +275,8 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { onValueChange={(v) => setForm({ ...form, paid_type: v })} /> - setForm({ ...form, delivery_request_date: e.target.value })} /> + setForm({ ...form, delivery_request_date: v })} /> @@ -333,3 +375,16 @@ function Field({ label, children }: { label: string; children: React.ReactNode } function toSmart(o: OptionItem): SmartSelectOption { return { code: o.code, label: o.label }; } + +// 운영 데이터에 'YYYY.MM.DD' 또는 'YYYY/MM/DD' 가 섞여 있을 수 있어 DateInput 입력 형식으로 정규화 +function normalizeDate(v: any): string { + if (!v) return ""; + const s = String(v).trim(); + if (!s) return ""; + const m = s.match(/^(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})/); + if (!m) return ""; + const yyyy = m[1]; + const mm = m[2].padStart(2, "0"); + const dd = m[3].padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} diff --git a/frontend/lib/api/salesPurchaseRequest.ts b/frontend/lib/api/salesPurchaseRequest.ts index 2b77b8d5..805bd701 100644 --- a/frontend/lib/api/salesPurchaseRequest.ts +++ b/frontend/lib/api/salesPurchaseRequest.ts @@ -107,6 +107,11 @@ export const salesPurchaseRequestApi = { listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f), listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f), + async listVendors(): Promise> { + const res = await apiClient.get("/sales/purchase-request/vendors"); + return (res.data?.data ?? []) as Array<{ code: string; label: string }>; + }, + async getProjectAutoFill(projectObjid: string): Promise { const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`); return (res.data?.data ?? null) as ProjectAutoFillInfo | null; From 0fe71298d23dd9c4f971484613894a1cbd44f032 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 14:52:50 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20NumberInput=20+=20?= =?UTF-8?q?=EC=88=AB=EC=9E=90=20=ED=8F=AC=EB=A7=B7=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(=EC=88=98=EB=9F=89=201,234=20/=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=201,234.00)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NumberInput 공용 컴포넌트: blur 시 콤마+소수점 자릿수 강제, focus 시 raw 숫자로 전환되어 자유 편집, 잘못된 입력은 이전 값 유지. - 다이얼로그 수량/단가 input → NumberInput 으로 교체. - 백엔드 정규화 — M-BOM/detail/proposal-targets: qty=FLOOR()::INTEGER, unit_price/partner_price/total_price=NUMERIC(18,2) (운영 sales_request_part 는 정수 String 이지만 M-BOM production_qty NUMERIC(15,4) 가 흘러들어와 '4.0000' 노출되던 문제 차단). - ProposalCreateDialog fmt: Math.floor 후 ko-KR toLocaleString. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/salesPurchaseRequestService.ts | 20 +-- frontend/components/common/NumberInput.tsx | 119 ++++++++++++++++++ .../components/sales/ProposalCreateDialog.tsx | 5 +- .../sales/PurchaseRequestFormDialog.tsx | 19 +-- 4 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 frontend/components/common/NumberInput.tsx diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts index f61c5388..bc6b35e0 100644 --- a/backend-node/src/services/salesPurchaseRequestService.ts +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -321,10 +321,10 @@ export async function getPurchaseRequestDetail(srmObjid: string) { `SELECT SRP.OBJID, SRP.PART_OBJID, COALESCE(PM.PART_NO, '') AS PART_NO, COALESCE(PM.PART_NAME, '') AS PART_NAME, - COALESCE(SRP.QTY, '0') AS QTY, + COALESCE(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY, SRP.ORG_QTY, SRP.PARTNER_OBJID, - COALESCE(SRP.PARTNER_PRICE, '') AS PARTNER_PRICE, - COALESCE(SRP.UNIT_PRICE, 0) AS UNIT_PRICE, + COALESCE(NULLIF(SRP.PARTNER_PRICE, '')::NUMERIC, 0)::NUMERIC(18,2) AS PARTNER_PRICE, + COALESCE(SRP.UNIT_PRICE, 0)::NUMERIC(18,2) AS UNIT_PRICE, COALESCE(SRP.VENDOR_PM, '') AS VENDOR_PM, SRP.DELIVERY_REQUEST_DATE, SRP.STATUS, SRP.PROPOSAL_DATE FROM SALES_REQUEST_PART SRP @@ -346,7 +346,7 @@ export async function listVendorOptions(): Promise<{ code: string; label: string const r = await pool.query( `SELECT OBJID::VARCHAR AS code, CLIENT_NM AS label FROM CLIENT_MNG - WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE') + WHERE COALESCE(USE_YN, '1') IN ('1', 'Y', 'y') AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> '' ORDER BY CLIENT_NM`, ); @@ -410,8 +410,8 @@ export async function listMbomPartsForProject(projectObjid: string) { COALESCE(PM.PART_NO, '') AS part_no, COALESCE(PM.PART_NAME, '') AS part_name, COALESCE(MD.UNIT, '') AS unit, - COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0) AS qty, - COALESCE(MD.UNIT_PRICE, 0) AS unit_price, + COALESCE(FLOOR(COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0))::INTEGER, 0) AS qty, + COALESCE(MD.UNIT_PRICE, 0)::NUMERIC(18,2) AS unit_price, COALESCE(MD.VENDOR, '') AS vendor_objid, CASE WHEN MD.VENDOR IS NULL OR MD.VENDOR = '' THEN '' @@ -558,9 +558,9 @@ export async function getProposalTargetParts(srmObjid: string) { SRP.OBJID, SRP.PART_OBJID, COALESCE(PM.PART_NO, '') AS PART_NO, COALESCE(PM.PART_NAME, '') AS PART_NAME, - COALESCE(SRP.QTY, '0') AS QTY, - COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0) AS UNIT_PRICE, - SRP.TOTAL_PRICE, + COALESCE(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY, + COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0)::NUMERIC(18,2) AS UNIT_PRICE, + SRP.TOTAL_PRICE::NUMERIC(18,2) AS TOTAL_PRICE, COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID, '') AS VENDOR_PM, CASE WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) IS NULL THEN '' @@ -587,7 +587,7 @@ export async function getProposalTargetParts(srmObjid: string) { SRP.OBJID, SRP.PART_OBJID, COALESCE(PM.PART_NO,'') AS PART_NO, COALESCE(PM.PART_NAME,'') AS PART_NAME, - COALESCE(SRP.QTY,'0') AS QTY + COALESCE(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY FROM SALES_REQUEST_PART SRP LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 diff --git a/frontend/components/common/NumberInput.tsx b/frontend/components/common/NumberInput.tsx new file mode 100644 index 00000000..fd098ab9 --- /dev/null +++ b/frontend/components/common/NumberInput.tsx @@ -0,0 +1,119 @@ +"use client"; + +/** + * NumberInput — 숫자 표시 통일 공용 컴포넌트 (RPS 숫자 포맷 정책) + * + * - 금액(decimals=2): 1,234.00 + * - 수량(decimals=0): 1,234 + * - 표시: 콤마 + 소수점 자릿수 강제 (blur 시 정규화) + * - 편집: focus 시 raw 숫자 ("1234.5")로 전환되어 자유 입력 → blur 시 1,234.50 으로 재포맷 + * - onChange 는 항상 number(또는 빈 문자열) 만 부모로 전달 + */ + +import React, { useEffect, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +export interface NumberInputProps { + value: number | string | null | undefined; + onChange: (v: number | "") => void; + decimals?: number; // 기본 0 (수량). 금액은 2. + min?: number; + max?: number; + disabled?: boolean; + placeholder?: string; + className?: string; + /** "right"=금액·수량 기본, 그 외도 가능 */ + align?: "right" | "left" | "center"; +} + +function toNumOrEmpty(v: any): number | "" { + if (v === "" || v == null) return ""; + const n = Number(v); + return Number.isFinite(n) ? n : ""; +} + +function formatFor(v: number | "", decimals: number): string { + if (v === "") return ""; + return v.toLocaleString("ko-KR", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +export function NumberInput({ + value, + onChange, + decimals = 0, + min, + max, + disabled, + placeholder, + className, + align = "right", +}: NumberInputProps) { + const num = toNumOrEmpty(value); + const [focused, setFocused] = useState(false); + const [draft, setDraft] = useState(""); + const inputRef = useRef(null); + + // 외부 value 변경 시 draft 동기화 (focus 중 외부 강제 갱신 시도는 무시 — 자연스러운 편집) + useEffect(() => { + if (!focused) setDraft(""); + }, [num, focused]); + + const displayValue = focused + ? draft + : (num === "" ? "" : formatFor(num, decimals)); + + const onFocus = (e: React.FocusEvent) => { + setFocused(true); + setDraft(num === "" ? "" : String(num)); // raw 숫자 (콤마 X) + // 전체 선택 — 빠른 재입력 편의 + requestAnimationFrame(() => e.target.select()); + }; + + const onBlur = () => { + setFocused(false); + if (draft.trim() === "") { + if (num !== "") onChange(""); + return; + } + // 콤마/공백 제거 후 숫자화 + const cleaned = draft.replace(/,/g, "").trim(); + let n = Number(cleaned); + if (!Number.isFinite(n)) { + // 잘못된 입력 → 이전 값 유지 + return; + } + if (decimals === 0) n = Math.floor(n); + else n = Number(n.toFixed(decimals)); + if (min != null && n < min) n = min; + if (max != null && n > max) n = max; + onChange(n); + }; + + const onChangeInner = (e: React.ChangeEvent) => { + // 편집 중엔 자유 입력 허용 (콤마·소수점·- 모두 허용 — blur 시 정규화) + setDraft(e.target.value); + }; + + return ( + 0 ? "decimal" : "numeric"} + disabled={disabled} + placeholder={placeholder} + value={displayValue} + onFocus={onFocus} + onBlur={onBlur} + onChange={onChangeInner} + className={cn( + align === "right" && "text-right", + align === "center" && "text-center", + className, + )} + /> + ); +} diff --git a/frontend/components/sales/ProposalCreateDialog.tsx b/frontend/components/sales/ProposalCreateDialog.tsx index 0aa6ed39..62cdfc19 100644 --- a/frontend/components/sales/ProposalCreateDialog.tsx +++ b/frontend/components/sales/ProposalCreateDialog.tsx @@ -173,9 +173,10 @@ function Section({ title, emptyMsg, children }: { title: React.ReactNode; emptyM ); } +// 수량: 자연수 1,234 / 금액: 1,234.00 (RPS 숫자 포맷 정책) function fmt(n: any) { - const v = Number(n ?? 0); - return v.toLocaleString(); + const v = Math.floor(Number(n ?? 0)); + return v.toLocaleString("ko-KR"); } function fmtMoney(n: any) { const v = Number(n ?? 0); diff --git a/frontend/components/sales/PurchaseRequestFormDialog.tsx b/frontend/components/sales/PurchaseRequestFormDialog.tsx index f3432364..7abddb6f 100644 --- a/frontend/components/sales/PurchaseRequestFormDialog.tsx +++ b/frontend/components/sales/PurchaseRequestFormDialog.tsx @@ -16,6 +16,7 @@ import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect" import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { salesPurchaseRequestApi, @@ -116,9 +117,11 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: part_objid: p.part_objid, part_no: p.part_no, part_name: p.part_name, - qty: p.qty, + qty: String(Math.floor(Number(p.qty ?? 0))), partner_objid: p.partner_objid ?? "", - partner_price: p.partner_price ?? p.unit_price ?? "", + partner_price: p.partner_price != null && Number(p.partner_price) > 0 + ? String(Number(p.partner_price)) + : (p.unit_price != null && Number(p.unit_price) > 0 ? String(Number(p.unit_price)) : ""), }))); } } catch (e: any) { @@ -194,9 +197,9 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: part_objid: hit.part_objid, part_no: hit.part_no, part_name: hit.part_name, - qty: hit.qty > 0 ? String(hit.qty) : "1", + qty: String(Math.max(1, Math.floor(Number(hit.qty ?? 0)))), partner_objid: hit.vendor_objid || "", - partner_price: hit.unit_price > 0 ? String(hit.unit_price) : "", + partner_price: Number(hit.unit_price ?? 0) > 0 ? String(Number(hit.unit_price)) : "", }); }; @@ -326,16 +329,16 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: {r.part_name || ""} - updateRow(r.rowKey, { qty: e.target.value })} /> + updateRow(r.rowKey, { qty: v === "" ? "" : String(v) })} /> updateRow(r.rowKey, { partner_objid: v })} /> - updateRow(r.rowKey, { partner_price: e.target.value })} /> + updateRow(r.rowKey, { partner_price: v === "" ? "" : String(v) })} />