From 12ea68616d9f9ad729b9841de5b3cca3ab5ae8ef Mon Sep 17 00:00:00 2001 From: hjjeong Date: Mon, 11 May 2026 09:29:43 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EA=B2=AC=EC=A0=81=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20V1=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95(=EC=A0=9C=ED=92=88=EA=B5=AC=EB=B6=84=C2=B7?= =?UTF-8?q?=EA=B5=AD=EB=82=B4=ED=95=B4=EC=99=B8=C2=B7=EB=B0=98=EB=82=A9?= =?UTF-8?q?=EC=82=AC=EC=9C=A0)=20+=20wace=201:1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=C2=B7=EC=9E=90=EB=8F=99=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getList SQL: 라인 집계에 product_summary(=PRODUCT_NAME, contract_item.product distinct join) / return_reason_summary 추가. wace는 헤더 product 폐지·라인으로 이동(운영 90건 contract_mgmt.product NULL) → 라인 집계로 그리드 표시 - GRID_COLUMNS 3개 추가: 제품구분 / 국내해외 / 반납사유 - searchForm.search_partName 필드 추가(초기화 포함). 검색 폼 UI는 PartSelect mode=partName 이미 존재 - docs/migration/sales/01-estimate-verify.md: wace ↔ RPS 항목 매핑 / 운영 데이터 코드 체계 / 갭 우선순위 - scripts/verify-estimate.sql: BEGIN/ROLLBACK 5개 시나리오 (등록·수정·G1·수주취소·그리드) 자동 검증 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/salesEstimateService.ts | 11 +- docs/migration/sales/01-estimate-verify.md | 197 ++++++++++++++ .../(main)/COMPANY_16/sales/estimate/page.tsx | 8 +- frontend/lib/api/salesEstimate.ts | 1 + scripts/verify-estimate.sql | 244 ++++++++++++++++++ 5 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 docs/migration/sales/01-estimate-verify.md create mode 100644 scripts/verify-estimate.sql diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index 9fe418cd..988a1aea 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -179,7 +179,7 @@ export async function getList(filter: EstimateListFilter) { ,C.customer_name AS CUSTOMER_NAME ,C.customer_type AS CUSTOMER_TYPE ,T.PRODUCT - ,CC_PRD.code_name AS PRODUCT_NAME + ,COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS PRODUCT_NAME ,T.AREA_CD ,CC_AREA.code_name AS AREA_NAME ,T.PAID_TYPE @@ -240,6 +240,7 @@ export async function getList(filter: EstimateListFilter) { END AS SERIAL_NO ,CI_AGG.earliest_due_date AS EARLIEST_DUE_DATE ,COALESCE(CI_AGG.other_due_date_count, 0) AS OTHER_DUE_DATE_COUNT + ,CI_AGG.return_reason_summary AS RETURN_REASON_SUMMARY /* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */ ,ML.mail_send_status AS MAIL_SEND_STATUS ,ML.mail_send_date AS MAIL_SEND_DATE @@ -291,7 +292,7 @@ export async function getList(filter: EstimateListFilter) { FROM estimate_template GROUP BY contract_objid ) ET_CNT ON ET_CNT.contract_objid = T.OBJID - /* 품목 집계 */ + /* 품목 집계 (제품구분/반납사유 한글명 distinct join — wace 헤더 product 폐지·라인으로 이동) */ LEFT JOIN ( SELECT CI.contract_objid, @@ -299,9 +300,13 @@ export async function getList(filter: EstimateListFilter) { (array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, (array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date, - GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count + GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count, + STRING_AGG(DISTINCT CC_RR.code_name, ', ') FILTER (WHERE CC_RR.code_name IS NOT NULL) AS return_reason_summary, + STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary FROM contract_item CI LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN comm_code CC_RR ON CC_RR.code_id = CI.return_reason AND CC_RR.status='active' + LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active' WHERE CI.status = 'ACTIVE' GROUP BY CI.contract_objid ) CI_AGG ON CI_AGG.contract_objid = T.OBJID diff --git a/docs/migration/sales/01-estimate-verify.md b/docs/migration/sales/01-estimate-verify.md new file mode 100644 index 00000000..ecd2e9ec --- /dev/null +++ b/docs/migration/sales/01-estimate-verify.md @@ -0,0 +1,197 @@ +# 01. 견적관리 wace 1:1 검증 + +> 작성: 2026-05-09 / 사이클: 구조적 검증 1차 (견적관리 메뉴) +> 목적: wace 운영 화면과 RPS 견적관리를 항목/식별자/채번 단위로 1:1 매칭 + 갭 도출 + 자동 검증 시나리오 + +## 1. 항목 매핑 + +### 1.1 등록/수정 폼 — 헤더 8개 (`estimateRegistFormPopup.jsp` 1행/2행) + +| # | wace 라벨 | wace name | RPS 폼 | 코드 그룹 | 필수 | 운영 데이터 | +|---|---|---|---|---|---|---| +| 1 | 주문유형 | `category_cd` | `EstimateBody.category_cd` | `0000167` | ✅ | 0001792(판매)·0001791(수리)·0900221(자체개발) | +| 2 | 국내/해외 | `area_cd` | `EstimateBody.area_cd` | `0001219` | ✅ | 0001220(국내)·0001221(해외) | +| 3 | 고객사 | `customer_objid` | `EstimateBody.customer_objid` | — | ✅ | `C_{customer_code}` 90건 일치 | +| 4 | 유/무상 | `paid_type` | `EstimateBody.paid_type` | (raw `paid`/`free`) | ✅ | paid 85·free 4·NULL 1 | +| 5 | 접수일 | `receipt_date` | `EstimateBody.receipt_date` | (date varchar) | ✅ | YYYY-MM-DD | +| 6 | 견적환종 | `contract_currency` | `EstimateBody.contract_currency` | `0001533` | — | 0001566(원)·0001534(달러)·0001537(엔) | +| 7 | 견적환율 | `exchange_rate` | `EstimateBody.exchange_rate` | (raw text) | — | | +| 8 | 결재여부 | `approval_required` | `EstimateBody.approval_required` | (Y/N 체크박스) | ✅ | 90건 N (결재모듈 미도입) | + +### 1.2 등록/수정 폼 — 라인 8개 + 삭제 + +| # | wace 라벨 | wace name | RPS 라인 | 컬럼/그룹 | 필수 | +|---|---|---|---|---|---| +| 1 | No (자동) | (auto seq) | `seq` | integer | — | +| 2 | 제품구분 | `item_product[]` | `EstimateItem.product` | comm_code `0000001` | ✅ | +| 3 | 품번 | `item_part_no_select[]` | `EstimateItem.part_objid`+`part_no` | PartSelect | ✅ | +| 4 | 품명 | `item_part_name_select[]` | `EstimateItem.part_name` | PartSelect | ✅ | +| 5 | S/N | `item_serial_no[]` | `EstimateItem.serials[]` | S/N 다이얼로그 | — | +| 6 | 견적수량 | `item_quantity[]` | `EstimateItem.quantity` | integer | — | +| 7 | 요청납기 | `item_due_date[]` | `EstimateItem.due_date` | date varchar | — | +| 8 | 반납사유 | `item_return_reason[]` | `EstimateItem.return_reason` | comm_code `0001810` | — | +| 9 | 고객요청사항 | `item_customer_request[]` | `EstimateItem.customer_request` | text | — | + +### 1.3 그리드 컬럼 — wace 활성 22개 + +| # | wace title | wace field | RPS GRID_COLUMNS | 상태 | +|---|---|---|---|---| +| 1 | 영업번호 | `CONTRACT_NO` | `contract_no` (frozen) | ✅ | +| 2 | 주문유형 | `CATEGORY_NAME` | `category_name` | ✅ | +| 3 | 접수일 | `RECEIPT_DATE` | `receipt_date` | ✅ | +| 4 | 요청납기 | `EARLIEST_DUE_DATE` | `earliest_due_date_label` | ✅ | +| 5 | 고객사 | `CUSTOMER_NAME` | `customer_name` | ✅ | +| 6 | 품명 | `ITEM_SUMMARY` | `item_summary` | ✅ | +| 7 | 견적수량 | `ESTIMATE_QUANTITY` | `estimate_quantity` | ✅ | +| 8 | 유/무상 | `PAID_TYPE` | `paid_type_name` | ✅ | +| 9 | 공급가액 | `EST_TOTAL_AMOUNT` | `est_total_amount` | ✅ | +| 10 | 원화환산공급가액 | `EST_TOTAL_AMOUNT_KRW` | `est_total_amount_krw` | ✅ | +| 11 | 견적현황 | `EST_STATUS` | `est_status` (folder) | ✅ | +| 12 | 추가견적 | `ADD_EST_CNT` | `add_est_cnt` (clip) | ✅ | +| 13 | 결재상태 | `APPR_STATUS` | `appr_status` | ✅ | +| 14 | 메일발송 | `MAIL_SEND_STATUS` | `mail_send_status_label` | ✅ | +| 15 | 환종 | `CONTRACT_CURRENCY_NAME` | `contract_currency_name` | ✅ | +| 16 | 환율 | `EXCHANGE_RATE` | `exchange_rate` | ✅ | +| 17 | S/N | `SERIAL_NO` | `serial_no` | ✅ | +| 18 | 품번 | `PART_NO` | `part_no` | ✅ | +| 19 | 작성자 | `WRITER_NAME` | `writer_name` | ✅ | +| 20 | **제품구분** | `PRODUCT_NAME` | (없음) | 🔴 갭 | +| 21 | **국내/해외** | `AREA_NAME` | (없음) | 🔴 갭 | +| 22 | **반납사유** | `RETURN_REASON_SUMMARY` | (없음) | 🔴 갭 (집계 컬럼 신설 필요) | + +### 1.4 검색 폼 — wace 활성 7개 + +| # | wace 라벨 | wace name | RPS searchForm | 상태 | +|---|---|---|---|---| +| 1 | 주문유형 | `category_cd` | `category_cd` | ✅ | +| 2 | 고객사 | `customer_objid` | `customer_objid` | ✅ | +| 3 | 품번 | `search_partNo` | `search_partObjId` | 🟡 (PartSelect로 part_objid 단일 검색) | +| 4 | **품명** | `search_partName` | (없음) | 🔴 갭 | +| 5 | S/N | `search_serialNo` | `search_serialNo` | ✅ | +| 6 | 결재상태 | `appr_status` | `appr_status` | ✅ | +| 7 | 접수일 | `receipt_start_date~end_date` | `receipt_start_date/end_date` | ✅ | + +### 1.5 액션 버튼 + +| 버튼 | wace endpoint | RPS 동작 | 상태 | +|---|---|---|---| +| 조회 | `/contractMgmt/estimateGridList.do` | `GET /sales/estimate/list` | ✅ | +| 삭제 | `/contractMgmt/deleteEstimateMgmtInfo.do` | `DELETE /sales/estimate/:id` | ✅ | +| 견적요청등록/수정 | `/contractMgmt/saveContractMgmtInfo.do` | `POST/PUT /sales/estimate` | ✅ (선택 시 수정 분기) | +| 견적작성 | `/contractMgmt/saveEstimate.do | saveEstimate2.do` (template1/2) | placeholder | 🟠 G5 별도 PR | +| 결재상신 | `/contractMgmt/checkApprovalRequired.do` → 아마란스 SSO | placeholder | 🟠 G4 별도 PR | +| 메일발송 | `/contractMgmt/sendEstimateMail.do` | `POST /sales/estimate/mail` (mail_log INSERT만) | 🟡 SMTP 미구현(G6) | + +--- + +## 2. 운영 데이터 코드 체계 (90건 검증 완료) + +### 2.1 식별자 + +| 항목 | 형식 | 검증 | +|---|---|---| +| `contract_mgmt.objid` | varchar (raw integer hash 또는 'CM-...') | wace 운영은 raw integer (문자열로 보관) | +| `contract_mgmt.contract_no` | `{YY}C-{NNNN}` | **90/90건 일치** (regex `^[0-9]{2}C-[0-9]{4}$`) | +| `contract_mgmt.customer_objid` | `C_{customer_code}` (10자리 padded) | **90/90건 customer_mng.customer_code로 매칭** | +| `contract_item.objid` | varchar (raw integer 또는 'CI-...') | | + +### 2.2 comm_code 그룹 ID + +| 용도 | parent_code_id | 자식 예시 | +|---|---|---| +| 주문유형 (category_cd) | `0000167` | 0001791(수리)/0001792(판매)/0900221(자체개발)/0000170(오버홀)/0000171(개조)/0000168(신규개발)/0001790(견적)/0900214(계획생산) | +| 국내/해외 (area_cd) | `0001219` | 0001220(국내)/0001221(해외) | +| 제품구분 (product) | `0000001` | 0000928(Machine)/0000930(A/S)/0001525(D/S)/0001539(B/S)/0001793(C/T)/0001794(A/C)/0001807(W/M)/0001809(기타) | +| 환종 (contract_currency) | `0001533` | 0001534(달러$)/0001535(유로€)/0001536(위안¥)/0001537(엔¥)/0001566(원₩) | +| 수주상태 (contract_result) | `0000963` | 0000964(수주)/0000965(Cancel)/0000966(Hold)/0000968(수주FCST) | +| 반납사유 (return_reason) | `0001810` | 0001811(수리불가) | + +### 2.3 채번 룰 + +| 항목 | 룰 | 적용 위치 | +|---|---|---| +| `contract_no` | `{YY}C-{NNNN}` (4자리 zero-pad, 같은 prefix MAX+1) | `salesOrderMgmtService.generateContractNo` | +| `project_no` | `{주문유형1}-{제품구분2}-{YYMMDD}-{NNN}` (예: R-CT-260507-001) | `salesOrderMgmtService.generateProjectNo` | + +--- + +## 3. 발견된 갭 (우선순위) + +| # | 우선 | 항목 | 권장 작업 | +|---|---|---|---| +| V1 | 🔴 | 그리드 컬럼 3개 누락 (제품구분/국내해외/반납사유) | RPS GRID_COLUMNS 추가 + getList SQL에 PRODUCT_NAME/AREA_NAME/RETURN_REASON_SUMMARY 컬럼 추가. RETURN_REASON_SUMMARY는 contract_item 집계로 LATERAL JOIN | +| V2 | 🟠 | 품명 검색 누락 (`search_partName`) | searchForm에 `search_partName` 추가 + 백엔드 SQL where 조건 추가 | +| V3 | 🟡 | paid_type NULL 1건 (운영 데이터 이슈) | 폼에서 신규 시 `paid` default 강제 — 이미 적용. 기존 NULL 데이터 정리는 별도 | +| V4 | 🟢 | 결재모듈 (G4) — 모든 운영 데이터 approval_required='N' | G4 별도 PR | +| V5 | 🟢 | 견적작성 PDF (G5) | G5 별도 PR | +| V6 | 🟢 | SMTP 실발송 (G6) | G6 별도 PR | + +--- + +## 4. 자동 검증 시나리오 (BEGIN/ROLLBACK) + +각 시나리오는 dev DB에서 트랜잭션 안에서 실제 SQL 실행 + 결과 검증 후 ROLLBACK. 영향 0. + +### 시나리오 1: 신규 견적요청 등록 + +``` +BEFORE: contract_mgmt 90 / contract_item N0 / contract_item_serial S0 +- INSERT contract_mgmt 1건 (contract_no=26C-0802, customer_objid='C_0000005546', ...) +- INSERT contract_item 1건 (product=0001793, part_objid=1868255719, quantity=2) +- INSERT contract_item_serial 0건 (S/N 미입력) +AFTER: contract_mgmt 91 / contract_item N0+1 / serial S0 +ROLLBACK → 모두 원복 +``` + +### 시나리오 2: 견적요청 수정 (라인 1→2 확장) + +``` +BEFORE: 26C-0801 contract_item 1건 +- upsertItems: 기존 라인 status='INACTIVE' +- INSERT 새 라인 2건 (objid 새로 발급, ON CONFLICT 미발동) +AFTER: contract_item ACTIVE 2건, INACTIVE 1건 (= 누적 3건) +ROLLBACK +``` + +### 시나리오 3: 견적요청 삭제 + +``` +BEFORE: 26C-XXXX contract_item N건, contract_item_serial M건 +- UPDATE contract_item_serial SET status='INACTIVE' WHERE item_objid IN (...) +- DELETE contract_item WHERE contract_objid=$ +- DELETE contract_mgmt WHERE objid=$ +AFTER: 모두 사라짐 +ROLLBACK +``` + +### 시나리오 4: 수주확정 → 프로젝트 자동생성 (G1) + +``` +BEFORE: project_mgmt 89 / contract_mgmt.contract_result NULL +- UPDATE contract_mgmt SET contract_result='0000964' +- 트리거: createProjectsFromContract + - hasProject=false + - contract_item N개 루프 → Machine 분기 → project_no 채번 → INSERT +AFTER: project_mgmt 89+N 또는 89+sum(Machine_qty)+non_machine_count +ROLLBACK +``` + +(검증 완료: 26C-0801 1라인 C/T qty=2 → project_no=R-CT-260508-001 1건 INSERT) + +### 시나리오 5: 수주취소 (cancel_qty 입력) + +``` +BEFORE: contract_item.cancel_qty NULL +- UPDATE contract_item SET cancel_qty='1', chgdate=NOW(), chg_user_id=$ +- contract_mgmt.contract_result 미변경 +검증: cancel_qty < order_qty (전체 취소 불가) +ROLLBACK +``` + +--- + +## 5. 다음 단계 + +1. **갭 V1·V2 수정** (그리드 3컬럼 + 품명 검색) → 사용자 확인 후 커밋 +2. **자동 검증 SQL 스크립트** 정리 (`scripts/verify-estimate.sql` — BEGIN/ROLLBACK 트랜잭션 모음) +3. **사용자 dev 환경 최종 확인** → 견적관리 메뉴 종결 → 주문관리 진행 diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 30a502ed..6a1dcb02 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -46,6 +46,9 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "part_no", label: "품번", width: "w-[120px]" }, { key: "writer_name", label: "작성자", width: "w-[100px]" }, + { key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" }, + { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, + { key: "return_reason_summary", label: "반납사유", width: "w-[120px]" }, ]; // ─── 코드 라벨 ──────────────────────────────────────────────── @@ -90,7 +93,8 @@ export default function SalesEstimatePage() { const [searchForm, setSearchForm] = useState({ category_cd: "", customer_objid: "", - search_partObjId: "", + search_partObjId: "", // 품번 (PartSelect → part_objid) + search_partName: "", // 품명 (PartSelect → part_objid 별도 사용 시 동기화) search_serialNo: "", appr_status: "", receipt_start_date: "", @@ -468,7 +472,7 @@ export default function SalesEstimatePage() {