From 332688a441916d31fb90e922dfb8d4d1df66d7a5 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Mon, 11 May 2026 18:47:56 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=A7=84=ED=96=89=EA=B4=80=EB=A6=AC=20P1.5?= =?UTF-8?q?=20=ED=96=89=20=ED=81=B4=EB=A6=AD=20+=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=E2=80=94=20=ED=8C=90=EB=A7=A4=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20(wace=20fn=5FopenSale?= =?UTF-8?q?RegPopup=20=EB=8C=80=EC=9D=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 진행관리 행 클릭 → /COMPANY_16/sales/sale?project_no={PROJECT_NO} 라우팅 · 판매관리 page: useSearchParams로 project_no 자동 필터 + 첫 매칭 행 자동 선택 + 초기화 핸들러 보강 · backend SaleListFilter.project_no 추가 (T.project_no = $1 단일 매칭, 기존 영업 흐름 무영향) · wace 새창 패턴 → RPS SPA 동일 탭 라우팅으로 매핑 (의도 동일) · docs/migration/project/01-progress.md + 01-progress-verify.md 신설 (영업관리 검증 문서 패턴) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/services/salesSaleService.ts | 4 + docs/migration/project/01-progress-verify.md | 181 ++++++++++++++++++ docs/migration/project/01-progress.md | 116 +++++++++++ .../COMPANY_16/project/progress/page.tsx | 9 + .../app/(main)/COMPANY_16/sales/sale/page.tsx | 20 ++ frontend/lib/api/salesSale.ts | 2 + 6 files changed, 332 insertions(+) create mode 100644 docs/migration/project/01-progress-verify.md create mode 100644 docs/migration/project/01-progress.md diff --git a/backend-node/src/services/salesSaleService.ts b/backend-node/src/services/salesSaleService.ts index 14217df5..d2c23b48 100644 --- a/backend-node/src/services/salesSaleService.ts +++ b/backend-node/src/services/salesSaleService.ts @@ -23,6 +23,8 @@ export interface SaleListFilter { salesStatus?: string; // 판매상태 (registered/cancelled 등) productType?: string; // project_mgmt.product (제품구분) nation?: string; // project_mgmt.area_cd (국내/해외) + // 진행관리 행 클릭으로 진입 시 (wace fn_openSaleRegPopup(PROJECT_NO) 대응) + project_no?: string; // project_mgmt.project_no 직접 매칭 // 매출관리 전용 revenueMode?: string; salesDeadlineFrom?: string; @@ -71,6 +73,8 @@ export async function getSaleList(filter: SaleListFilter) { const params: any[] = []; let idx = 1; + // 진행관리 행 클릭 진입용 — wace fn_openSaleRegPopup(PROJECT_NO) 대응 + if (filter.project_no) { conditions.push(`T.project_no = $${idx++}`); params.push(filter.project_no); } if (filter.orderType) { conditions.push(`T.category_cd = $${idx++}`); params.push(filter.orderType); } if (filter.poNo) { conditions.push(`T.po_no ILIKE $${idx++}`); params.push(`%${filter.poNo}%`); } if (filter.customer_objid) { conditions.push(`T.customer_objid = $${idx++}`); params.push(filter.customer_objid); } diff --git a/docs/migration/project/01-progress-verify.md b/docs/migration/project/01-progress-verify.md new file mode 100644 index 00000000..6fc083dc --- /dev/null +++ b/docs/migration/project/01-progress-verify.md @@ -0,0 +1,181 @@ +# 01. 진행관리 wace 1:1 검증 + +> 작성: 2026-05-11 / 사이클: 구조적 검증 1차 +> 목적: wace 운영판 화면(`projectMgmtWbsList3.jsp`)과 RPS 진행관리(`/COMPANY_16/project/progress`)를 데이터 단위로 1:1 일치 확인 + 갭 검증 + 사용자 시나리오 검증. + +## 0. 자격증명 + +``` +RPS : host=211.115.91.141 port=11134 db=vexplor_rps user=postgres pw=vexplor0909!! +운영DB : host=211.115.91.141 port=11133 db=waceplm user=postgres pw=waceplm0909!! +``` + +## 1. 데이터 일치 검증 + +### 1.1 운영판 화면 첫 행 일치 + +운영판 `projectMgmtWbsList3` 그리드 첫 행 (사용자 화면 캡처) 기준: +``` +PROJECT_NO = S-기타-260506-001 +주문유형 = 판매 +제품구분 = 기타 +국내/해외 = 국내 +접수일 = 2026-05-06 +고객사 = 한미반도체주식회사 +유/무상 = 유상 +품번 = 31011-0012 +품명 = CARBON BRUSH SET (HMD V2) +수주수량 = 8 +발주일 = 2026-05-06 +``` + +RPS service SQL 검증: +```sql +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps < 원본: `/project/projectMgmtWbsList3.do` (`projectMgmtWbsList3.jsp` 349줄) + 매퍼 `projectMgmtWbsGridList` (project.xml:3854~4280) +> 대상: `app/(main)/COMPANY_16/project/progress/page.tsx` + `backend-node/src/services/projectMgmtService.ts` +> 작성: 2026-05-11 / 사이클: 구조적 검증 1차 (진행관리 메뉴) + +## 1. 운영판 식별 + +wace_plm 컨트롤러에 진행관리 관련 endpoint 3개 동시 존재: + +| URL | view | 사용처 | +|---|---|---| +| `/project/projectMgmtList.do` (L321) | `projectMgmtList.jsp` | 옛 화면 (사용 안 함) | +| `/project/projectMgmtList1.do` (L399) | `projectMgmtList1.jsp` | 변종 | +| **`/project/projectMgmtWbsList3.do`** (L3243) | **`projectMgmtWbsList3.jsp`** | **RPS 운영판 화면** ✅ | + +> 운영판 화면 캡처에서 컬럼 라벨(`주문유형/품번/품명/S/N/수주수량/E-BOM/M-BOM/발주일/입고율/제조1,2팀/...`)이 일치하는 jsp를 grep으로 역추적해 확정. + +## 2. 항목 매핑 + +### 2.1 검색 폼 — wace 활성 11개 (jsp:222-313) + +| # | wace 라벨 | wace name | RPS filter (`ProgressListFilter`) | 입력 위젯 | 상태 | +|---|---|---|---|---|---| +| 1 | 년도 | `Year` | `Year` | `` × 2 | ✅ | +| 7 | 국내/해외 | `area_cd` | `area_cd` ('국내'/'해외' 라벨) | `` 정적 | ✅ | +| 9 | 품번 | `product_item_code` (select2-part) | `search_partObjId` | `PartSelect mode="partNo"` | 🟡 (영업관리 패턴 통일) | +| 10 | 품명 | `product_item_name` (select2-part) | `search_partObjId` (공유) | `PartSelect mode="partName"` | 🟡 (영업관리 패턴 통일) | +| 11 | S/N | `serial_no` | `serial_no` | `` | ✅ | + +**비활성** (JSP `<%-- --%>` 블록): `location` / `setup` / `pm_user_id` — 무시. + +### 2.2 그리드 컬럼 — wace 8그룹 18셀 → RPS 평탄화 21셀 + +DataGrid 중첩 헤더 미지원으로 그룹명을 라벨 prefix(이슈/원가/...)로 표현. wace SQL `projectMgmtWbsGridList` 활성 본문 1:1. + +| # | wace title | wace field | RPS GRID_COLUMNS key | SQL 출처 | 상태 | +|---|---|---|---|---|---| +| 1 | 프로젝트번호 (frozen) | `PROJECT_NO` | `project_no` | `project_mgmt.project_no` | ✅ | +| 2 | 주문유형 | `CATEGORY_NAME` | `category_name` | `CODE_NAME(category_cd)` | ✅ | +| 3 | 제품구분 | `PRODUCT_NAME` | `product_name` | `CODE_NAME(product)` | ✅ | +| 4 | 국내/해외 | `AREA_NAME` | `area_name` | `CODE_NAME(area_cd)` | ✅ | +| 5 | 접수일 | `REG_DATE` | `reg_date` | `TO_CHAR(regdate,'YYYY-MM-DD')` | ✅ | +| 6 | 고객사 | `CUSTOMER_NAME` | `customer_name` | `customer_mng` LEFT JOIN | ✅ (RPS 매핑) | +| 7 | 유/무상 | `FREE_OF_CHARGE` | `free_of_charge` | `contract_mgmt.paid_type` → '유상'/'무상' | ✅ | +| 8 | 품번 | `PRODUCT_ITEM_CODE` | `product_item_code` | `project_mgmt.part_no` | ✅ | +| 9 | 품명 | `PRODUCT_ITEM_NAME` | `product_item_name` | `project_mgmt.part_name` | ✅ | +| 10 | S/N | `SERIAL_NO` | `serial_no` | `contract_item_serial` 집계 | ✅ | +| 11 | 수주수량 | `CONTRACT_QTY` | `contract_qty` | `project_mgmt.quantity::numeric` | ✅ | +| 12 | 요청납기 | `REQ_DEL_DATE` | `req_del_date` | COALESCE(`contract_item.due_date`, `project_mgmt.due_date`, `contract_mgmt.due_date`) | ✅ | +| 13 | E-BOM | `EBOM_STATUS` | `ebom_status` | `project_mgmt.ebom_status` | ✅ (운영도 대부분 빈값) | +| 14 | M-BOM | `MBOM_STATUS` | `mbom_status` | `project_mgmt.mbom_status` | ✅ | +| 15 | 발주일 | `ORDER_DATE` | `order_date` | `contract_mgmt.order_date` 스칼라 서브쿼리 | ✅ | +| 16 | 입고율 | `RECEIVING_RATE` | `receiving_rate` | `project_mgmt.receiving_rate` | ✅ | +| 17 | 제조1,2팀 | `PRODUCTION_TEAM_12` | `production_team_12` | `project_mgmt.production_team_12` | ✅ | +| 18 | 제조3팀 | `PRODUCTION_TEAM_3` | `production_team_3` | `project_mgmt.production_team_3` | ✅ | +| 19 | 조립 | `ASSEMBLY` | `assembly` | wace SQL에 없음 → NULL | ✅ (운영도 빈값) | +| 20 | 검증 | `VERIFICATION` | `verification` | wace SQL에 없음 → NULL | ✅ | +| 21 | 출하일 | `SHIPMENT_DATE` | `shipment_date` | `sales_registration.shipping_date` (project_no 매칭, MAX sale_no) | ✅ | + +### 2.3 ORDER BY + +```sql +ORDER BY SUBSTRING(project_no, POSITION('-' IN project_no)+1) DESC, + overhaul_order DESC NULLS LAST +``` + +→ "주문유형-제품구분-YYMMDD-NNN" 패턴에서 첫 `-` 이후 부분 내림차순 = 사실상 (제품구분 → YYMMDD → NNN) 역순. + +### 2.4 액션 + +| 동작 | wace | RPS | +|---|---|---| +| 조회 | `btnSearch` → `/projectMgmtWbsGridList.do` | `GET /api/project/progress/list` | +| 프로젝트번호 옵션 | `code_map.project_no = common.getCusProjectNoList` | `GET /api/project/progress/project-no-options` | +| **행 클릭** | `cellClick: fn_openSaleRegPopup(PROJECT_NO, "detail")` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` 새 창 | `router.push('/COMPANY_16/sales/sale?project_no={PROJECT_NO}')` 페이지 라우팅 + 자동 필터/선택 | + +행 클릭 처리는 RPS SPA 패턴에 맞춰 새 창 대신 같은 탭 내 페이지 라우팅 + URL 쿼리로 `project_no` 전달. 판매관리 페이지가 `useSearchParams`로 받아 검색폼에 자동 적용 + 첫 매칭 행 자동 선택. wace의 의도(특정 프로젝트의 판매 등록 화면 보여주기)는 동일하게 달성. + +## 3. RPS 매핑 변경 사항 + +| wace 분기 | RPS 매핑 | 이유 | +|---|---|---| +| `CASE WHEN customer_objid LIKE 'C_%' THEN client_mng ELSE supply_mng END` | `LEFT JOIN customer_mng ON customer_code = substring/통합` | RPS는 `customer_mng` 단일 마스터 (영업관리 G1 자산) | +| `product_item_code` / `product_item_name` LIKE 텍스트 검색 | `search_partObjId` 단일 part_objid 매칭 | 영업관리 패턴 통일 + RPS의 PartSelect 컴포넌트 재사용 | +| `CODE_NAME(...)` 함수 호출 | 동일 (RPS DB 함수 보유) | 영업관리는 LEFT JOIN 패턴이지만 진행관리는 함수 호출 12회로 가독성 우선 | + +## 4. 의존 테이블 (활성 SQL 기준 20개 중 RPS 보유 12개) + +✅ `project_mgmt`(메인) / `contract_mgmt` / `contract_item` / `contract_item_serial` / `customer_mng` / `user_info` / `comm_code` + `CODE_NAME()` 함수 / `attach_file_info` / `sales_registration` / `part_mng` + +❌ `pms_wbs_task` / `setup_wbs_task` / `assembly_wbs_task` / `bom_part_qty` / `part_bom_report` / `planning_issue` / `release_mgmt` / `input_cost_goal` / `expense_master/detail` / `work_diary` + +→ 부재한 테이블이 의존하는 SQL 항목들(진척율 / 이슈 카운트 / 투입원가 / 출고)은 wace JSP 그리드 컬럼 정의에 **노출되지 않음** (8그룹 18셀에 포함 X). 따라서 그리드 표시 데이터엔 영향 없음. backend SQL은 `0` / `NULL` 자리만 유지 후 P2(WBS) 이식에서 채움. + +## 5. 미구현 / P1.5+ 보류 + +| 항목 | 메모 | +|---|---| +| 다중 프로젝트번호 선택 | wace는 multi-select2. RPS는 단일(`SmartSelect`). 다중 모드는 P1.5에서 보강 가능 | +| 진척율 / 이슈 / 원가 / 출고 컬럼 채움 | 그리드 표시 컬럼엔 없으므로 영향 없음. 그러나 service SQL의 `0` 자리들은 P2에서 활성화 | +| `getById` / `updateProject` 라우트 | 옛 jsp(projectMgmtList) 기반으로 만든 자리. 행 클릭이 판매관리 라우팅으로 통일됐으므로 미사용 — 삭제 검토 가능 | +| 엑셀 다운로드 | wace에 없음 — 본 PR 제외 | + +## 6. 관련 파일 + +- backend: [services/projectMgmtService.ts](../../backend-node/src/services/projectMgmtService.ts) · [controllers/projectMgmtController.ts](../../backend-node/src/controllers/projectMgmtController.ts) · [routes/projectMgmtRoutes.ts](../../backend-node/src/routes/projectMgmtRoutes.ts) +- frontend: [app/(main)/COMPANY_16/project/progress/page.tsx](../../frontend/app/(main)/COMPANY_16/project/progress/page.tsx) · [lib/api/projectMgmt.ts](../../frontend/lib/api/projectMgmt.ts) +- 판매관리 연동: [app/(main)/COMPANY_16/sales/sale/page.tsx](../../frontend/app/(main)/COMPANY_16/sales/sale/page.tsx)(useSearchParams `project_no`) · [services/salesSaleService.ts](../../backend-node/src/services/salesSaleService.ts)(`SaleListFilter.project_no`) +- 검증: [01-progress-verify.md](./01-progress-verify.md) diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx index 781a44c2..7acd8309 100644 --- a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -11,6 +11,7 @@ // 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결 import React, { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -74,11 +75,18 @@ const EMPTY_FILTER: ProgressListFilter = { }; export default function ProjectProgressPage() { + const router = useRouter(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [filter, setFilter] = useState(EMPTY_FILTER); const [projectNoOptions, setProjectNoOptions] = useState([]); + // wace `fn_openSaleRegPopup(PROJECT_NO)` 1:1 — RPS는 SPA 패턴 따라 판매관리 페이지로 라우팅 + const handleRowClick = (row: any) => { + if (!row?.project_no) return; + router.push(`/COMPANY_16/sales/sale?project_no=${encodeURIComponent(row.project_no)}`); + }; + const fetchList = useCallback(async () => { setLoading(true); try { @@ -220,6 +228,7 @@ export default function ProjectProgressPage() { data={rows} loading={loading} showRowNumber + onRowClick={handleRowClick} emptyMessage="조건에 맞는 프로젝트가 없습니다." gridId="project-progress-wbslist3" /> diff --git a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx index 3f18ed14..536f3f4c 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -63,16 +64,21 @@ const GRID_COLUMNS: DataGridColumn[] = [ export default function SalesSalePage() { const { user } = useAuth(); + const searchParams = useSearchParams(); + // 진행관리 행 클릭으로 진입한 경우 — wace fn_openSaleRegPopup(PROJECT_NO) 대응 + const incomingProjectNo = searchParams?.get("project_no") ?? ""; const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); // wace salesMgmtList.jsp 검색 폼: 1줄 7개 / 2줄 3개 + // project_no는 진입 시점에만 자동 적용 (UI에 노출 안 함) const [searchForm, setSearchForm] = useState({ orderType: "", poNo: "", customer_objid: "", search_partObjId: "", serialNo: "", shippingStatus: "", salesStatus: "", orderDateFrom: "", orderDateTo: "", shippingDateFrom: "", shippingDateTo: "", + project_no: incomingProjectNo, }); const [registerOpen, setRegisterOpen] = useState(false); @@ -96,6 +102,19 @@ export default function SalesSalePage() { useEffect(() => { fetchList(); }, [fetchList]); + // 진행관리에서 project_no 들고 진입한 경우 — 첫 매칭 행 자동 선택 + const [autoSelectedFromUrl, setAutoSelectedFromUrl] = useState(false); + useEffect(() => { + if (!incomingProjectNo || autoSelectedFromUrl || loading) return; + if (rows.length === 0) { + toast.warning(`프로젝트번호 ${incomingProjectNo} 의 판매 데이터가 없습니다.`); + setAutoSelectedFromUrl(true); + return; + } + setSelected(rows[0] as SaleListRow); + setAutoSelectedFromUrl(true); + }, [incomingProjectNo, rows, loading, autoSelectedFromUrl]); + const openRegister = () => { if (!selected) { toast.warning("판매등록할 행을 선택하세요."); return; } setForm({ @@ -156,6 +175,7 @@ export default function SalesSalePage() { serialNo: "", shippingStatus: "", salesStatus: "", orderDateFrom: "", orderDateTo: "", shippingDateFrom: "", shippingDateTo: "", + project_no: "", })}> 초기화 diff --git a/frontend/lib/api/salesSale.ts b/frontend/lib/api/salesSale.ts index cd6a30e7..7069f28b 100644 --- a/frontend/lib/api/salesSale.ts +++ b/frontend/lib/api/salesSale.ts @@ -16,6 +16,8 @@ export interface SaleListFilter { salesStatus?: string; salesDeadlineFrom?: string; salesDeadlineTo?: string; + // 진행관리 행 클릭으로 진입 시 query param에서 받음 + project_no?: string; } export interface SaleListRow { From 7c4817b045ed711c671344e84b6d66e1edc2382c Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 12:06:01 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=A7=84=ED=96=89=EA=B4=80=EB=A6=AC=20P1.5?= =?UTF-8?q?=20=EC=9E=AC=EC=9E=91=EC=97=85=20=E2=80=94=20PROJECT=5FNO=20?= =?UTF-8?q?=EC=85=80=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=A0=95=EB=B3=B4=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · wace fn_openSaleRegPopup(PROJECT_NO, "detail") 의도 재해석 — read-only 상세 조회 모드 · 행 전체 클릭(onRowClick) → PROJECT_NO 컬럼 셀 클릭(cellClick)으로 좁힘 (wace 1:1) · 판매관리 페이지 라우팅 폐기 → ProjectInfoDialog 신설 (같은 탭, list row 직접 사용, 추가 API 호출 0) · 표시 항목: 프로젝트번호/영업번호/주문유형/제품구분/국내해외/고객사/유무상/품번/품명/S/N/수주수량/접수일/요청납기/발주일/프로젝트명/작성자 · 영업관리 변경 롤백 (SaleListFilter.project_no, useSearchParams 자동 선택, 초기화 핸들러) · 01-progress / 01-progress-verify 문서 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/services/salesSaleService.ts | 4 - docs/migration/project/01-progress-verify.md | 51 ++++++------- docs/migration/project/01-progress.md | 9 +-- .../COMPANY_16/project/progress/page.tsx | 28 ++++--- .../app/(main)/COMPANY_16/sales/sale/page.tsx | 20 ----- .../components/project/ProjectInfoDialog.tsx | 73 +++++++++++++++++++ frontend/lib/api/projectMgmt.ts | 1 + frontend/lib/api/salesSale.ts | 2 - 8 files changed, 117 insertions(+), 71 deletions(-) create mode 100644 frontend/components/project/ProjectInfoDialog.tsx diff --git a/backend-node/src/services/salesSaleService.ts b/backend-node/src/services/salesSaleService.ts index d2c23b48..14217df5 100644 --- a/backend-node/src/services/salesSaleService.ts +++ b/backend-node/src/services/salesSaleService.ts @@ -23,8 +23,6 @@ export interface SaleListFilter { salesStatus?: string; // 판매상태 (registered/cancelled 등) productType?: string; // project_mgmt.product (제품구분) nation?: string; // project_mgmt.area_cd (국내/해외) - // 진행관리 행 클릭으로 진입 시 (wace fn_openSaleRegPopup(PROJECT_NO) 대응) - project_no?: string; // project_mgmt.project_no 직접 매칭 // 매출관리 전용 revenueMode?: string; salesDeadlineFrom?: string; @@ -73,8 +71,6 @@ export async function getSaleList(filter: SaleListFilter) { const params: any[] = []; let idx = 1; - // 진행관리 행 클릭 진입용 — wace fn_openSaleRegPopup(PROJECT_NO) 대응 - if (filter.project_no) { conditions.push(`T.project_no = $${idx++}`); params.push(filter.project_no); } if (filter.orderType) { conditions.push(`T.category_cd = $${idx++}`); params.push(filter.orderType); } if (filter.poNo) { conditions.push(`T.po_no ILIKE $${idx++}`); params.push(`%${filter.poNo}%`); } if (filter.customer_objid) { conditions.push(`T.customer_objid = $${idx++}`); params.push(filter.customer_objid); } diff --git a/docs/migration/project/01-progress-verify.md b/docs/migration/project/01-progress-verify.md index 6fc083dc..edee138f 100644 --- a/docs/migration/project/01-progress-verify.md +++ b/docs/migration/project/01-progress-verify.md @@ -130,35 +130,34 @@ SELECT count(*) AS total, 각 시나리오는 `GET /api/project/progress/list?...` 로 직접 호출 또는 화면에서 검색 후 응답 행 수 / 첫 행 데이터 확인. -## 4. P1.5 행 클릭 라우팅 검증 +## 4. P1.5 PROJECT_NO 셀 클릭 → 프로젝트 정보 다이얼로그 검증 ### 4.1 시나리오 1. `/COMPANY_16/project/progress` 진입 → 그리드 90건 -2. 임의 행(예: `S-CT-260507-003`) 클릭 -3. 브라우저 URL이 `/COMPANY_16/sales/sale?project_no=S-CT-260507-003` 로 변경 -4. 판매관리 페이지 로딩 후: - - `searchForm.project_no` 자동 세팅 - - 그리드에 매칭 행 1건만 표시 - - 그 행이 자동 선택(`selected`) 상태 -5. "출하지시/판매등록" 버튼 활성화 +2. **PROJECT_NO 컬럼 셀만 클릭** (wace `cellClick`과 동일). 다른 컬럼 셀 클릭은 행 선택만. +3. `ProjectInfoDialog` 오픈 — 같은 탭 내 다이얼로그 (새 창 X) +4. 다이얼로그 내용 확인: + - 프로젝트번호 / 영업번호 / 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / 품번 / 품명 / S/N / 수주수량 / 접수일 / 요청납기 / 발주일 / 프로젝트명 / 작성자 + - 모두 read-only (편집 불가) + - 빈 값은 `-` 로 표시 +5. 닫기 버튼으로 다이얼로그 닫힘 -### 4.2 매칭 데이터 부재 케이스 +### 4.2 별도 API 호출 없음 -진행관리에는 있지만 판매관리(project_mgmt 메인 + sales_registration LEFT JOIN)에 매칭이 없는 경우: -- Toast: `프로젝트번호 {PROJECT_NO} 의 판매 데이터가 없습니다.` -- 그리드 0건 표시 -- 사용자가 검색 초기화로 전체 복귀 가능 (초기화 버튼이 `project_no`도 비움) +list SQL 응답(`ProgressRow`)에 표시 필요 데이터가 모두 포함돼 있어 다이얼로그 오픈 시 추가 fetch 없음. 네트워크 검증: +- DevTools Network 패널 → 셀 클릭 시 추가 `/api/...` 호출 0건 확인 -### 4.3 backend 검증 +### 4.3 wace 운영판과의 차이 -```bash -curl -s "http://localhost:8080/api/project/progress/list?project_nos=PJ-1778222043948-342" \ - -H "Cookie: ..." | jq '.data | length' # 1 기대 +| 항목 | wace | RPS | +|---|---|---| +| 클릭 대상 | PROJECT_NO 셀 (cellClick) | 동일 | +| 화면 형식 | 새 창 (`fn_centerPopup` 1000×550) | 같은 탭 내 Dialog | +| 모드 | `saleNo="detail"` (판매등록 폼 detail 모드) | 프로젝트 정보 read-only | +| 데이터 | `salesRegForm.do?orderNo=...` 서버 렌더 | list row 객체 직접 사용 (추가 API 호출 없음) | -curl -s "http://localhost:8080/api/sales?project_no=S-CT-260507-003" \ - -H "Cookie: ..." | jq '.data | length' # 1 기대 (판매관리에 해당 행 매칭 시) -``` +RPS는 SPA 패턴 + 진행관리 본연의 목적이 "프로젝트 상황 모니터링"이라 정보 표시에 집중. 판매 등록은 영업관리 판매관리 메뉴에서 처리. ## 5. 미구현 / 알려진 갭 @@ -168,14 +167,6 @@ curl -s "http://localhost:8080/api/sales?project_no=S-CT-260507-003" \ | 진척율 / 이슈 / 원가 / 출고 컬럼 데이터 | 그리드 표시엔 안 들어가지만 service SQL 자리는 0/NULL | P2(WBS) | | `getById` / `updateProject` 라우트 | 옛 jsp 기반 — 행 클릭 라우팅 통일 후 미사용 | 정리 검토 | -## 6. 회귀 방지 — 영업관리에 미친 변경 +## 6. 회귀 방지 — 영업관리에 영향 없음 -진행관리 P1.5 라우팅을 위해 추가한 변경 — 회귀 검증 필요: - -| 파일 | 변경 | 회귀 위험 | -|---|---|---| -| `backend-node/src/services/salesSaleService.ts` | `SaleListFilter.project_no` 추가, `getSaleList`에 1줄 필터 추가 | 기존 영업 검색 흐름 영향 없음 (조건 추가만) | -| `frontend/lib/api/salesSale.ts` | `SaleListFilter.project_no` 타입만 추가 | 무영향 | -| `frontend/app/(main)/COMPANY_16/sales/sale/page.tsx` | `useSearchParams` import + 자동 행 선택 useEffect 추가, 검색폼/초기화 핸들러에 `project_no: ""` 추가 | URL 쿼리 없으면 기존 동작과 동일 | - -→ 영업관리 4개 메뉴 회귀 테스트 시 `/sales/sale` 정상 진입 / 조회 / 판매등록 흐름 확인. +P1.5 초기 시도(판매관리 페이지 라우팅)는 사용자 의도와 어긋나 폐기. 영업관리에 추가했던 변경(`SaleListFilter.project_no` / `useSearchParams` 자동 선택 / 검색폼 `project_no` 필드)을 모두 롤백해 **영업관리에 변경 영향 없음**. 진행관리 P1.5는 진행관리 페이지 + 신규 `ProjectInfoDialog` 컴포넌트만으로 완결됨. diff --git a/docs/migration/project/01-progress.md b/docs/migration/project/01-progress.md index 2999726e..dc0d5741 100644 --- a/docs/migration/project/01-progress.md +++ b/docs/migration/project/01-progress.md @@ -79,9 +79,9 @@ ORDER BY SUBSTRING(project_no, POSITION('-' IN project_no)+1) DESC, |---|---|---| | 조회 | `btnSearch` → `/projectMgmtWbsGridList.do` | `GET /api/project/progress/list` | | 프로젝트번호 옵션 | `code_map.project_no = common.getCusProjectNoList` | `GET /api/project/progress/project-no-options` | -| **행 클릭** | `cellClick: fn_openSaleRegPopup(PROJECT_NO, "detail")` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` 새 창 | `router.push('/COMPANY_16/sales/sale?project_no={PROJECT_NO}')` 페이지 라우팅 + 자동 필터/선택 | +| **PROJECT_NO 셀 클릭** (cellClick) | `fn_openSaleRegPopup(PROJECT_NO, "detail")` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}&saleNo=detail` 새 창(read-only 상세 모드) | `ProjectInfoDialog` (같은 탭 다이얼로그) — 그리드 row 객체 그대로 read-only 표시 | -행 클릭 처리는 RPS SPA 패턴에 맞춰 새 창 대신 같은 탭 내 페이지 라우팅 + URL 쿼리로 `project_no` 전달. 판매관리 페이지가 `useSearchParams`로 받아 검색폼에 자동 적용 + 첫 매칭 행 자동 선택. wace의 의도(특정 프로젝트의 판매 등록 화면 보여주기)는 동일하게 달성. +행 클릭은 **PROJECT_NO 컬럼 셀에만** 걸림 (wace `cellClick`). 행 전체 클릭은 그냥 행 선택만. wace의 "detail" 모드 새 창은 RPS에서는 같은 탭 내 `ProjectInfoDialog`로 매핑 — 별도 API 호출 없이 list 응답(`ProgressRow`)을 그대로 다이얼로그에 전달해 read-only 표시. 표시 항목: 프로젝트번호 / 영업번호 / 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / 품번 / 품명 / S/N / 수주수량 / 접수일 / 요청납기 / 발주일 / 프로젝트명 / 작성자. ## 3. RPS 매핑 변경 사항 @@ -105,12 +105,11 @@ ORDER BY SUBSTRING(project_no, POSITION('-' IN project_no)+1) DESC, |---|---| | 다중 프로젝트번호 선택 | wace는 multi-select2. RPS는 단일(`SmartSelect`). 다중 모드는 P1.5에서 보강 가능 | | 진척율 / 이슈 / 원가 / 출고 컬럼 채움 | 그리드 표시 컬럼엔 없으므로 영향 없음. 그러나 service SQL의 `0` 자리들은 P2에서 활성화 | -| `getById` / `updateProject` 라우트 | 옛 jsp(projectMgmtList) 기반으로 만든 자리. 행 클릭이 판매관리 라우팅으로 통일됐으므로 미사용 — 삭제 검토 가능 | +| `getById` / `updateProject` 라우트 | 옛 jsp(projectMgmtList) 기반 자리. 행 클릭이 `ProjectInfoDialog` (list row 직접 사용)로 통일됐으므로 미사용 — 삭제 검토 가능 | | 엑셀 다운로드 | wace에 없음 — 본 PR 제외 | ## 6. 관련 파일 - backend: [services/projectMgmtService.ts](../../backend-node/src/services/projectMgmtService.ts) · [controllers/projectMgmtController.ts](../../backend-node/src/controllers/projectMgmtController.ts) · [routes/projectMgmtRoutes.ts](../../backend-node/src/routes/projectMgmtRoutes.ts) -- frontend: [app/(main)/COMPANY_16/project/progress/page.tsx](../../frontend/app/(main)/COMPANY_16/project/progress/page.tsx) · [lib/api/projectMgmt.ts](../../frontend/lib/api/projectMgmt.ts) -- 판매관리 연동: [app/(main)/COMPANY_16/sales/sale/page.tsx](../../frontend/app/(main)/COMPANY_16/sales/sale/page.tsx)(useSearchParams `project_no`) · [services/salesSaleService.ts](../../backend-node/src/services/salesSaleService.ts)(`SaleListFilter.project_no`) +- frontend: [app/(main)/COMPANY_16/project/progress/page.tsx](../../frontend/app/(main)/COMPANY_16/project/progress/page.tsx) · [components/project/ProjectInfoDialog.tsx](../../frontend/components/project/ProjectInfoDialog.tsx) · [lib/api/projectMgmt.ts](../../frontend/lib/api/projectMgmt.ts) - 검증: [01-progress-verify.md](./01-progress-verify.md) diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx index 7acd8309..521037f6 100644 --- a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -10,8 +10,7 @@ // 검색폼: 11필드 (1행 6 + 2행 5) // 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결 -import React, { useCallback, useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -22,6 +21,7 @@ import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { PartSelect } from "@/components/common/PartSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { ProjectInfoDialog } from "@/components/project/ProjectInfoDialog"; import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt"; // wace projectMgmtWbsList3.jsp 컬럼 정의 1:1 (8그룹 → 평탄화, 그룹명은 라벨 prefix) @@ -75,17 +75,24 @@ const EMPTY_FILTER: ProgressListFilter = { }; export default function ProjectProgressPage() { - const router = useRouter(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [filter, setFilter] = useState(EMPTY_FILTER); const [projectNoOptions, setProjectNoOptions] = useState([]); - // wace `fn_openSaleRegPopup(PROJECT_NO)` 1:1 — RPS는 SPA 패턴 따라 판매관리 페이지로 라우팅 - const handleRowClick = (row: any) => { - if (!row?.project_no) return; - router.push(`/COMPANY_16/sales/sale?project_no=${encodeURIComponent(row.project_no)}`); - }; + // wace `fn_openSaleRegPopup(PROJECT_NO, "detail")` 대응 — PROJECT_NO 셀 클릭 시 프로젝트 정보 다이얼로그 + const [infoOpen, setInfoOpen] = useState(false); + const [infoRow, setInfoRow] = useState(null); + + // GRID_COLUMNS의 project_no 셀에만 onClick 주입 (DataGrid 컬럼별 onClick 패턴) + const columns = useMemo( + () => GRID_COLUMNS.map((col) => + col.key === "project_no" + ? { ...col, onClick: (row: any) => { setInfoRow(row as ProgressRow); setInfoOpen(true); } } + : col, + ), + [], + ); const fetchList = useCallback(async () => { setLoading(true); @@ -224,15 +231,16 @@ export default function ProjectProgressPage() { {/* 그리드 (8그룹 18셀 평탄화) */}
+ + ); } diff --git a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx index 536f3f4c..3f18ed14 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -64,21 +63,16 @@ const GRID_COLUMNS: DataGridColumn[] = [ export default function SalesSalePage() { const { user } = useAuth(); - const searchParams = useSearchParams(); - // 진행관리 행 클릭으로 진입한 경우 — wace fn_openSaleRegPopup(PROJECT_NO) 대응 - const incomingProjectNo = searchParams?.get("project_no") ?? ""; const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); // wace salesMgmtList.jsp 검색 폼: 1줄 7개 / 2줄 3개 - // project_no는 진입 시점에만 자동 적용 (UI에 노출 안 함) const [searchForm, setSearchForm] = useState({ orderType: "", poNo: "", customer_objid: "", search_partObjId: "", serialNo: "", shippingStatus: "", salesStatus: "", orderDateFrom: "", orderDateTo: "", shippingDateFrom: "", shippingDateTo: "", - project_no: incomingProjectNo, }); const [registerOpen, setRegisterOpen] = useState(false); @@ -102,19 +96,6 @@ export default function SalesSalePage() { useEffect(() => { fetchList(); }, [fetchList]); - // 진행관리에서 project_no 들고 진입한 경우 — 첫 매칭 행 자동 선택 - const [autoSelectedFromUrl, setAutoSelectedFromUrl] = useState(false); - useEffect(() => { - if (!incomingProjectNo || autoSelectedFromUrl || loading) return; - if (rows.length === 0) { - toast.warning(`프로젝트번호 ${incomingProjectNo} 의 판매 데이터가 없습니다.`); - setAutoSelectedFromUrl(true); - return; - } - setSelected(rows[0] as SaleListRow); - setAutoSelectedFromUrl(true); - }, [incomingProjectNo, rows, loading, autoSelectedFromUrl]); - const openRegister = () => { if (!selected) { toast.warning("판매등록할 행을 선택하세요."); return; } setForm({ @@ -175,7 +156,6 @@ export default function SalesSalePage() { serialNo: "", shippingStatus: "", salesStatus: "", orderDateFrom: "", orderDateTo: "", shippingDateFrom: "", shippingDateTo: "", - project_no: "", })}> 초기화 diff --git a/frontend/components/project/ProjectInfoDialog.tsx b/frontend/components/project/ProjectInfoDialog.tsx new file mode 100644 index 00000000..874af21f --- /dev/null +++ b/frontend/components/project/ProjectInfoDialog.tsx @@ -0,0 +1,73 @@ +"use client"; + +// 진행관리 PROJECT_NO 셀 클릭 시 표시되는 프로젝트 정보 다이얼로그 (read-only). +// wace `fn_openSaleRegPopup(PROJECT_NO, "detail")` 대응 — 새 창 대신 같은 탭 내 다이얼로그. +// list SQL 응답(ProgressRow)에 필요 데이터가 모두 포함돼 있어 별도 detail API 호출 불필요. + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { ProgressRow } from "@/lib/api/projectMgmt"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + row: ProgressRow | null; +} + +const fmtQty = (v: unknown) => { + if (v == null || v === "") return ""; + const n = Number(String(v).replace(/,/g, "")); + return isNaN(n) ? String(v) : n.toLocaleString(); +}; + +export function ProjectInfoDialog({ open, onOpenChange, row }: Props) { + return ( + + + + 프로젝트 정보 + + + {row ? ( +
+ {row.project_no} + {row.contract_no} + {row.category_name} + {row.product_name} + {row.area_name} + {row.customer_name} + {row.free_of_charge} + {row.product_item_code} + {row.product_item_name} + {row.serial_no} + {fmtQty(row.contract_qty)} + {row.reg_date} + {row.req_del_date} + {row.order_date} + {row.project_name ?? ""} + {row.writer_name} +
+ ) : null} + + + + +
+
+ ); +} + +function Label({ children }: { children: React.ReactNode }) { + return
{children}
; +} +function Value({ children, className }: { children: React.ReactNode; className?: string }) { + const empty = children == null || children === ""; + return ( +
+ {empty ? - : children} +
+ ); +} diff --git a/frontend/lib/api/projectMgmt.ts b/frontend/lib/api/projectMgmt.ts index 2e1bb859..f102aff5 100644 --- a/frontend/lib/api/projectMgmt.ts +++ b/frontend/lib/api/projectMgmt.ts @@ -30,6 +30,7 @@ export interface ProjectNoOption { export interface ProgressRow { objid: string; project_no: string | null; + project_name: string | null; category_cd: string | null; category_name: string | null; customer_objid: string | null; diff --git a/frontend/lib/api/salesSale.ts b/frontend/lib/api/salesSale.ts index 7069f28b..cd6a30e7 100644 --- a/frontend/lib/api/salesSale.ts +++ b/frontend/lib/api/salesSale.ts @@ -16,8 +16,6 @@ export interface SaleListFilter { salesStatus?: string; salesDeadlineFrom?: string; salesDeadlineTo?: string; - // 진행관리 행 클릭으로 진입 시 query param에서 받음 - project_no?: string; } export interface SaleListRow { From 50669a66ee9f2c3814f4087ed92830824becb235 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 13:43:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EA=B4=80=EB=A6=AC>=EC=A0=9C=ED=92=88=EA=B5=AC=EB=B6=84=5FWBS?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=A9=94=EB=89=B4=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20wace=20WBS=20=ED=85=9C=ED=94=8C=EB=A6=BF=201:1=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 메인 그리드 5컬럼(제품구분/제목/WBS/등록자/등록일) + 통합 팝업(트리 CRUD + 엑셀 임포트 + 템플릿 다운로드) · 운영 매핑: pms_wbs_template(헤더) + pms_wbs_task_standard(트리) — 활성 갈래 확정 (_info/_standard2 갈래는 2021년 멈춘 레거시) · wace mergeExcelUploadWBS 1:1: 신규=헤더+트리 INSERT, 수정=트리 일괄 DELETE→INSERT (헤더 변경 없음) · objid 채번 gen_random_uuid()::text, 엑셀 파싱 xlsx(SheetJS), 정적 템플릿 frontend/public/templates/ · DataGrid 컬럼 단위 onClick 추가 (WBS 폴더 셀 클릭용) · DDL: 8개 테이블 162컬럼 (docs/migration/project/ddl-extracted/200_pms_wbs.sql) / GAP: docs/migration/project/02-wbs-template.md · 프로젝트 자동 복사/진행관리 연계는 wace도 미완성 — P2 범위 외 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/package-lock.json | 106 +++- backend-node/package.json | 3 +- backend-node/src/app.ts | 2 + .../src/controllers/wbsTemplateController.ts | 91 ++++ backend-node/src/routes/wbsTemplateRoutes.ts | 29 ++ .../src/services/wbsTemplateService.ts | 293 +++++++++++ docs/migration/project/02-wbs-template.md | 477 ++++++++++++++++++ .../project/ddl-extracted/200_pms_wbs.sql | 333 ++++++++++++ .../migration/project/ddl-extracted/README.md | 102 ++++ .../COMPANY_16/project/wbs-template/page.tsx | 173 +++++++ frontend/components/common/DataGrid.tsx | 4 + .../components/layout/AdminPageRenderer.tsx | 1 + .../components/project/WbsTemplateDialog.tsx | 440 ++++++++++++++++ frontend/lib/api/wbsTemplate.ts | 113 +++++ .../templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx | Bin 0 -> 9303 bytes 15 files changed, 2165 insertions(+), 2 deletions(-) create mode 100644 backend-node/src/controllers/wbsTemplateController.ts create mode 100644 backend-node/src/routes/wbsTemplateRoutes.ts create mode 100644 backend-node/src/services/wbsTemplateService.ts create mode 100644 docs/migration/project/02-wbs-template.md create mode 100644 docs/migration/project/ddl-extracted/200_pms_wbs.sql create mode 100644 docs/migration/project/ddl-extracted/README.md create mode 100644 frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx create mode 100644 frontend/components/project/WbsTemplateDialog.tsx create mode 100644 frontend/lib/api/wbsTemplate.ts create mode 100644 frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c4708087..12507fbc 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -45,7 +45,8 @@ "redis": "^4.6.10", "socket.io": "^4.8.3", "uuid": "^13.0.0", - "winston": "^3.11.0" + "winston": "^3.11.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -4022,6 +4023,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4755,6 +4765,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4960,6 +4983,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -5198,6 +5230,18 @@ "node": ">= 0.10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -6553,6 +6597,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -11067,6 +11120,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -11991,6 +12056,24 @@ "node": ">= 6" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12093,6 +12176,27 @@ "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", "integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index d9410550..23360432 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -59,7 +59,8 @@ "redis": "^4.6.10", "socket.io": "^4.8.3", "uuid": "^13.0.0", - "winston": "^3.11.0" + "winston": "^3.11.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 97a13d9b..90f134e3 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -176,6 +176,7 @@ import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관 import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인) import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers) import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) +import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -421,6 +422,7 @@ app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문 app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인) app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers) app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인) +app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/wbsTemplateController.ts b/backend-node/src/controllers/wbsTemplateController.ts new file mode 100644 index 00000000..63c79ccd --- /dev/null +++ b/backend-node/src/controllers/wbsTemplateController.ts @@ -0,0 +1,91 @@ +// ============================================================ +// 프로젝트관리 > 제품구분_WBS관리 controller +// projectMgmtController 패턴 따라 { success, data } envelope. +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/wbsTemplateService"; +import { logger } from "../utils/logger"; + +// GET /api/project/wbs-template?product=... +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listTemplates({ + product: (req.query.product as string) || undefined, + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("WBS 템플릿 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// GET /api/project/wbs-template/check-duplicate?product=...&title=... +export async function checkDuplicate(req: AuthenticatedRequest, res: Response) { + try { + const product = (req.query.product as string) || ""; + const title = (req.query.title as string) || ""; + if (!product || !title) { + return res.json({ success: true, data: { duplicate: false } }); + } + const duplicate = await svc.checkDuplicate(product, title); + return res.json({ success: true, data: { duplicate } }); + } catch (e: any) { + logger.error("WBS 템플릿 중복 체크 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// GET /api/project/wbs-template/:id (헤더 + 트리) +export async function getById(req: AuthenticatedRequest, res: Response) { + try { + const result = await svc.getById(req.params.id); + if (!result.master) { + return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없습니다." }); + } + return res.json({ success: true, data: result }); + } catch (e: any) { + logger.error("WBS 템플릿 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// POST /api/project/wbs-template (신규 + 수정 통합 — wace mergeExcelUploadWBS 1:1) +export async function save(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user?.userId || ""; + const data = await svc.saveTemplate(req.body, userId); + return res.json({ success: true, message: "저장하였습니다.", data }); + } catch (e: any) { + logger.error("WBS 템플릿 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// DELETE /api/project/wbs-template (body { objids: string[] }) +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const objids: string[] = Array.isArray(req.body?.objids) ? req.body.objids : []; + const result = await svc.deleteTemplates(objids); + return res.json({ success: true, ...result }); + } catch (e: any) { + logger.error("WBS 템플릿 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// POST /api/project/wbs-template/parse-excel (multipart, field: file) +export async function parseExcel(req: AuthenticatedRequest, res: Response) { + try { + const file = (req as any).file as Express.Multer.File | undefined; + if (!file) { + return res.status(400).json({ success: false, message: "엑셀 파일이 필요합니다." }); + } + const data = svc.parseExcel(file.buffer); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("WBS 엑셀 파싱 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/wbsTemplateRoutes.ts b/backend-node/src/routes/wbsTemplateRoutes.ts new file mode 100644 index 00000000..2d939742 --- /dev/null +++ b/backend-node/src/routes/wbsTemplateRoutes.ts @@ -0,0 +1,29 @@ +// ============================================================ +// 프로젝트관리 > 제품구분_WBS관리 routes +// 영업관리/projectMgmtRoutes 패턴. +// 엑셀 파싱은 multer memoryStorage (디스크 저장 불필요 — 파싱 후 즉시 응답). +// ============================================================ + +import { Router } from "express"; +import multer from "multer"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/wbsTemplateController"; + +const router = Router(); +router.use(authenticateToken); + +// 엑셀 파일 메모리 업로드 (10MB 제한) +const excelUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, +}); + +router.get("/check-duplicate", ctrl.checkDuplicate); +router.post("/parse-excel", excelUpload.single("file"), ctrl.parseExcel); + +router.get("/", ctrl.getList); +router.post("/", ctrl.save); +router.delete("/", ctrl.remove); +router.get("/:id", ctrl.getById); + +export default router; diff --git a/backend-node/src/services/wbsTemplateService.ts b/backend-node/src/services/wbsTemplateService.ts new file mode 100644 index 00000000..f5bb3a91 --- /dev/null +++ b/backend-node/src/services/wbsTemplateService.ts @@ -0,0 +1,293 @@ +// ============================================================ +// 프로젝트관리 > 제품구분_WBS관리 (wace_plm 도메인 이식) +// 메인 화면: wace `/project/wbsTemplateMngList.do` +// 통합 팝업: wace `/project/WBSExcelImportPopUp.do` +// 매퍼 SQL: wace_plm/src/com/pms/mapper/project.xml:5552 외 +// JSP 원본: wbsTemplateMngList.jsp + WBSExcelImportPopUp.jsp +// 서비스 원본: ProjectService.java:2902 mergeExcelUploadWBS +// +// 1:1 이식 + 변경점: +// · objid 채번: wace CommonUtils.createObjId() → PG `gen_random_uuid()::text` (영업관리 패턴) +// · 엑셀 파싱: Apache POI XSSFWorkbook → npm `xlsx` (SheetJS) +// · 트랜잭션: SqlSession.commit() → PG client BEGIN/COMMIT +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import * as XLSX from "xlsx"; + +// ─── 타입 ──────────────────────────────────────────────── + +export interface TemplateListFilter { + product?: string; +} + +export interface WbsTaskRow { + WBS_TASK_OBJID: string; + TASK_NAME: string; + UNIT_NO: string; + UPPER_TASK_OBJID: string; + TASK_LEVEL: string; +} + +export interface SaveTemplatePayload { + templateObjId?: string; + product: string; + title: string; + customer_product?: string; + tasks: WbsTaskRow[]; // TOTAL 행 포함 (TASK_LEVEL=0) +} + +// ─── 1) 메인 그리드 (wace project.wbsTemplateMngGridList 1:1) ───── + +export async function listTemplates(filter: TemplateListFilter) { + const pool = getPool(); + const conditions: string[] = ["1=1"]; + const params: any[] = []; + let idx = 1; + + if (filter.product) { + conditions.push(`PRODUCT_OBJID = $${idx++}`); + params.push(filter.product); + } + + const sql = ` + SELECT + OBJID, + PRODUCT_OBJID, + CODE_NAME(PRODUCT_OBJID) AS PRODUCT_NAME, + TITLE, + WRITER, + (SELECT DEPT_NAME || USER_NAME FROM USER_INFO WHERE USER_ID = WRITER) AS WRITER_TITLE, + REG_DATE, + TO_CHAR(REG_DATE, 'YYYY-MM-DD') AS REG_DATE_TITLE, + (SELECT COUNT(1) FROM PMS_WBS_TASK_STANDARD PWTS WHERE PWTS.PARENT_OBJID = OBJID) AS WBS_TASK_CNT, + CUSTOMER_PRODUCT + FROM PMS_WBS_TEMPLATE + WHERE ${conditions.join(" AND ")} + ORDER BY REG_DATE DESC NULLS LAST + `; + + const result = await pool.query(sql, params); + return result.rows; +} + +// ─── 2) 팝업 진입: 헤더 + 트리 (wace WBSExcelImportPopUp.do 1:1) ── + +export async function getById(objid: string) { + const pool = getPool(); + + const masterSql = ` + SELECT OBJID, PRODUCT_OBJID, + CODE_NAME(PRODUCT_OBJID) AS PRODUCT_OBJID_NAME, + TITLE, WRITER, REG_DATE, CUSTOMER_PRODUCT + FROM PMS_WBS_TEMPLATE + WHERE OBJID = $1 + `; + + const taskSql = ` + SELECT T.OBJID, + T.PARENT_OBJID, + T.TASK_NAME, + T.TASK_SEQ, + T.TASK_LEVEL, + T.USER_ID, + (SELECT USER_NAME FROM USER_INFO WHERE USER_ID = T.USER_ID) AS USER_ID_TITLE, + T.WRITER, + T.REG_DATE, + T.UNIT_NO, + T.UPPER_TASK_OBJID + FROM PMS_WBS_TASK_STANDARD AS T + WHERE T.PARENT_OBJID = $1 + ORDER BY CAST(T.TASK_SEQ AS INTEGER) + `; + + const [master, tasks] = await Promise.all([ + pool.query(masterSql, [objid]), + pool.query(taskSql, [objid]), + ]); + + return { + master: master.rows[0] ?? null, + tasks: tasks.rows, + }; +} + +// ─── 3) 중복 체크 (wace project.getWBSTemplateProductList 1:1) ──── + +export async function checkDuplicate(product: string, title: string) { + const pool = getPool(); + const sql = ` + SELECT T.OBJID + FROM PMS_WBS_TEMPLATE T + LEFT OUTER JOIN PMS_WBS_TASK_STANDARD T1 ON T.OBJID = T1.PARENT_OBJID + WHERE T.PRODUCT_OBJID = $1 + AND T.TITLE = $2 + LIMIT 1 + `; + const result = await pool.query(sql, [product, title]); + return result.rows.length > 0; +} + +// ─── 4) 통합 저장 (wace ProjectService.mergeExcelUploadWBS 1:1) ──── + +export async function saveTemplate(payload: SaveTemplatePayload, writer: string) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + let masterObjId: string; + + if (payload.templateObjId) { + // 수정 모드: 헤더 UPDATE 안 함, 트리 일괄 DELETE → 재 INSERT (wace 동일) + masterObjId = payload.templateObjId; + await client.query( + `DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID = $1`, + [masterObjId] + ); + } else { + // 신규 모드: 헤더 INSERT (wace saveWBSTaskTemp 1:1) + const masterRes = await client.query( + `INSERT INTO PMS_WBS_TEMPLATE + (OBJID, PRODUCT_OBJID, TITLE, CUSTOMER_PRODUCT, WRITER, REG_DATE) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, now()) + RETURNING OBJID`, + [ + payload.product, + payload.title, + payload.customer_product ?? "", + writer, + ] + ); + masterObjId = masterRes.rows[0].objid; + } + + // 트리 INSERT (wace saveWBSTemplateTaskInfo — upsert) + for (let i = 0; i < payload.tasks.length; i++) { + const t = payload.tasks[i]; + await client.query( + `INSERT INTO PMS_WBS_TASK_STANDARD + (OBJID, PARENT_OBJID, TASK_NAME, TASK_SEQ, TASK_LEVEL, + USER_ID, WRITER, REG_DATE, UNIT_NO, UPPER_TASK_OBJID) + VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9) + ON CONFLICT (OBJID) DO UPDATE SET + TASK_NAME = EXCLUDED.TASK_NAME, + TASK_SEQ = EXCLUDED.TASK_SEQ, + TASK_LEVEL = EXCLUDED.TASK_LEVEL, + USER_ID = EXCLUDED.USER_ID, + UNIT_NO = EXCLUDED.UNIT_NO, + UPPER_TASK_OBJID = EXCLUDED.UPPER_TASK_OBJID`, + [ + t.WBS_TASK_OBJID, + masterObjId, + t.TASK_NAME, + String(i + 1), + t.TASK_LEVEL, + "", // user_id (wace mergeExcelUploadWBS도 미설정) + writer, + t.UNIT_NO, + t.UPPER_TASK_OBJID, + ] + ); + } + + await client.query("COMMIT"); + return { OBJID: masterObjId }; + } catch (e) { + await client.query("ROLLBACK"); + logger.error("[wbsTemplateService.saveTemplate] failed", { error: e }); + throw e; + } finally { + client.release(); + } +} + +// ─── 5) 다건 삭제 (wace ProjectService.deleteWBSTemplateMaster 1:1) ─ + +export async function deleteTemplates(objids: string[]) { + if (!objids || objids.length === 0) { + return { result: "true", msg: "삭제할 대상이 없습니다." }; + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const placeholders = objids.map((_, i) => `$${i + 1}`).join(","); + + await client.query( + `DELETE FROM PMS_WBS_TEMPLATE WHERE OBJID IN (${placeholders})`, + objids + ); + await client.query( + `DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID IN (${placeholders})`, + objids + ); + + await client.query("COMMIT"); + return { result: "true", msg: "삭제하였습니다." }; + } catch (e) { + await client.query("ROLLBACK"); + logger.error("[wbsTemplateService.deleteTemplates] failed", { error: e }); + throw e; + } finally { + client.release(); + } +} + +// ─── 6) 엑셀 파싱 (wace ProjectService.parsingExcelFile 1:1) ────── +// 입력: 업로드된 .xlsx Buffer (multer memoryStorage) +// 출력: List<{ WBS_OBJID, UNIT_NO, TASK_NAME }> — 클라이언트가 행 채움 +// +// 엑셀 규칙 (wace 동일): +// · 0행 = "입력" 라벨, 1행 = "수준 / unit name" 헤더 → 무시 +// · 2행부터 데이터: 열 0/1/2 = 수준1/2/3 셀, 열 3 = TASK_NAME +// · UNIT_NO = 수준3 → 수준2 → 수준1 우선 +// · unit_no + task_name 둘 다 있어야 결과 포함 + +export function parseExcel(buffer: Buffer) { + const wb = XLSX.read(buffer, { type: "buffer" }); + const firstSheet = wb.SheetNames[0]; + const sheet = wb.Sheets[firstSheet]; + + // raw=false → cell 표시값(string) / defval='' → 빈 셀도 string '' + const rows = XLSX.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: "", + }); + + const result: Array<{ WBS_OBJID: string; UNIT_NO: string; TASK_NAME: string }> = []; + + // 2행(인덱스 2)부터 — wace 동일 + for (let i = 2; i < rows.length; i++) { + const row = rows[i]; + if (!row) continue; + + const levelValues = [ + String(row[0] ?? "").trim(), + String(row[1] ?? "").trim(), + String(row[2] ?? "").trim(), + ]; + const taskName = String(row[3] ?? "").trim(); + + const unitNo = levelValues[2] || levelValues[1] || levelValues[0]; + + if (unitNo && taskName) { + result.push({ + // wace는 server측에서 OBJID 발급 (CommonUtils.createObjId) + // vexplor_rps: 클라이언트가 화면 표시용 임시 ID 발급, 저장 시 새 OBJID 부여 + // → 여기서는 crypto.randomUUID()로 임시 ID 생성 + WBS_OBJID: crypto.randomUUID(), + UNIT_NO: unitNo, + TASK_NAME: taskName, + }); + } + } + + return result; +} diff --git a/docs/migration/project/02-wbs-template.md b/docs/migration/project/02-wbs-template.md new file mode 100644 index 00000000..9a65affb --- /dev/null +++ b/docs/migration/project/02-wbs-template.md @@ -0,0 +1,477 @@ +# P2: 프로젝트관리 > 제품구분_WBS관리 (WBS 템플릿) + +> 작성일: 2026-05-12 +> 원본: wace_plm `/project/wbsTemplateMngList.do` + `/project/WBSExcelImportPopUp.do` 통합 워크플로 +> 운영판 URL: https://waceplm.esgrin.com/main.do → 프로젝트관리 > 제품구분_WBS관리 +> 대상 DB 테이블: `pms_wbs_template`(헤더), `pms_wbs_task_standard`(트리) + +--- + +## 1. 범위 (Scope) + +### 1.1 범위 안 + +- **메인 그리드** (5컬럼): 제품구분 / 제목 / WBS(폴더 아이콘) / 등록자 / 등록일 +- **검색**: 제품구분 단일 셀렉트 +- **신규 등록**: 통합 팝업 진입 (`WBSExcelImportPopUp.do?product=...`) +- **수정**: 통합 팝업 진입 (`WBSExcelImportPopUp.do?templateObjId=...`) +- **삭제**: 다건 선택 후 헤더+트리 cascade +- **통합 팝업**: 헤더(제품구분/제목) + 트리 CRUD(추가/하위추가/삭제) + 엑셀 임포트 + 템플릿 다운로드 + 저장 + +### 1.2 범위 밖 (의도적 제외) + +- 진행관리(P1) WBS 연계 (= 프로젝트 생성 시 템플릿 자동 복사) — wace도 미완성 영역, vexplor_rps에서도 손대지 않음 +- 간트차트 / FN Task 연결 / 작업확정 / 제품별 WBS / 셋업 WBS — wace의 30+ endpoint 중 진행관리/별도 메뉴용 +- `pms_wbs_task` 본체(트리) — 진행관리에서 사용 예정, 본 메뉴 미사용 + +--- + +## 2. 운영판 화면 검증 (2026-05-11 캡처) + +### 2.1 메인 그리드 + +| 영역 | 내용 | +|---|---| +| 화면 제목 | "프로젝트관리_제품구분_WBS관리" | +| 우상단 버튼 | 삭제 / 등록 / 조회 / 초기화 / [엑셀 다운로드] | +| 검색 필터 | **제품구분** select 단일 | +| 그리드 컬럼 | 체크박스 / 제품구분 / 제목 / WBS(폴더) / 등록자 / 등록일 | +| 운영 데이터 | 1건: Machine / test 생산 / [폴더] / 경영지원팀관리자 / 2026-04-08 | + +### 2.2 통합 팝업 (등록/수정) + +- URL: `/project/WBSExcelImportPopUp.do?templateObjId=1120026346` (운영 확인) +- 제목: 신규 시 "WBS 템플릿 등록" / 수정 시 "WBS 템플릿 수정" +- 헤더: 제품구분(수정 시 disabled) + 제목(수정 시 disabled) +- 버튼: Template Download / 추가 / 하위추가 / 삭제 / 저장 / 닫기 +- 드롭존: "Drag & Drop 엑셀 템플릿" +- 트리 그리드: 선택 / 수준(1/2/3 세 칸) / Unit Name·공정 — TOTAL 행 + 자식 5행 (운영 데이터) + +### 2.3 엑셀 템플릿 (`WBS_EXCEL_IMPORT_TEMPLATE.xlsx`) + +| A (수준1) | B (수준2) | C (수준3) | D (unit name /공정) | +|---|---|---|---| +| 1 | | | TASK1 | +| | 1.1 | | TASK2 | +| | | 1.1.1 | TASK3 | +| ... | | | | + +- 1행 = "입력" 라벨(노란색), 2행 = "수준/unit name /공정" 헤더, **3행부터 데이터**. +- 수준 위치 = depth(1/2/3), 셀 값 = UNIT_NO ("1", "1.1", "1.1.1" 형식 자유 입력). +- TASK_NAME은 D열. + +--- + +## 3. 데이터 모델 (운영 1:1) + +### 3.1 `pms_wbs_template` (헤더, 1건 운영) + +| 컬럼 | 타입 | 용도 | +|---|---|---| +| `objid` | varchar PK | 헤더 키 (Java 측 채번) | +| `product_objid` | varchar | 제품구분 코드 (CODE_NAME 함수로 라벨화) | +| `title` | varchar | 템플릿 제목 | +| `writer` | varchar | 등록자 user_id | +| `reg_date` | timestamp | 등록일 | +| `customer_product` | varchar | (미사용 — 운영 비활성 컬럼 `CUSTOMER_PRODUCT`로 흔적만) | + +### 3.2 `pms_wbs_task_standard` (트리, 5건 운영, 활성 갈래) + +| 컬럼 | 타입 | 용도 | +|---|---|---| +| `objid` | varchar PK | task 키 | +| `parent_objid` | varchar | **`pms_wbs_template.objid` 참조** (헤더 FK) | +| `task_name` | varchar | task 이름 (TOTAL / 사용자 입력 / 엑셀 임포트) | +| `task_seq` | varchar | 폼 제출 순서 (INTEGER 캐스팅 정렬용) | +| `task_level` | varchar | depth (0=TOTAL, 1/2/3=수준) | +| `unit_no` | varchar | "1" / "1.1" / "1.1.1" 표기 — depth와 일치 | +| `upper_task_objid` | varchar | **트리 부모 objid** (TOTAL의 objid가 depth=1의 부모, 이전 depth-1 행이 depth>1의 부모) | +| `user_id` / `writer` / `reg_date` | varchar/varchar/timestamp | 메타 | + +→ vexplor_rps DDL 위치: [docs/migration/project/ddl-extracted/200_pms_wbs.sql](ddl-extracted/200_pms_wbs.sql) (8개 테이블 중 2개가 본 메뉴 대상). + +### 3.3 폐기 / 미사용 (참고) + +| 테이블 | 운영 카운트 | 폐기 이유 | +|---|---:|---| +| `pms_wbs_task_info` | 518 (2021 멈춤) | 매퍼 사용 없음 — 레거시 | +| `pms_wbs_task_standard2` | 74 | 매퍼 사용 없음 | +| `pms_wbs_task_confirm` | 0 | 매퍼 사용 없음 | + +--- + +## 4. wace 1:1 매핑 카탈로그 + +### 4.1 ProjectController.java endpoint (P2 범위 8개) + +| URL | 라인 | 호출 service | 매퍼 | +|---|---:|---|---| +| `/project/wbsTemplateMngList.do` | 2242 | (forward only) + `bizMakeOptionList('0000001')` | (코드맵만) | +| `/project/wbsTemplateMngGridList.do` | 2270 | `commonService.selectListPagingNew` | `project.wbsTemplateMngGridList` | +| `/project/WBSExcelImportPopUp.do` | 2282 | `getWBSTemplateMasterInfo` + `getWBSTemplateTaskList` (수정 시) | `getWBSTemplateMasterInfo` + `getWBSTemplateTaskList` | +| `/project/getWBSTemplateTaskList.do` | 2419 | `getWBSTemplateTaskList` (AJAX, 팝업 로드 시) | `getWBSTemplateTaskList` | +| `/project/parsingExcelFile.do` | 2319 | `parsingExcelFile` | (Apache POI 직접) | +| `/project/excelImportFileProc.do` | 2336 | `commonService.insertUploadFileInfo` | (파일 메타만) | +| `/project/checkWBSTemplateProduct.do` | 2371 | `getWBSTemplateProductList` | `getWBSTemplateProductList` | +| `/project/saveExcelUploadWBS.do` | 2379 | **`mergeExcelUploadWBS`** (통합 저장) | `deleteWBSTemplateTaskByMaster` + `saveWBSTaskTemp` + `saveWBSTemplateTaskInfo` | +| `/project/deleteWBSTemplateMaster.do` | 2474 | `deleteWBSTemplateMaster` | `deleteWBSTemplateMaster` + `deleteWBSTemplateMasterTask` | + +→ 9개 endpoint, 그 중 핵심 워크플로는 4개 (`grid` / `popup-forward` / `parse` / `merge-save`). + +### 4.2 project.xml 매퍼 SQL (라인번호 + 본문 핵심) + +#### `wbsTemplateMngGridList` (5552) — 메인 그리드 + +```sql +SELECT + OBJID, PRODUCT_OBJID, + CODE_NAME(PRODUCT_OBJID) AS PRODUCT_NAME, + TITLE, + WRITER, + (SELECT DEPT_NAME || USER_NAME FROM USER_INFO WHERE USER_ID = WRITER) AS WRITER_TITLE, + REG_DATE, + TO_CHAR(REG_DATE, 'YYYY-MM-DD') AS REG_DATE_TITLE, + (SELECT COUNT(1) FROM PMS_WBS_TASK_STANDARD PWTS WHERE PWTS.PARENT_OBJID = OBJID) AS WBS_TASK_CNT, + CUSTOMER_PRODUCT +FROM PMS_WBS_TEMPLATE +WHERE 1=1 + + AND PRODUCT_OBJID = #{product} + +``` + +→ `CODE_NAME()` 함수 호출 (RPS DB 보유, P1 진행관리에서도 사용). + +#### `getWBSTemplateMasterInfo` (5647) — 팝업 헤더 + +```sql +SELECT OBJID, PRODUCT_OBJID, CODE_NAME(PRODUCT_OBJID) AS PRODUCT_OBJID_NAME, + TITLE, WRITER, REG_DATE, CUSTOMER_PRODUCT +FROM PMS_WBS_TEMPLATE AS T +WHERE OBJID = #{OBJID} +``` + +#### `getWBSTemplateTaskList` (5661) — 팝업 트리 + +```sql +SELECT T.OBJID, T.PARENT_OBJID, T.TASK_NAME, T.TASK_SEQ, T.TASK_LEVEL, + T.USER_ID, + (SELECT USER_NAME FROM USER_INFO WHERE USER_ID = T.USER_ID) AS USER_ID_TITLE, + T.WRITER, T.REG_DATE, T.UNIT_NO, T.UPPER_TASK_OBJID +FROM PMS_WBS_TASK_STANDARD AS T +WHERE T.PARENT_OBJID = #{OBJID} +ORDER BY CAST(T.TASK_SEQ AS INTEGER) +``` + +#### `saveWBSTemplateTaskInfo` (5609) — 트리 upsert + +```sql +INSERT INTO PMS_WBS_TASK_STANDARD + (OBJID, PARENT_OBJID, TASK_NAME, TASK_SEQ, TASK_LEVEL, USER_ID, + WRITER, REG_DATE, UNIT_NO, UPPER_TASK_OBJID) +VALUES (#{objid}, #{parent_objid}, #{task_name}, #{task_seq}, #{task_level}, + #{user_id}, #{writer}, now(), #{unit_no}, #{upper_task_objid}) +ON CONFLICT (OBJID) DO UPDATE + SET TASK_NAME = #{task_name}, TASK_SEQ = #{task_seq}, + TASK_LEVEL = #{task_level}, USER_ID = #{user_id}, + UNIT_NO = #{unit_no}, UPPER_TASK_OBJID = #{upper_task_objid} +``` + +→ **upsert (INSERT … ON CONFLICT DO UPDATE)** — neon/node-postgres에서 동일 패턴 사용. + +#### `saveWBSTaskTemp` (5587) — 헤더 INSERT + +```sql +INSERT INTO PMS_WBS_TEMPLATE + (OBJID, PRODUCT_OBJID, TITLE, CUSTOMER_PRODUCT, WRITER, REG_DATE) +VALUES (#{objid}, #{product}, #{title}, #{customer_product}, #{writer}, now()) +``` + +#### `deleteWBSTemplateTaskByMaster` (5643) — 수정 시 트리 cascade clear + +```sql +DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID = #{parent_objid} +``` + +#### `deleteWBSTemplateMaster` (5726) / `deleteWBSTemplateMasterTask` (5734) + +```sql +DELETE FROM PMS_WBS_TEMPLATE WHERE OBJID IN (...) +DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID IN (...) +``` + +→ 헤더 다건 삭제 + cascade. 트랜잭션 1개. + +#### `getWBSTemplateProductList` (5576) — 중복 체크 + +```sql +SELECT * +FROM PMS_WBS_TEMPLATE AS T +LEFT OUTER JOIN PMS_WBS_TASK_STANDARD AS T1 ON T.OBJID = T1.PARENT_OBJID +WHERE T.PRODUCT_OBJID = #{PRODUCT} + AND T.TITLE = #{TITLE} +``` + +→ 신규 등록 시 동일 (제품구분 + 제목) 조합 있는지 사전 체크. + +### 4.3 ProjectService 핵심 메소드 + +#### `mergeExcelUploadWBS` (line 2902, 통합 저장) + +``` +PersonBean.userId → writer +request.parameter("product"|"title"|"templateObjId"|"customer_product") + +분기: + if templateObjId != "": + wbsMasterObjId = templateObjId # 헤더 재사용 (UPDATE 안 함!) + sqlSession.delete("deleteWBSTemplateTaskByMaster", {parent_objid: wbsMasterObjId}) + else: + wbsMasterObjId = CommonUtils.createObjId() + sqlSession.insert("saveWBSTaskTemp", {objid, product, title, customer_product, writer}) + +루프 (WBS_TASK_OBJID 배열): + for i, wbsObjId in enumerate(WBS_TASK_OBJID): + {TASK_NAME, UNIT_NO, UPPER_TASK_OBJID, TASK_LEVEL} = request 각 hidden + sqlSession.insert("saveWBSTemplateTaskInfo", { + objid, task_name, task_seq=i+1, task_level, unit_no, + upper_task_objid, parent_objid=wbsMasterObjId, writer + }) + +sqlSession.commit() +``` + +**핵심 패턴**: +- 수정 모드는 **헤더 UPDATE 안 함** (product/title 변경 불가). 트리만 일괄 DELETE → INSERT. +- 신규 모드는 헤더 INSERT + 트리 INSERT. +- 트리 순서(task_seq)는 폼 제출 순서. + +#### `parsingExcelFile` (line 2779) + +``` +fileList = commonService.getFileList({targetObjId, docType: "WBS_EXCEL_IMPORT"}) +첫 파일을 XSSFWorkbook으로 읽음. + +루프 rowIndex = 2 ~ end (3행부터): + 열 0/1/2 → levelValues[0..2] # 수준1/2/3 셀 + 열 3 → taskName + + unitNo = levelValues[2] or levelValues[1] or levelValues[0] # depth 3→2→1 우선 + if unitNo and taskName: + result.add({WBS_OBJID = createObjId(), UNIT_NO = unitNo, TASK_NAME = taskName}) +``` + +→ depth는 클라이언트가 unit_no의 `.` 개수로 계산 (`.match(/\./g) || []).length + 1`). +→ 파싱 결과는 클라이언트에 List 리턴, **DB 저장은 사용자가 "저장" 버튼 누를 때 mergeExcelUploadWBS에서**. + +#### `deleteWBSTemplateMaster` (line 3284) + +``` +SqlSession (transactional) +sqlSession.delete("deleteWBSTemplateMaster", {checkArr}) +sqlSession.delete("deleteWBSTemplateMasterTask", {checkArr}) +commit +``` + +--- + +## 5. UI 동작 명세 (`WBSExcelImportPopUp.jsp` 1:1) + +### 5.1 모드 분기 + +```js +isEditMode = (templateObjId !== "") +if isEditMode: + loadExistingTasks() // AJAX → /project/getWBSTemplateTaskList.do + title.value = masterInfo.TITLE + product.value = masterInfo.PRODUCT_OBJID +else: + addTotalRow() // 빈 트리에 TOTAL 행 1개만 +``` + +### 5.2 트리 행 구조 + +```html + + + + + + + + + + + +``` + +- TOTAL 행은 hidden TASK_LEVEL=0 / UNIT_NO=0 / TASK_NAME=TOTAL / 체크박스 없음 / "TOTAL" 텍스트 표시. +- **수준 1/2/3은 셋 중 하나에만 값 입력 가능** (`bindLevelInput`): 한 칸 입력 시 다른 두 칸 비우고 UNIT_NO + TASK_LEVEL 동기화. + +### 5.3 버튼 핸들러 + +| 버튼 | 함수 | 동작 | +|---|---|---| +| Template Download | `templateDownload.click` | `location.href="/template/WBS_EXCEL_IMPORT_TEMPLATE.xlsx"` (정적 파일) | +| 추가 | `addRow()` | 선택된 행 다음에 같은 depth 행 추가. 선택 없으면 마지막에 append. | +| 하위추가 | `addChildRow()` | 선택된 행의 하위(depth+1) 행을 자식 마지막 다음에 추가. depth>=3 거부. | +| 삭제 | `deleteRow()` | 선택된 행 + 하위 후손 행 일괄 삭제. cascade 확인 alert. | +| 저장 | `saveWBS()` | 검증 → calculateParentRelations() → POST `/project/saveExcelUploadWBS.do` | +| 닫기 | `self.close()` | 팝업 닫기 | + +### 5.4 저장 검증 로직 (`saveWBS()`) + +``` +1. title 빈값 거부 +2. WBS_TASK_OBJID 0개 거부 (등록할 항목 없음) +3. 각 행에서 UNIT_NO + TASK_NAME 빈값 거부 (TOTAL 행 제외) +4. 신규 모드일 때 fn_checkWBSTemplateRevision() — (PRODUCT, TITLE) 중복 거부 +5. calculateParentRelations() — 모든 행 순회하며 UPPER_TASK_OBJID 채우기: + - depth=1 → totalObjId (TOTAL 행 objid) + - depth>1 → 이전 prevAll 중 depth-1인 가장 가까운 행 objid +6. POST form serialize → opener.fn_search() + self.close() +``` + +### 5.5 엑셀 임포트 흐름 + +``` +사용자 파일 드롭 → fileUploadPreProc() → preFileDelete() (기존 삭제 alert) + ↓ +fnc_setFileDropZone POST /project/excelImportFileProc.do (Multipart) + ↓ 업로드 완료 콜백 → setExcelFileArea() → setUploadTemplateFile() + ↓ getFileList.do AJAX → 첨부 표시 + parsingExcelFile() + ↓ parsingExcelFile() POST /project/parsingExcelFile.do + ↓ 응답 List(WBS_OBJID, UNIT_NO, TASK_NAME) + ↓ 각 row를 #wbsTaskList 에 append (depth는 unit_no의 '.' 개수로 계산) +``` + +### 5.6 그리드 행 클릭 (메인 화면) + +- WBS 폴더 아이콘 클릭 → `fn_openWBSTaskListPopUp(objid)` → `/project/WBSExcelImportPopUp.do?templateObjId={objid}` 팝업 (1340x700) +- 등록 버튼 → `/project/WBSExcelImportPopUp.do?product={product}` 팝업 + - 제품구분 미선택 시 alert "제품은 필수값입니다 제품을 선택해 주세요" + +--- + +## 6. vexplor_rps 이식 매핑 (구현 계획) + +### 6.1 backend-node + +| wace | vexplor_rps | 비고 | +|---|---|---| +| `ProjectController` WBS template 9개 | `backend-node/src/controllers/wbsTemplateController.ts` | REST 통합 | +| `ProjectService.mergeExcelUploadWBS` | `backend-node/src/services/wbsTemplateService.ts` `saveTemplate(payload)` | upsert 패턴 그대로 | +| `ProjectService.parsingExcelFile` | `backend-node/src/services/wbsTemplateService.ts` `parseExcelFile(buffer)` | Apache POI → **`xlsx`** npm 패키지 | +| `CODE_NAME(PRODUCT_OBJID)` | DB 함수 직접 호출 (P1 진행관리와 동일) | RPS DB 보유 | +| `commonService.getFileList` | 영업관리 첨부 패턴 재사용 또는 임포트 시 메모리 처리 | + +#### REST endpoint 매핑 + +``` +GET /api/project/wbs-template — 메인 그리드 (product 필터) +GET /api/project/wbs-template/:id — 헤더 + 트리 (팝업 진입) +POST /api/project/wbs-template — 신규 저장 (헤더 + 트리) +PUT /api/project/wbs-template/:id — 수정 저장 (트리만 일괄 DELETE→INSERT) +DELETE /api/project/wbs-template — 다건 삭제 (헤더 + cascade) +POST /api/project/wbs-template/parse-excel — 엑셀 파일 multipart → 파싱 결과 JSON +GET /api/project/wbs-template/check-duplicate?product=&title= — 중복 체크 +GET /api/project/wbs-template/excel-template — 엑셀 템플릿 다운로드 (`public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx` 정적) +``` + +#### objid 채번 + +wace는 `CommonUtils.createObjId()` (시퀀스 함수 기반 string). vexplor_rps는 `nanoid()` 또는 `crypto.randomUUID()` → 영업관리 패턴 확인 후 통일. + +### 6.2 frontend + +| wace | vexplor_rps | 비고 | +|---|---|---| +| `wbsTemplateMngList.jsp` | `frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx` | 메인 페이지 | +| 통합 팝업 `WBSExcelImportPopUp.jsp` | `frontend/components/project/WbsTemplateDialog.tsx` | shadcn Dialog + 트리 테이블 + 파일 드롭존 | +| 메인 그리드 | `DataGrid` 5컬럼 (영업관리 패턴 재사용) | frozen 제품구분 | +| API 클라이언트 | `frontend/lib/api/wbsTemplate.ts` | 영업관리 패턴 | +| 메뉴 | `AdminPageRenderer.tsx` dynamic 등록 | 라우트 `/COMPANY_16/project/wbs-template` | + +#### 트리 UI 결정 사항 + +운영판은 jQuery 기반 단순 테이블 + hidden input 직렬화. vexplor_rps는 React 상태로 트리 행 관리: +- 행 배열 + depth 필드 (1~3) + `objid` 클라이언트 측 생성 (`nanoid()`) +- 추가/하위추가/삭제는 배열 조작 +- 수준 1/2/3 컬럼 단일 입력 보장 +- 저장 시 task_seq = index+1, upper_task_objid 자동 계산 (depth=1→TOTAL, depth>1→이전 depth-1 항목 objid) + +#### 엑셀 라이브러리 + +- `xlsx` (SheetJS) — backend-node에서 파싱 +- 정적 엑셀 템플릿 파일은 `frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx`에 wace 원본 그대로 배치 + +--- + +## 7. 함정 & 결정 메모 + +### 7.1 수정 모드 헤더 변경 불가 +운영판 mergeExcelUploadWBS는 templateObjId 있으면 헤더 UPDATE를 호출하지 않음. 화면에서 product1 disabled / title은 표시만. vexplor_rps도 동일하게 disabled 처리 — **헤더 수정 기능은 추가하지 않음**. + +### 7.2 트리 일괄 DELETE → INSERT +수정 시 운영은 `deleteWBSTemplateTaskByMaster`로 트리 전체 삭제 후 폼 데이터로 재삽입. vexplor_rps도 같은 패턴 (트랜잭션 1개). 부분 update/delete 분기 없음 — 간단하고 안전. + +### 7.3 task_seq는 INTEGER 캐스팅 정렬 +SQL `ORDER BY CAST(T.TASK_SEQ AS INTEGER)`. wace는 폼 제출 순서로 1, 2, 3, ... 매김. vexplor_rps도 동일. + +### 7.4 upper_task_objid 자동 계산 +클라이언트 측 `calculateParentRelations()` — 백엔드로 보내기 전 결정. depth=1 행의 부모는 TOTAL 행 objid. 운영판 그대로. + +### 7.5 CUSTOMER_PRODUCT 컬럼은 비활성 +운영판 메인 그리드의 "고객사_장비목적" 컬럼은 JSP 주석 처리됨. `saveWBSTemplateMasterInfo`(UPDATE)는 CUSTOMER_PRODUCT만 수정하는데 호출하는 곳이 비활성 마스터 폼뿐. vexplor_rps 초기 이식에서는 **빼고**, customer_product는 DB에 보존만 함. + +### 7.6 엑셀 1행/2행 무시 +파싱 루프 `for(rowIndex = 2 ; ...)` — 1행(입력 라벨) + 2행(수준/unit name 헤더) 무시, **3행부터 데이터**. vexplor_rps도 동일. + +### 7.7 신규 등록 시 product 필수 +`wbsTemplateMngList.jsp` 라인 53-57: 등록 버튼 클릭 시 product 비었으면 alert. 운영 UX 그대로. + +### 7.8 폐기 갈래 안 건드림 +`pms_wbs_task_info`, `_standard2`, `_confirm`은 DDL은 보존했지만 매퍼/서비스/UI에서 절대 사용하지 않음. 향후 진행관리 P2(WBS 진행 트리) 진입 시 `pms_wbs_task` 본체만 추가 매핑. + +### 7.9 진행관리 P1과의 연결은 P2 범위 외 +프로젝트(주문) 생성 시 템플릿 자동 복사 흐름은 wace도 미완성 — 사용자 명시. vexplor_rps도 추후 별도 단계로. + +--- + +## 8. 검증 베이스라인 (운영DB) + +운영DB에 1건만 있어 화면 검증 단순: + +```sql +-- 메인 그리드 조회 (운영 wbsTemplateMngGridList 1:1) +SELECT OBJID, CODE_NAME(PRODUCT_OBJID) AS PRODUCT_NAME, TITLE, WRITER, + TO_CHAR(REG_DATE,'YYYY-MM-DD') AS REG_DATE_TITLE, + (SELECT COUNT(1) FROM pms_wbs_task_standard WHERE parent_objid = t.objid) AS WBS_TASK_CNT +FROM pms_wbs_template t; +-- 운영: 1건 (Machine / test 생산 / 경영지원팀관리자 / 2026-04-08 / WBS_TASK_CNT=5) + +-- 트리 조회 (운영 getWBSTemplateTaskList 1:1) +SELECT objid, task_name, task_seq, task_level, unit_no, upper_task_objid +FROM pms_wbs_task_standard +WHERE parent_objid = '1120026346' +ORDER BY CAST(task_seq AS INTEGER); +-- 운영: 5건 (TOTAL + ㅁㅁ4건) +``` + +vexplor_rps 측은 운영 데이터 시드 없이 빈 상태에서 시작 — 화면 검증은 사용자가 직접 등록·저장·수정·삭제로. + +--- + +## 9. 산출물 체크리스트 + +- [x] DDL: `docs/migration/project/ddl-extracted/200_pms_wbs.sql` (8개 테이블) +- [x] DDL README: `docs/migration/project/ddl-extracted/README.md` +- [x] GAP 문서: 본 파일 +- [ ] backend service: `backend-node/src/services/wbsTemplateService.ts` +- [ ] backend controller/route: `backend-node/src/controllers/wbsTemplateController.ts` + `routes/wbsTemplateRoutes.ts` +- [ ] frontend page: `frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx` +- [ ] frontend dialog: `frontend/components/project/WbsTemplateDialog.tsx` +- [ ] frontend api: `frontend/lib/api/wbsTemplate.ts` +- [ ] AdminPageRenderer dynamic 등록 +- [ ] 엑셀 템플릿 정적 파일: `frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx` +- [ ] 검증: 운영판 1:1 등록/수정/삭제/엑셀 임포트 동작 diff --git a/docs/migration/project/ddl-extracted/200_pms_wbs.sql b/docs/migration/project/ddl-extracted/200_pms_wbs.sql new file mode 100644 index 00000000..0fc2c581 --- /dev/null +++ b/docs/migration/project/ddl-extracted/200_pms_wbs.sql @@ -0,0 +1,333 @@ +-- ============================================================ +-- WBS관리(P2) 운영 DDL — wace_plm 운영DB(211.115.91.141:11133/waceplm) 추출 +-- 추출일: 2026-05-11 +-- 추출 방법: information_schema 쿼리 (pg_dump 14.19 ↔ PG 16.8 mismatch) +-- 대상 테이블 8개 (운영 카운트): +-- pms_wbs_task 58 cols / 0건 +-- pms_wbs_task_info 22 cols / 518건 ← 일반 task 리스트 (실사용) +-- pms_wbs_task_confirm 7 cols / 0건 (objid numeric) +-- pms_wbs_task_standard 10 cols / 5건 (트리 표준) +-- pms_wbs_task_standard2 20 cols / 74건 ← 일반 task 표준 (실사용) +-- pms_wbs_template 6 cols / 1건 +-- setup_wbs_task 20 cols / 2,576건 ← 진척율 데이터 +-- setup_wbs_task_standard 19 cols / 46건 +-- +-- 비고: +-- · 운영 스키마 1:1 보존 — 길이 명시 없는 varchar 그대로(무제한). +-- · objid 컬럼은 wace Java 측에서 UUID/시퀀스 생성(시퀀스 없음). +-- · company_code 분기 없음(vexplor_rps는 COMPANY_16 단독). +-- ============================================================ + +BEGIN; + +-- ------------------------------------------------------------ +-- 1) pms_wbs_task (WBS 트리 — 설계/구매/제작/자체검사/최종검사/출하/셋업 단계별) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS pms_wbs_task CASCADE; +CREATE TABLE pms_wbs_task ( + objid varchar, + contract_objid varchar, + parent_objid varchar, + task_name varchar(1000) DEFAULT NULL, + task_seq varchar, + design_user_id varchar, + design_plan_start varchar, + design_plan_end varchar, + design_act_start varchar, + design_act_end varchar, + purchase_user_id varchar, + purchase_plan_start varchar, + purchase_plan_end varchar, + purchase_act_start varchar, + purchase_act_end varchar, + produce_user_id varchar, + produce_plan_start varchar, + produce_plan_end varchar, + produce_act_start varchar, + produce_act_end varchar, + selfins_user_id varchar, + selfins_plan_start varchar, + selfins_plan_end varchar, + selfins_act_start varchar, + selfins_act_end varchar, + finalins_user_id varchar, + finalins_plan_start varchar, + finalins_plan_end varchar, + finalins_act_start varchar, + finalins_act_end varchar, + ship_user_id varchar, + ship_plan_start varchar, + ship_plan_end varchar, + ship_act_start varchar, + ship_act_end varchar, + setup_user_id varchar, + setup_plan_start varchar, + setup_plan_end varchar, + setup_act_start varchar, + setup_act_end varchar, + writer varchar, + design_rate varchar DEFAULT '0', + purchase_rate varchar DEFAULT '0', + produce_rate varchar DEFAULT '0', + selfins_rate varchar DEFAULT '0', + finalins_rate varchar DEFAULT '0', + ship_rate varchar DEFAULT '0', + setup_rate varchar DEFAULT '0', + unit_no varchar, + reg_date timestamp, + update_date timestamp, + modifier varchar, + task_level varchar(10) DEFAULT '', + wbs_type varchar(20) DEFAULT '', + remark text DEFAULT '', + upper_task_objid varchar(255) DEFAULT '', + template_task_objid varchar(255) DEFAULT '', + progress varchar(10) DEFAULT '' +); +CREATE UNIQUE INDEX wbs_task_pk ON pms_wbs_task USING btree (objid); +CREATE INDEX pms_wbs_task_contract_objid_idx ON pms_wbs_task USING btree (contract_objid); + +COMMENT ON COLUMN pms_wbs_task.objid IS '키'; +COMMENT ON COLUMN pms_wbs_task.contract_objid IS '계약키값'; +COMMENT ON COLUMN pms_wbs_task.parent_objid IS '부모키'; +COMMENT ON COLUMN pms_wbs_task.task_name IS 'task이름'; +COMMENT ON COLUMN pms_wbs_task.task_seq IS 'task순번'; +COMMENT ON COLUMN pms_wbs_task.design_user_id IS '설계담당'; +COMMENT ON COLUMN pms_wbs_task.design_plan_start IS '설계계획시작일'; +COMMENT ON COLUMN pms_wbs_task.design_plan_end IS '설계계획종료일'; +COMMENT ON COLUMN pms_wbs_task.design_act_start IS '설계시작일'; +COMMENT ON COLUMN pms_wbs_task.design_act_end IS '설계종료일'; +COMMENT ON COLUMN pms_wbs_task.purchase_user_id IS '구매담당'; +COMMENT ON COLUMN pms_wbs_task.purchase_plan_start IS '구매계획시작일'; +COMMENT ON COLUMN pms_wbs_task.purchase_plan_end IS '구매계획종료일'; +COMMENT ON COLUMN pms_wbs_task.purchase_act_start IS '구매시작일'; +COMMENT ON COLUMN pms_wbs_task.purchase_act_end IS '구매종료일'; +COMMENT ON COLUMN pms_wbs_task.produce_user_id IS '제작담당자'; +COMMENT ON COLUMN pms_wbs_task.produce_plan_start IS '제작계획시작일'; +COMMENT ON COLUMN pms_wbs_task.produce_plan_end IS '제작계획종료일'; +COMMENT ON COLUMN pms_wbs_task.produce_act_start IS '제작시작일'; +COMMENT ON COLUMN pms_wbs_task.produce_act_end IS '제작종료일'; +COMMENT ON COLUMN pms_wbs_task.writer IS '작성자'; +COMMENT ON COLUMN pms_wbs_task.design_rate IS '설계진척율'; +COMMENT ON COLUMN pms_wbs_task.reg_date IS '등록일'; +COMMENT ON COLUMN pms_wbs_task.update_date IS '수정일'; +COMMENT ON COLUMN pms_wbs_task.modifier IS '수정자'; + +-- ------------------------------------------------------------ +-- 2) pms_wbs_task_info (일반 task 리스트 — 실사용 518건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS pms_wbs_task_info CASCADE; +CREATE TABLE pms_wbs_task_info ( + objid varchar(64) NOT NULL, + target_objid varchar(64), + task_step varchar(32), + task_name varchar(256), + task_seq varchar(32), + dept_code varchar(32), + manager_user_id varchar(32), + task_perform_day varchar(32), + plan_start_date varchar(64), + plan_end_date varchar(64), + result_start_date varchar(64), + result_end_date varchar(64), + expected_point varchar(32), + standard_doc_name varchar(512), + task_status varchar(32), + pm_user_id varchar(32), + pm_confirm_status varchar(32), + pm_confirm_date varchar(64), + remark varchar(256), + writer varchar(32), + reg_date timestamp, + update_date timestamp, + CONSTRAINT pms_wbs_task_info_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN pms_wbs_task_info.objid IS '유일키'; +COMMENT ON COLUMN pms_wbs_task_info.target_objid IS '프로젝트 유일키'; +COMMENT ON COLUMN pms_wbs_task_info.task_step IS '테스크 단계(Phase)'; +COMMENT ON COLUMN pms_wbs_task_info.task_name IS '테스크 명'; +COMMENT ON COLUMN pms_wbs_task_info.task_seq IS '테스트 순서'; +COMMENT ON COLUMN pms_wbs_task_info.dept_code IS '부서코드'; +COMMENT ON COLUMN pms_wbs_task_info.manager_user_id IS '담당자코드'; +COMMENT ON COLUMN pms_wbs_task_info.task_perform_day IS '수행소요일'; +COMMENT ON COLUMN pms_wbs_task_info.plan_start_date IS '계획 시작일'; +COMMENT ON COLUMN pms_wbs_task_info.plan_end_date IS '계획 종료일'; +COMMENT ON COLUMN pms_wbs_task_info.result_start_date IS '실적 시작일'; +COMMENT ON COLUMN pms_wbs_task_info.result_end_date IS '실적 종료일'; +COMMENT ON COLUMN pms_wbs_task_info.expected_point IS '예상시점'; +COMMENT ON COLUMN pms_wbs_task_info.standard_doc_name IS '표준문서명'; +COMMENT ON COLUMN pms_wbs_task_info.task_status IS '테스트 상태'; +COMMENT ON COLUMN pms_wbs_task_info.pm_user_id IS 'PM 아이디'; +COMMENT ON COLUMN pms_wbs_task_info.pm_confirm_status IS 'PM 승인 상태'; +COMMENT ON COLUMN pms_wbs_task_info.pm_confirm_date IS 'PM 승인 일자'; +COMMENT ON COLUMN pms_wbs_task_info.remark IS '비고'; +COMMENT ON COLUMN pms_wbs_task_info.writer IS '작성자'; +COMMENT ON COLUMN pms_wbs_task_info.reg_date IS '등록일'; +COMMENT ON COLUMN pms_wbs_task_info.update_date IS '수정일'; + +-- ------------------------------------------------------------ +-- 3) pms_wbs_task_confirm (작업 확정 — objid numeric, 0건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS pms_wbs_task_confirm CASCADE; +CREATE TABLE pms_wbs_task_confirm ( + objid numeric, + target_objid numeric, + confirm_type varchar(32), + contents varchar(4000), + result varchar(32), + regdate timestamp, + writer varchar(32) +); + +-- ------------------------------------------------------------ +-- 4) pms_wbs_task_standard (트리 표준 — 5건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS pms_wbs_task_standard CASCADE; +CREATE TABLE pms_wbs_task_standard ( + objid varchar NOT NULL, + parent_objid varchar, + task_name varchar, + task_seq varchar, + user_id varchar, + writer varchar, + reg_date timestamp, + unit_no varchar, + upper_task_objid varchar, + task_level varchar, + CONSTRAINT pms_wbs_task_standard_pkey PRIMARY KEY (objid) +); + +-- ------------------------------------------------------------ +-- 5) pms_wbs_task_standard2 (일반 task 표준 — 실사용 74건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS pms_wbs_task_standard2 CASCADE; +CREATE TABLE pms_wbs_task_standard2 ( + task_step varchar(32), + task_name varchar(256), + task_seq varchar(32), + dept_code varchar(32), + manager_user_id varchar(32), + task_perform_day varchar(32), + plan_start_date varchar(64), + plan_end_date varchar(64), + result_start_date varchar(64), + result_end_date varchar(64), + expected_point varchar(64), + standard_doc_name varchar(512), + task_status varchar(32), + pm_user_id varchar(32), + pm_confirm_status varchar(32), + pm_confirm_date varchar(64), + remark varchar(256), + writer varchar(32), + reg_date timestamp, + update_date timestamp +); + +COMMENT ON COLUMN pms_wbs_task_standard2.task_step IS '테스크 단계(Phase)'; +COMMENT ON COLUMN pms_wbs_task_standard2.task_name IS '테스크 명'; +COMMENT ON COLUMN pms_wbs_task_standard2.dept_code IS '부서코드'; +COMMENT ON COLUMN pms_wbs_task_standard2.manager_user_id IS '담당자코드'; +COMMENT ON COLUMN pms_wbs_task_standard2.task_perform_day IS '수행소요일'; +COMMENT ON COLUMN pms_wbs_task_standard2.plan_start_date IS '계획 시작일'; +COMMENT ON COLUMN pms_wbs_task_standard2.plan_end_date IS '계획 종료일'; +COMMENT ON COLUMN pms_wbs_task_standard2.result_start_date IS '실적 시작일'; +COMMENT ON COLUMN pms_wbs_task_standard2.result_end_date IS '실적 종료일'; +COMMENT ON COLUMN pms_wbs_task_standard2.expected_point IS '예상시점'; +COMMENT ON COLUMN pms_wbs_task_standard2.standard_doc_name IS '표준문서명'; +COMMENT ON COLUMN pms_wbs_task_standard2.task_status IS '테스트 상태'; +COMMENT ON COLUMN pms_wbs_task_standard2.pm_user_id IS 'PM 아이디'; +COMMENT ON COLUMN pms_wbs_task_standard2.pm_confirm_status IS 'PM 승인 상태'; +COMMENT ON COLUMN pms_wbs_task_standard2.pm_confirm_date IS 'PM 승인 일자'; +COMMENT ON COLUMN pms_wbs_task_standard2.remark IS '비고'; +COMMENT ON COLUMN pms_wbs_task_standard2.writer IS '작성자'; +COMMENT ON COLUMN pms_wbs_task_standard2.reg_date IS '등록일'; +COMMENT ON COLUMN pms_wbs_task_standard2.update_date IS '수정일'; + +-- ------------------------------------------------------------ +-- 6) pms_wbs_template (제품별 WBS 템플릿 — 1건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS pms_wbs_template CASCADE; +CREATE TABLE pms_wbs_template ( + objid varchar NOT NULL, + product_objid varchar, + title varchar, + writer varchar, + reg_date timestamp, + customer_product varchar, + CONSTRAINT pms_wbs_template_pkey PRIMARY KEY (objid) +); + +-- ------------------------------------------------------------ +-- 7) setup_wbs_task (셋업 작업 진척 — 운영 2,576건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS setup_wbs_task CASCADE; +CREATE TABLE setup_wbs_task ( + objid varchar, + contract_objid varchar, + parent_objid varchar, + task_category varchar, + task_name varchar(1000) DEFAULT NULL, + standard_objid varchar, + setup_plan_start varchar, + setup_plan_end varchar, + setup_act_start varchar, + setup_act_end varchar, + setup_delaye_day varchar, + writer varchar, + employees_in varchar, + employees_out varchar, + employees_total varchar, + setup_rate varchar DEFAULT '0', + unit_no varchar, + task_seq varchar, + proj_step varchar, + regdate timestamp +); +CREATE UNIQUE INDEX setup_wbs_task_pk ON setup_wbs_task USING btree (objid); +CREATE INDEX setup_wbs_task_contract_objid_idx ON setup_wbs_task USING btree (contract_objid); + +COMMENT ON COLUMN setup_wbs_task.objid IS 'objid'; +COMMENT ON COLUMN setup_wbs_task.contract_objid IS 'project_objid'; +COMMENT ON COLUMN setup_wbs_task.parent_objid IS 'task부모키'; +COMMENT ON COLUMN setup_wbs_task.task_category IS 'TASK구분'; +COMMENT ON COLUMN setup_wbs_task.task_name IS 'TASK명'; +COMMENT ON COLUMN setup_wbs_task.setup_plan_start IS '계획시작일'; +COMMENT ON COLUMN setup_wbs_task.setup_plan_end IS '계획완료일'; +COMMENT ON COLUMN setup_wbs_task.setup_act_start IS '실적시작일'; +COMMENT ON COLUMN setup_wbs_task.setup_act_end IS '실적완료일'; +COMMENT ON COLUMN setup_wbs_task.writer IS '작성자'; +COMMENT ON COLUMN setup_wbs_task.employees_in IS '자사투입인원'; +COMMENT ON COLUMN setup_wbs_task.employees_out IS '외주투입인원'; +COMMENT ON COLUMN setup_wbs_task.regdate IS '등록일'; + +-- ------------------------------------------------------------ +-- 8) setup_wbs_task_standard (셋업 표준 — 46건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS setup_wbs_task_standard CASCADE; +CREATE TABLE setup_wbs_task_standard ( + objid varchar, + contract_objid varchar, + parent_objid varchar, + task_category varchar, + task_name varchar(1000) DEFAULT NULL, + setup_user_id varchar, + setup_plan_start varchar, + setup_plan_end varchar, + setup_act_start varchar, + setup_act_end varchar, + setup_delaye_day varchar, + writer varchar, + employees_in varchar, + employees_out varchar, + employees_total varchar, + setup_rate varchar DEFAULT '0', + unit_no varchar, + task_seq varchar, + proj_step varchar +); +-- 운영 인덱스 이름에 공백 포함됨(타이포로 추정): "setup_wbs_task_standard _objid_key" +CREATE UNIQUE INDEX "setup_wbs_task_standard _objid_key" ON setup_wbs_task_standard USING btree (objid); + +COMMIT; diff --git a/docs/migration/project/ddl-extracted/README.md b/docs/migration/project/ddl-extracted/README.md new file mode 100644 index 00000000..ce64ff19 --- /dev/null +++ b/docs/migration/project/ddl-extracted/README.md @@ -0,0 +1,102 @@ +# 추출된 DDL (wace_plm 운영 DB) — 프로젝트관리(P2) + +> 추출일: 2026-05-11 +> 출처: `211.115.91.141:11133/waceplm` (PostgreSQL 16.8) +> 적용 대상: `211.115.91.141:11134/vexplor_rps` +> 추출 방법: information_schema 쿼리 (pg_dump 14.19 ↔ 16.8 버전 불일치로 직접 추출 불가 — 영업관리 패턴과 동일) + +vexplor_rps에 부재한 WBS 계열 8개 테이블을 운영DB에서 추출해 P2(WBS관리)용 베이스 스키마 구성. + +## 파일 + +| 파일 | 테이블 | 컬럼 | 운영 데이터 | +|---|---|---:|---:| +| `200_pms_wbs.sql` | `pms_wbs_task` (트리 — 설계/구매/제작/자체검사/최종검사/출하/셋업) | 58 | 0건 | +| | `pms_wbs_task_info` (**일반 task 리스트 — 실사용**) | 22 | 518건 | +| | `pms_wbs_task_confirm` (작업 확정, objid numeric) | 7 | 0건 | +| | `pms_wbs_task_standard` (트리 표준) | 10 | 5건 | +| | `pms_wbs_task_standard2` (**일반 task 표준 — 실사용**) | 20 | 74건 | +| | `pms_wbs_template` (제품별 템플릿) | 6 | 1건 | +| | `setup_wbs_task` (**셋업 진척 — 최다 사용**) | 20 | 2,576건 | +| | `setup_wbs_task_standard` (셋업 표준) | 19 | 46건 | + +운영 컬럼 합 162개 / vexplor_rps 적용 후 162개 — 100% 일치 확인. + +## 핵심 발견 + +### 1. 두 갈래 WBS 구조 (트리 vs 평면 리스트) + +운영DB에 **두 종류 task 테이블이 공존**: + +| 갈래 | 구조 | 운영 데이터 | +|---|---|---| +| **`pms_wbs_task` + `_standard`** | parent_objid 트리 / 단계별(설계/구매/제작/자체검사/최종검사/출하/셋업) plan/act/rate 컬럼 | 0건 / 5건 ← **사실상 미사용** | +| **`pms_wbs_task_info` + `_standard2`** | 평면 리스트 / task_step+task_seq 정렬 / PM 승인 흐름 | 518건 / 74건 ← **실사용** | + +→ **운영판이 실제 어느 화면에서 어느 갈래를 쓰는지** wace JSP 분석 + 운영판 화면 캡처로 결정해야 함. `_info` 갈래가 데이터량 압도적이라 메인 후보지만, JSP 화면이 `pms_wbs_task`(트리)를 가리킬 가능성도 있음 — 두 갈래 모두 분석. + +### 2. setup_wbs_task가 진척율 데이터 (P1 진행관리 그리드와 연결) + +`setup_wbs_task` 2,576건이 운영에서 가장 큰 WBS 데이터. 진행관리 P1 그리드 컬럼 `제조1,2팀 / 제조3팀 / 조립 / 검증 / 출하일` 자리가 P1에선 빈 자리였는데, **이게 setup_wbs_task에서 채워지는 데이터**일 가능성 높음 (P1 GAP 문서 확인 필요). + +### 3. 인덱스 명 typo + +`setup_wbs_task_standard _objid_key` (`_standard` 뒤에 공백 한 칸) — 운영 그대로 1:1 보존 (`"…"` 따옴표 처리). + +### 4. PK/UNIQUE 정책 일관성 없음 + +- PK: `pms_wbs_task_info`, `pms_wbs_task_standard`, `pms_wbs_template` (objid) +- UNIQUE 인덱스만: `pms_wbs_task`(wbs_task_pk), `setup_wbs_task`(setup_wbs_task_pk), `setup_wbs_task_standard` +- 제약 0: `pms_wbs_task_confirm`, `pms_wbs_task_standard2` + +운영 1:1 보존. PostgreSQL 측에서는 UNIQUE 인덱스만 있어도 ON CONFLICT 등 동작에 영향 없음. + +### 5. objid 채번 방식 + +운영DB에 WBS 시퀀스 없음 → wace Java 측에서 `UUID.randomUUID()` 또는 자체 시퀀스 함수로 생성한 문자열 사용. vexplor_rps에서는 nanoid/uuid 등 backend-node 측 채번 패턴 적용 예정 (영업관리 sales_no 패턴과는 다름). + +## 운영 데이터 시드 (선택) + +운영 데이터를 vexplor_rps에 가져오려면(데이터량 작은 표준/템플릿만): + +```bash +# pg_dump 직접 사용 불가(버전 mismatch) — psql COPY 또는 INSERT 추출 +PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \ + -c "COPY (SELECT * FROM pms_wbs_task_standard2) TO STDOUT WITH CSV HEADER" \ + > /tmp/pms_wbs_task_standard2.csv + +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \ + -c "\COPY pms_wbs_task_standard2 FROM '/tmp/pms_wbs_task_standard2.csv' WITH CSV HEADER" +``` + +`setup_wbs_task` 2,576건도 동일 방식. 실 데이터 의존성(contract_objid가 운영 project_mgmt.objid를 참조) 때문에 vexplor_rps 측 데이터와 맞지 않을 가능성 — 시드는 화면 검증 단계에서 케이스별 판단. + +## 추출 명령 재현 + +```bash +# 컬럼 정보 +PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm -t -A -F '|' -c " +SELECT table_name||'|'||ordinal_position||'|'||column_name||'|'||data_type||'|'||COALESCE(character_maximum_length::text,'')||'|'||COALESCE(numeric_precision::text,'')||'|'||COALESCE(numeric_scale::text,'')||'|'||is_nullable||'|'||COALESCE(column_default,'') +FROM information_schema.columns +WHERE table_schema='public' + AND table_name IN ('pms_wbs_task','pms_wbs_task_info','pms_wbs_task_confirm','pms_wbs_task_standard','pms_wbs_task_standard2','pms_wbs_template','setup_wbs_task','setup_wbs_task_standard') +ORDER BY table_name, ordinal_position;" + +# 제약조건 +... information_schema.table_constraints JOIN key_column_usage + +# 인덱스 +... pg_indexes WHERE schemaname='public' + +# 컬럼 코멘트 +... information_schema.columns LEFT JOIN pg_statio_all_tables JOIN pg_description +``` + +## 적용 + +```bash +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \ + -v ON_ERROR_STOP=1 -f 200_pms_wbs.sql +``` + +8개 테이블 모두 IDEMPOTENT (`DROP TABLE IF EXISTS … CASCADE`로 재실행 안전). diff --git a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx new file mode 100644 index 00000000..c3d747cb --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +// 프로젝트관리 > 제품구분_WBS관리 (wace wbsTemplateMngList.jsp 1:1 이식) +// 원본: +// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/wbsTemplateMngList.jsp (378줄) +// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:5552 wbsTemplateMngGridList +// GAP: docs/migration/project/02-wbs-template.md +// +// 그리드: 5컬럼 (제품구분 / 제목 / WBS(folder) / 등록자 / 등록일) +// 검색: 제품구분 단일 +// 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1) + +import React, { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Search, Loader2, RotateCcw, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { Label } from "@/components/ui/label"; +import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate"; +import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog"; + +const PRODUCT_GROUP = "0000001"; // 제품구분 + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "product_name", label: "제품구분", width: "w-[200px]", frozen: true }, + { key: "title", label: "제목", minWidth: "min-w-[260px]" }, + { + key: "wbs_task_cnt", + label: "WBS", + width: "w-[100px]", + align: "center", + renderType: "folder", // wace fnc_getFolderIcon + }, + { key: "writer_title", label: "등록자", width: "w-[180px]" }, + { key: "reg_date_title", label: "등록일", width: "w-[130px]", align: "center" }, +]; + +export default function WbsTemplatePage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [filterProduct, setFilterProduct] = useState(""); + const [checkedIds, setCheckedIds] = useState([]); + + // 다이얼로그 상태 + const [dialogOpen, setDialogOpen] = useState(false); + const [editObjId, setEditObjId] = useState(null); + const [defaultProduct, setDefaultProduct] = useState(""); + + const fetchList = useCallback(async (product?: string) => { + setLoading(true); + try { + const data = await wbsTemplateApi.list(product || undefined); + setRows(data); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + const handleSearch = () => fetchList(filterProduct); + const handleReset = () => { + setFilterProduct(""); + fetchList(); + }; + + // 등록 (wace btnRegist click — product 선택 필수) + const handleRegist = () => { + if (!filterProduct) { + toast.error("제품은 필수값입니다. 제품을 선택해 주세요."); + return; + } + setEditObjId(null); + setDefaultProduct(filterProduct); + setDialogOpen(true); + }; + + // 수정 (wace fn_openWBSTaskListPopUp — WBS 폴더 컬럼 클릭) + const handleOpenEdit = (row: any) => { + setEditObjId(row.objid); + setDefaultProduct(""); + setDialogOpen(true); + }; + + // 삭제 (wace fn_delete — 체크된 행 다건 삭제) + const handleDelete = async () => { + if (checkedIds.length === 0) { + toast.error("선택된 대상이 없습니다."); + return; + } + if (!confirm("삭제하시겠습니까?")) return; + try { + const res = await wbsTemplateApi.remove(checkedIds); + toast.success(res?.msg ?? "삭제하였습니다."); + fetchList(filterProduct); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + // DataGrid 컬럼에 folder 클릭 핸들러 주입 + const columns: DataGridColumn[] = GRID_COLUMNS.map((c) => + c.key === "wbs_task_cnt" ? { ...c, onClick: handleOpenEdit } : c + ); + + return ( +
+ {/* 검색폼 — wace wbsTemplateMngList.jsp:361-371 (제품구분 1필드) */} +
+
+
+ + +
+
+ + + + +
+
+
+ + {/* 그리드 (5컬럼) */} +
+ +
+ + {/* 통합 팝업 */} + fetchList(filterProduct)} + /> +
+ ); +} diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index e9ec6f51..8b911afc 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -752,6 +752,8 @@ export function DataGrid({ {columns.map((col) => { const w = columnWidths[col.key]; const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined; + // 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외) + const cellClickable = !!col.onClick && !col.editable; return ( { e.stopPropagation(); col.onClick!(row); } : undefined} onDoubleClick={(e) => { if (col.editable) { e.stopPropagation(); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 6aa6eaef..398518ab 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -105,6 +105,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/components/project/WbsTemplateDialog.tsx b/frontend/components/project/WbsTemplateDialog.tsx new file mode 100644 index 00000000..8dd61ea1 --- /dev/null +++ b/frontend/components/project/WbsTemplateDialog.tsx @@ -0,0 +1,440 @@ +"use client"; + +// 프로젝트관리 > 제품구분_WBS관리 통합 팝업 +// wace WBSExcelImportPopUp.jsp (613줄) 1:1 이식 +// - 트리 CRUD (추가/하위추가/삭제) + 수준 1/2/3 자동 번호 + 엑셀 임포트 + 템플릿 다운로드 + 저장 +// - 헤더(제품구분/제목): 수정 모드에서 disabled (wace mergeExcelUploadWBS는 헤더 UPDATE 안 함) + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { Loader2, Download, Plus, CornerDownRight, Trash2, Save, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { wbsTemplateApi, WbsTaskInput } from "@/lib/api/wbsTemplate"; + +const PRODUCT_GROUP = "0000001"; +const TEMPLATE_DOWNLOAD_URL = "/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx"; + +// 클라이언트 행 모델 +interface WbsRow { + objid: string; // 클라이언트 임시 ID (UUID) — 저장 시 그대로 DB OBJID + depth: number; // 0=TOTAL, 1~3 + unitNo: string; // "1", "1.1", "1.1.1" (자동 renumber로 채움) + taskName: string; + checked: boolean; +} + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + templateObjId: string | null; // null=신규 + defaultProduct: string; // 신규 시 메인의 검색 product + onSaved: () => void; +} + +function newObjId(): string { + return crypto.randomUUID(); +} + +function makeTotalRow(): WbsRow { + return { objid: newObjId(), depth: 0, unitNo: "0", taskName: "TOTAL", checked: false }; +} + +export function WbsTemplateDialog({ open, onOpenChange, templateObjId, defaultProduct, onSaved }: Props) { + const isEditMode = !!templateObjId; + const fileInputRef = useRef(null); + + const [product, setProduct] = useState(""); + const [title, setTitle] = useState(""); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // ─── 모드 분기 (wace WBSExcelImportPopUp.jsp:21-30) ────── + useEffect(() => { + if (!open) return; + if (isEditMode && templateObjId) { + loadExistingTasks(templateObjId); + } else { + setProduct(defaultProduct); + setTitle(""); + setRows([makeTotalRow()]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const loadExistingTasks = async (objid: string) => { + setLoading(true); + try { + const detail = await wbsTemplateApi.detail(objid); + if (!detail) { + toast.error("템플릿을 찾을 수 없습니다."); + onOpenChange(false); + return; + } + setProduct(detail.master.product_objid); + setTitle(detail.master.title); + + // wace loadExistingTasks 1:1: TASK_LEVEL=0 → TOTAL 행, 그 외 depth/unit_no 매핑 + const list: WbsRow[] = detail.tasks.map((t) => { + const depth = parseInt(t.task_level, 10); + if (depth === 0) { + return { objid: t.objid, depth: 0, unitNo: "0", taskName: t.task_name || "TOTAL", checked: false }; + } + return { + objid: t.objid, + depth, + unitNo: t.unit_no || "", + taskName: t.task_name || "", + checked: false, + }; + }); + // TOTAL이 없을 경우 보강 + if (!list.some((r) => r.depth === 0)) list.unshift(makeTotalRow()); + setRows(list); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + // ─── 트리 동작 ───────────────────────────────────────── + + // wace renumberAllRows 1:1: depth별 카운터로 1.1.1 형식 재생성 + const renumberRows = useCallback((arr: WbsRow[]): WbsRow[] => { + const counters = [0, 0, 0]; + return arr.map((r) => { + if (r.depth === 0) return r; + const d = r.depth; + if (d === 1) { counters[0] += 1; counters[1] = 0; counters[2] = 0; } + else if (d === 2) { counters[1] += 1; counters[2] = 0; } + else if (d === 3) { counters[2] += 1; } + const unit = d === 1 ? `${counters[0]}` + : d === 2 ? `${counters[0]}.${counters[1]}` + : `${counters[0]}.${counters[1]}.${counters[2]}`; + return { ...r, unitNo: unit }; + }); + }, []); + + // 마지막 후손 인덱스 (wace findLastDescendant) + const findLastDescendantIdx = (arr: WbsRow[], parentIdx: number): number => { + const parentDepth = arr[parentIdx].depth; + let last = parentIdx; + for (let i = parentIdx + 1; i < arr.length; i++) { + if (arr[i].depth > parentDepth) last = i; + else break; + } + return last; + }; + + // 추가 (wace addRow): 선택된 행 다음에 같은 depth 행 추가. 선택 없으면 끝에 depth=1. + const handleAddRow = () => { + setRows((prev) => { + const arr = prev.map((r) => ({ ...r, checked: false })); + const selectedIdx = prev.map((r, i) => (r.checked ? i : -1)).filter((i) => i >= 0).pop(); + let insertAt: number; + let depth: number; + if (selectedIdx !== undefined && selectedIdx >= 0) { + const sel = prev[selectedIdx]; + depth = sel.depth === 0 ? 1 : sel.depth; + insertAt = findLastDescendantIdx(prev, selectedIdx) + 1; + } else { + depth = 1; + insertAt = arr.length; + } + const newRow: WbsRow = { objid: newObjId(), depth, unitNo: "", taskName: "", checked: false }; + const next = [...arr.slice(0, insertAt), newRow, ...arr.slice(insertAt)]; + return renumberRows(next); + }); + }; + + // 하위추가 (wace addChildRow): depth+1 행 추가 (3 거부) + const handleAddChildRow = () => { + const selectedIdx = rows.map((r, i) => (r.checked ? i : -1)).filter((i) => i >= 0).pop(); + if (selectedIdx === undefined || selectedIdx < 0) { + toast.error("부모 행을 선택해 주세요."); + return; + } + const sel = rows[selectedIdx]; + if (sel.depth >= 3) { + toast.error("수준 3 이하로는 추가할 수 없습니다."); + return; + } + setRows((prev) => { + const arr = prev.map((r) => ({ ...r, checked: false })); + const insertAt = findLastDescendantIdx(prev, selectedIdx) + 1; + const newRow: WbsRow = { objid: newObjId(), depth: sel.depth + 1, unitNo: "", taskName: "", checked: false }; + const next = [...arr.slice(0, insertAt), newRow, ...arr.slice(insertAt)]; + return renumberRows(next); + }); + }; + + // 삭제 (wace deleteRow): 선택 행 + 모든 후손 cascade + const handleDeleteRow = () => { + const checkedIdx = rows.map((r, i) => (r.checked ? i : -1)).filter((i) => i >= 0); + if (checkedIdx.length === 0) { + toast.error("삭제할 행을 선택해 주세요."); + return; + } + const removeSet = new Set(); + let hasChildren = false; + for (const idx of checkedIdx) { + if (rows[idx].depth === 0) continue; // TOTAL 보호 + removeSet.add(idx); + const lastDesc = findLastDescendantIdx(rows, idx); + if (lastDesc > idx) { + hasChildren = true; + for (let j = idx + 1; j <= lastDesc; j++) removeSet.add(j); + } + } + const msg = hasChildren ? "하위 항목도 함께 삭제됩니다. 삭제하시겠습니까?" : "삭제하시겠습니까?"; + if (!confirm(msg)) return; + setRows((prev) => renumberRows(prev.filter((_, i) => !removeSet.has(i)))); + }; + + // 체크박스 토글 + const toggleCheck = (idx: number, val: boolean) => { + setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, checked: val } : r))); + }; + + // taskName 수정 + const updateTaskName = (idx: number, name: string) => { + setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, taskName: name } : r))); + }; + + // ─── 엑셀 임포트 (wace parsingExcelFile 1:1) ──────────── + + const handleExcelFile = async (file: File) => { + setLoading(true); + try { + const parsed = await wbsTemplateApi.parseExcel(file); + // 결과를 행으로 변환 (depth는 unit_no의 '.' 개수+1) + const total = makeTotalRow(); + const newRows: WbsRow[] = parsed.map((p) => { + const dotCount = (p.UNIT_NO.match(/\./g) || []).length; + const depth = Math.max(1, Math.min(3, dotCount + 1)); + return { objid: newObjId(), depth, unitNo: "", taskName: p.TASK_NAME, checked: false }; + }); + setRows(renumberRows([total, ...newRows])); + toast.success(`${newRows.length}건 임포트 완료`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); + } finally { + setLoading(false); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (!confirm("파일을 업로드하시겠습니까?\n기존에 입력된 항목은 삭제됩니다.")) { + e.target.value = ""; + return; + } + handleExcelFile(file); + } + e.target.value = ""; + }; + + // ─── 저장 (wace saveWBS 1:1) ────────────────────────── + + const handleSave = async () => { + if (!product) { toast.error("제품구분을 선택해 주세요."); return; } + if (!title.trim()) { toast.error("제목을 입력해 주세요."); return; } + const dataRows = rows.filter((r) => r.depth > 0); + if (dataRows.length === 0) { toast.error("등록할 항목을 추가해 주세요."); return; } + for (const r of dataRows) { + if (!r.unitNo || !r.taskName.trim()) { + toast.error("수준과 Unit Name / 공정을 모두 입력해 주세요."); + return; + } + } + + // 신규 모드: 중복 체크 + if (!isEditMode) { + const dup = await wbsTemplateApi.checkDuplicate(product, title.trim()); + if (dup) { + toast.error("이미 해당 제목으로 등록된 정보가 존재합니다."); + return; + } + } + + // wace calculateParentRelations 1:1 + const totalIdx = rows.findIndex((r) => r.depth === 0); + const totalObjId = totalIdx >= 0 ? rows[totalIdx].objid : ""; + const tasks: WbsTaskInput[] = rows.map((r, i) => { + let upper = ""; + if (r.depth === 0) { + upper = ""; + } else if (r.depth === 1) { + upper = totalObjId; + } else { + for (let j = i - 1; j >= 0; j--) { + if (rows[j].depth === r.depth - 1) { upper = rows[j].objid; break; } + } + } + return { + WBS_TASK_OBJID: r.objid, + TASK_NAME: r.depth === 0 ? "TOTAL" : r.taskName, + UNIT_NO: r.depth === 0 ? "0" : r.unitNo, + UPPER_TASK_OBJID: upper, + TASK_LEVEL: String(r.depth), + }; + }); + + if (!confirm("저장하시겠습니까?")) return; + setSaving(true); + try { + await wbsTemplateApi.save({ + templateObjId: templateObjId ?? undefined, + product, + title: title.trim(), + tasks, + }); + toast.success("저장하였습니다."); + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const titleText = useMemo(() => (isEditMode ? "WBS 템플릿 수정" : "WBS 템플릿 등록"), [isEditMode]); + + return ( + + + + {titleText} + + + {/* 헤더 — 제품구분 + 제목 (수정 시 disabled) */} +
+
+ + +
+
+ + setTitle(e.target.value)} + disabled={isEditMode} + placeholder="템플릿 제목" + /> +
+
+ + {/* 액션 버튼 */} +
+ + + +
+ + + +
+
+ + {/* 트리 그리드 */} +
+ + + + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && rows.map((r, idx) => ( + + + {/* 수준 1/2/3 — depth 위치에만 unitNo 표시 */} + + + + + + ))} + {!loading && rows.length === 0 && ( + + )} + +
선택수준Unit Name / 공정
123
+ 로딩 중... +
+ {r.depth > 0 && ( + toggleCheck(idx, v === true)} + /> + )} + {r.depth === 1 ? r.unitNo : ""}{r.depth === 2 ? r.unitNo : ""}{r.depth === 3 ? r.unitNo : ""} + {r.depth === 0 ? ( +
TOTAL
+ ) : ( + updateTaskName(idx, e.target.value)} + className="h-7" + /> + )} +
행이 없습니다.
+
+ + + + + +
+
+ ); +} diff --git a/frontend/lib/api/wbsTemplate.ts b/frontend/lib/api/wbsTemplate.ts new file mode 100644 index 00000000..7b660587 --- /dev/null +++ b/frontend/lib/api/wbsTemplate.ts @@ -0,0 +1,113 @@ +import { apiClient } from "./client"; + +// ─── 타입 (wace project.xml wbsTemplateMngGridList 1:1) ───── + +export interface TemplateRow { + objid: string; + product_objid: string | null; + product_name: string | null; // CODE_NAME(PRODUCT_OBJID) + title: string | null; + writer: string | null; + writer_title: string | null; // DEPT_NAME || USER_NAME + reg_date: string | null; + reg_date_title: string | null; // YYYY-MM-DD + wbs_task_cnt: number | string | null; + customer_product: string | null; +} + +export interface TemplateMaster { + objid: string; + product_objid: string; + product_objid_name: string; + title: string; + writer: string; + reg_date: string; + customer_product: string; +} + +export interface TemplateTask { + objid: string; + parent_objid: string; + task_name: string; + task_seq: string; + task_level: string; + user_id: string; + user_id_title: string; + writer: string; + reg_date: string; + unit_no: string; + upper_task_objid: string; +} + +export interface TemplateDetail { + master: TemplateMaster; + tasks: TemplateTask[]; +} + +// 저장 payload — wace mergeExcelUploadWBS의 폼 hidden 직렬화에 대응 +export interface WbsTaskInput { + WBS_TASK_OBJID: string; + TASK_NAME: string; + UNIT_NO: string; + UPPER_TASK_OBJID: string; + TASK_LEVEL: string; +} + +export interface SaveTemplatePayload { + templateObjId?: string; // 있으면 수정, 없으면 신규 + product: string; + title: string; + customer_product?: string; + tasks: WbsTaskInput[]; // TOTAL 행 포함 (TASK_LEVEL=0) +} + +// 엑셀 파싱 결과 +export interface ParsedExcelRow { + WBS_OBJID: string; + UNIT_NO: string; + TASK_NAME: string; +} + +// ─── API ──────────────────────────────────────────────── + +export const wbsTemplateApi = { + async list(product?: string): Promise { + const res = await apiClient.get("/project/wbs-template", { + params: product ? { product } : {}, + }); + return (res.data?.data ?? []) as TemplateRow[]; + }, + + async detail(objid: string): Promise { + const res = await apiClient.get(`/project/wbs-template/${objid}`); + return res.data?.data ?? null; + }, + + async checkDuplicate(product: string, title: string): Promise { + const res = await apiClient.get("/project/wbs-template/check-duplicate", { + params: { product, title }, + }); + return Boolean(res.data?.data?.duplicate); + }, + + async save(payload: SaveTemplatePayload) { + const res = await apiClient.post("/project/wbs-template", payload); + return res.data; + }, + + async remove(objids: string[]) { + const res = await apiClient.delete("/project/wbs-template", { + data: { objids }, + }); + return res.data; + }, + + async parseExcel(file: File): Promise { + const form = new FormData(); + form.append("file", file); + const res = await apiClient.post("/project/wbs-template/parse-excel", form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return (res.data?.data ?? []) as ParsedExcelRow[]; + }, +}; diff --git a/frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..db0e131f179321ee9444b698a8a7fef0d29fe540 GIT binary patch literal 9303 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 literal 0 HcmV?d00001 From 5c085dc69e940f1f753ae85ba09fe6675a1b7f3b Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 13:44:58 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20wace=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=ED=8C=90=201:1=20+=20=EC=98=81=EC=97=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=90=EB=A7=A4=C2=B7=EB=A7=A4=EC=B6=9C=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 다이얼로그 디자인: 프로젝트정보 박스(주문유형/제품구분/국내해외/고객사·유무상/접수일/견적환종/견적환율) + 품목정보 테이블(No/품번/품명/S/N/요청납기/고객요청사항/반납사유) · ProjectInfoData 정규화 props — 진행관리/판매관리/매출관리 각각 toProjectInfo 매핑으로 호출 · backend SQL 3곳 보강: exchange_rate (contract_mgmt) + customer_request (CI→CM fallback) + return_reason_name (CI CODE_NAME) · 판매관리/매출관리 page에 columns useMemo + project_no 셀 onClick + ProjectInfoDialog state 추가 · wace 운영 URL: /salesMgmt/salesRegForm.do?saleNo=detail 1:1 매핑 (새 창 → 같은 탭 다이얼로그) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/projectMgmtService.ts | 10 ++ backend-node/src/services/salesSaleService.ts | 8 ++ .../COMPANY_16/project/progress/page.tsx | 26 +++- .../(main)/COMPANY_16/sales/revenue/page.tsx | 37 ++++- .../app/(main)/COMPANY_16/sales/sale/page.tsx | 37 ++++- .../components/project/ProjectInfoDialog.tsx | 126 ++++++++++++------ frontend/lib/api/projectMgmt.ts | 5 + frontend/lib/api/salesSale.ts | 14 ++ 8 files changed, 215 insertions(+), 48 deletions(-) diff --git a/backend-node/src/services/projectMgmtService.ts b/backend-node/src/services/projectMgmtService.ts index 7421f062..593a6194 100644 --- a/backend-node/src/services/projectMgmtService.ts +++ b/backend-node/src/services/projectMgmtService.ts @@ -148,6 +148,16 @@ export async function listProgress(filter: ProgressListFilter) { ,T.CONTRACT_PRICE_CURRENCY ,T.CONTRACT_CURRENCY ,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME + ,(SELECT CM.exchange_rate FROM contract_mgmt CM WHERE CM.objid = T.CONTRACT_OBJID) AS EXCHANGE_RATE + ,COALESCE( + (SELECT CI.customer_request FROM contract_item CI + WHERE CI.contract_objid = T.CONTRACT_OBJID AND CI.part_objid = T.PART_OBJID + AND CI.status='ACTIVE' ORDER BY CI.objid DESC LIMIT 1), + (SELECT CM.customer_request FROM contract_mgmt CM WHERE CM.objid = T.CONTRACT_OBJID) + ) AS CUSTOMER_REQUEST + ,(SELECT CODE_NAME(CI.return_reason) FROM contract_item CI + WHERE CI.contract_objid = T.CONTRACT_OBJID AND CI.part_objid = T.PART_OBJID + AND CI.status='ACTIVE' ORDER BY CI.objid DESC LIMIT 1) AS RETURN_REASON_NAME ,T.REGDATE ,TO_CHAR(T.REGDATE,'YYYY-MM-DD') AS REG_DATE ,T.WRITER diff --git a/backend-node/src/services/salesSaleService.ts b/backend-node/src/services/salesSaleService.ts index 14217df5..de0d569e 100644 --- a/backend-node/src/services/salesSaleService.ts +++ b/backend-node/src/services/salesSaleService.ts @@ -143,7 +143,11 @@ export async function getSaleList(filter: SaleListFilter) { ,COALESCE(CC_CUR_S.code_name, CC_CUR.code_name) AS sales_currency_name ,T.contract_currency ,CC_CUR.code_name AS contract_currency_name + ,CM.exchange_rate AS exchange_rate ,SR.sales_exchange_rate + ,(SELECT CODE_NAME(CI2.return_reason) FROM contract_item CI2 + WHERE CI2.contract_objid = T.contract_objid AND CI2.part_objid = T.part_objid + AND CI2.status='ACTIVE' ORDER BY CI2.objid DESC LIMIT 1) AS return_reason_name ,SR.shipping_date ,SR.shipping_method ,SR.shipping_order_status @@ -289,9 +293,13 @@ export async function getRevenueList(filter: SaleListFilter) { END AS sales_total_amount_krw ,T.contract_currency ,CC_CUR.code_name AS contract_currency_name + ,CM.exchange_rate AS exchange_rate ,SR.sales_currency ,CC_CUR_S.code_name AS sales_currency_name ,SR.sales_exchange_rate + ,(SELECT CODE_NAME(CI2.return_reason) FROM contract_item CI2 + WHERE CI2.contract_objid = T.contract_objid AND CI2.part_objid = T.part_objid + AND CI2.status='ACTIVE' ORDER BY CI2.objid DESC LIMIT 1) AS return_reason_name ,SR.shipping_date ,SR.shipping_method ,SR.serial_no diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx index 521037f6..e7984bab 100644 --- a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -21,9 +21,27 @@ import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { PartSelect } from "@/components/common/PartSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; -import { ProjectInfoDialog } from "@/components/project/ProjectInfoDialog"; +import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt"; +// 진행관리 row → 정규화된 ProjectInfoData 매핑 +const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({ + orderType: r.category_name, + productType: r.product_name, + area: r.area_name, + customer: r.customer_name, + paidType: r.free_of_charge, + regDate: r.reg_date, + currency: r.contract_currency_name, + exchangeRate: r.exchange_rate, + partNo: r.product_item_code, + partName: r.product_item_name, + serialNo: r.serial_no, + reqDelDate: r.req_del_date, + customerRequest: r.customer_request, + returnReason: r.return_reason_name, +}); + // wace projectMgmtWbsList3.jsp 컬럼 정의 1:1 (8그룹 → 평탄화, 그룹명은 라벨 prefix) const GRID_COLUMNS: DataGridColumn[] = [ { key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true }, @@ -82,13 +100,13 @@ export default function ProjectProgressPage() { // wace `fn_openSaleRegPopup(PROJECT_NO, "detail")` 대응 — PROJECT_NO 셀 클릭 시 프로젝트 정보 다이얼로그 const [infoOpen, setInfoOpen] = useState(false); - const [infoRow, setInfoRow] = useState(null); + const [infoData, setInfoData] = useState(null); // GRID_COLUMNS의 project_no 셀에만 onClick 주입 (DataGrid 컬럼별 onClick 패턴) const columns = useMemo( () => GRID_COLUMNS.map((col) => col.key === "project_no" - ? { ...col, onClick: (row: any) => { setInfoRow(row as ProgressRow); setInfoOpen(true); } } + ? { ...col, onClick: (row: any) => { setInfoData(toProjectInfo(row as ProgressRow)); setInfoOpen(true); } } : col, ), [], @@ -240,7 +258,7 @@ export default function ProjectProgressPage() { /> - + ); } diff --git a/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx b/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx index a2adb7de..cdfe6c07 100644 --- a/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -19,8 +19,27 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { PartSelect } from "@/components/common/PartSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesSale"; +// RevenueListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1) +const toProjectInfo = (r: RevenueListRow): ProjectInfoData => ({ + orderType: r.order_type_name, + productType: r.product_type_name, + area: r.nation_name, + customer: r.customer, + paidType: r.payment_type_name, + regDate: r.receipt_date, + currency: r.contract_currency_name, + exchangeRate: r.exchange_rate, + partNo: r.product_no, + partName: r.product_name, + serialNo: r.serial_no, + reqDelDate: r.request_date, + customerRequest: r.customer_request, + returnReason: r.return_reason_name, +}); + // wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤 const GRID_COLUMNS: DataGridColumn[] = [ { key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true }, @@ -70,6 +89,18 @@ export default function SalesRevenuePage() { const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); const [checkedIds, setCheckedIds] = useState([]); + + // 프로젝트번호 셀 클릭 → 프로젝트 상세 정보 다이얼로그 (wace 운영판 1:1) + const [infoOpen, setInfoOpen] = useState(false); + const [infoData, setInfoData] = useState(null); + const columns = useMemo( + () => GRID_COLUMNS.map((col) => + col.key === "project_no" + ? { ...col, onClick: (row: any) => { setInfoData(toProjectInfo(row as RevenueListRow)); setInfoOpen(true); } } + : col, + ), + [], + ); // wace revenueMgmtList.jsp 활성 11개 const [searchForm, setSearchForm] = useState({ orderType: "", poNo: "", customer_objid: "", @@ -268,7 +299,7 @@ export default function SalesRevenuePage() { setSelected(id ? rows.find((r) => r.id === id) ?? null : null)} @@ -280,6 +311,8 @@ export default function SalesRevenuePage() { loading={loading} /> + + {/* 마감정보 입력 Dialog */} diff --git a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx index 3f18ed14..20463668 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -17,8 +17,27 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { PartSelect } from "@/components/common/PartSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale"; +// SaleListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1) +const toProjectInfo = (r: SaleListRow): ProjectInfoData => ({ + orderType: r.order_type_name, + productType: r.product_type_name, + area: r.nation_name, + customer: r.customer, + paidType: r.payment_type_name, + regDate: r.receipt_date, + currency: r.contract_currency_name, + exchangeRate: r.exchange_rate, + partNo: r.product_no, + partName: r.product_name, + serialNo: r.serial_no, + reqDelDate: r.request_date, + customerRequest: r.customer_request, + returnReason: r.return_reason_name, +}); + // wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤 const GRID_COLUMNS: DataGridColumn[] = [ { key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true }, @@ -75,6 +94,18 @@ export default function SalesSalePage() { shippingDateFrom: "", shippingDateTo: "", }); + // 프로젝트번호 셀 클릭 → 프로젝트 상세 정보 다이얼로그 (wace 운영판 1:1) + const [infoOpen, setInfoOpen] = useState(false); + const [infoData, setInfoData] = useState(null); + const columns = useMemo( + () => GRID_COLUMNS.map((col) => + col.key === "project_no" + ? { ...col, onClick: (row: any) => { setInfoData(toProjectInfo(row as SaleListRow)); setInfoOpen(true); } } + : col, + ), + [], + ); + const [registerOpen, setRegisterOpen] = useState(false); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ @@ -248,7 +279,7 @@ export default function SalesSalePage() { setSelected(id ? rows.find((r) => r.id === id) ?? null : null)} @@ -257,6 +288,8 @@ export default function SalesSalePage() { loading={loading} /> + + {/* 출하지시/판매등록 Dialog */} diff --git a/frontend/components/project/ProjectInfoDialog.tsx b/frontend/components/project/ProjectInfoDialog.tsx index 874af21f..f73f0a81 100644 --- a/frontend/components/project/ProjectInfoDialog.tsx +++ b/frontend/components/project/ProjectInfoDialog.tsx @@ -1,54 +1,99 @@ "use client"; -// 진행관리 PROJECT_NO 셀 클릭 시 표시되는 프로젝트 정보 다이얼로그 (read-only). -// wace `fn_openSaleRegPopup(PROJECT_NO, "detail")` 대응 — 새 창 대신 같은 탭 내 다이얼로그. -// list SQL 응답(ProgressRow)에 필요 데이터가 모두 포함돼 있어 별도 detail API 호출 불필요. +// 프로젝트 상세 정보 다이얼로그 — wace 운영판 (salesRegForm.do?saleNo=detail) 1:1 +// 사용처: 진행관리 / 영업관리 판매관리 / 매출관리 그리드의 프로젝트번호 셀 클릭 시. +// 페이지마다 row 키가 다르므로 ProjectInfoData 정규화 객체를 호출 측에서 매핑해 전달. import React from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ProgressRow } from "@/lib/api/projectMgmt"; + +export interface ProjectInfoData { + // 프로젝트정보 박스 + orderType: string | null; // 주문유형 + productType: string | null; // 제품구분 + area: string | null; // 국내/해외 + customer: string | null; // 고객사 + paidType: string | null; // 유/무상 + regDate: string | null; // 접수일 + currency: string | null; // 견적환종 + exchangeRate: string | null; // 견적환율 + // 품목정보 테이블 (라인 1건) + partNo: string | null; // 품번 + partName: string | null; // 품명 + serialNo: string | null; // S/N + reqDelDate: string | null; // 요청납기 + customerRequest: string | null; // 고객요청사항 + returnReason: string | null; // 반납사유 +} interface Props { open: boolean; onOpenChange: (open: boolean) => void; - row: ProgressRow | null; + data: ProjectInfoData | null; } -const fmtQty = (v: unknown) => { - if (v == null || v === "") return ""; - const n = Number(String(v).replace(/,/g, "")); - return isNaN(n) ? String(v) : n.toLocaleString(); -}; +const dash = (v: string | null | undefined) => + v == null || v === "" ? "-" : v; -export function ProjectInfoDialog({ open, onOpenChange, row }: Props) { +export function ProjectInfoDialog({ open, onOpenChange, data }: Props) { return ( - + - 프로젝트 정보 + 프로젝트 상세 정보 - {row ? ( -
- {row.project_no} - {row.contract_no} - {row.category_name} - {row.product_name} - {row.area_name} - {row.customer_name} - {row.free_of_charge} - {row.product_item_code} - {row.product_item_name} - {row.serial_no} - {fmtQty(row.contract_qty)} - {row.reg_date} - {row.req_del_date} - {row.order_date} - {row.project_name ?? ""} - {row.writer_name} + {data ? ( +
+ {/* 프로젝트정보 */} +
+
프로젝트정보
+
+ 주문유형{dash(data.orderType)} + 제품구분{dash(data.productType)} + 국내/해외{dash(data.area)} + 고객사{dash(data.customer)} + + 유/무상{dash(data.paidType)} + 접수일{dash(data.regDate)} + 견적환종{dash(data.currency)} + 견적환율{dash(data.exchangeRate)} +
+
+ + {/* 품목정보 */} +
+
품목정보
+
+ + + + + + + + + + + + + + + + + + + + + + + +
No품번품명S/N요청납기고객요청사항반납사유
1{dash(data.partNo)}{dash(data.partName)}{dash(data.serialNo)}{dash(data.reqDelDate)}{dash(data.customerRequest)}{dash(data.returnReason)}
+
+
) : null} @@ -60,14 +105,15 @@ export function ProjectInfoDialog({ open, onOpenChange, row }: Props) { ); } -function Label({ children }: { children: React.ReactNode }) { - return
{children}
; +function K({ children }: { children: React.ReactNode }) { + return
{children}
; } -function Value({ children, className }: { children: React.ReactNode; className?: string }) { - const empty = children == null || children === ""; - return ( -
- {empty ? - : children} -
- ); +function V({ children }: { children: React.ReactNode }) { + return
{children}
; +} +function Th({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; +} +function Td({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; } diff --git a/frontend/lib/api/projectMgmt.ts b/frontend/lib/api/projectMgmt.ts index f102aff5..fa300ab2 100644 --- a/frontend/lib/api/projectMgmt.ts +++ b/frontend/lib/api/projectMgmt.ts @@ -62,6 +62,11 @@ export interface ProgressRow { writer_name: string | null; cu01_cnt: number | null; cu02_cnt: number | null; + // 다이얼로그 표시용 (wace 운영판 1:1) + contract_currency_name: string | null; + exchange_rate: string | null; + customer_request: string | null; + return_reason_name: string | null; } export interface ProgressDetail { diff --git a/frontend/lib/api/salesSale.ts b/frontend/lib/api/salesSale.ts index cd6a30e7..8a969476 100644 --- a/frontend/lib/api/salesSale.ts +++ b/frontend/lib/api/salesSale.ts @@ -62,6 +62,11 @@ export interface SaleListRow { manager_name: string | null; cu01_cnt: number | null; serial_no: string | null; + // 다이얼로그 표시용 (wace 운영판 1:1) + order_type_name: string | null; + contract_currency_name: string | null; + exchange_rate: string | null; + return_reason_name: string | null; } export interface RevenueListRow { @@ -106,6 +111,15 @@ export interface RevenueListRow { manager_name: string | null; incoterms: string | null; cu01_cnt: number | null; + // 다이얼로그 표시용 (wace 운영판 1:1) + order_type_name: string | null; + product_type_name: string | null; + nation_name: string | null; + product_no: string | null; + product_name: string | null; + contract_currency_name: string | null; + exchange_rate: string | null; + return_reason_name: string | null; } export interface SaleRegisterBody {