From 5e72aff136e65355bda9b0817f5c4f366b6d8aa2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 16:39:55 +0900 Subject: [PATCH] fix(est): apply head cost classification safely --- resources/tester/test_est_business.py | 27 +++++++++++++++++++++++ tools/est-test/PLAN.md | 2 ++ tools/est-test/README.md | 2 +- tools/est-test/native-actions.js | 4 ++-- tools/est-test/native-business-actions.js | 26 ++++++++++++++-------- tools/est-test/native-engine.js | 9 +++++++- tools/est-test/native-rows.js | 16 +++++++++----- 7 files changed, 68 insertions(+), 18 deletions(-) diff --git a/resources/tester/test_est_business.py b/resources/tester/test_est_business.py index 165bc04bf..17a898d8e 100644 --- a/resources/tester/test_est_business.py +++ b/resources/tester/test_est_business.py @@ -42,6 +42,33 @@ def test_estimate_classification_refreshes_only_selected_source_and_preserves_ma assert result["other"] and not result["flag"] +def test_head_classification_preserves_other_groups_and_rejects_unsafe_restoration(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.AutoCalc=false; + p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0},{Code:'N',Name:'other',Cost1:800,ValueIndex:0}]; + p.tables.HeadMainDat=[{Index:1,Code:'H1',Name:'first'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200,JC:0}, + {Index:2,Code:'H2',Name:'second'},{Index:0,Code:'N',LinkIndex:5,GN:1,GU:5,GC:5}]; + const row=p.tables.HeadMainDat[1],other=JSON.stringify(p.tables.HeadMainDat[3]); + EstBusinessActions.setEstimateClassification(p,row,1,3,'HeadMainDat'); + const otherPreserved=JSON.stringify(p.tables.HeadMainDat[3])===other; + const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC}; + const auto=EstNative.calculate(p).tables.HeadMainDat[1]; + EstBusinessActions.setEstimateClassification(p,row,1,0,'HeadMainDat'); + const restored={GU:row.GU,JU:row.JU}; + const mismatch=p.tables.HeadMainDat[3];EstBusinessActions.setEstimateClassification(p,mismatch,3,3,'HeadMainDat'); + let rejected=false;try{EstBusinessActions.setEstimateClassification(p,mismatch,3,0,'HeadMainDat')}catch{rejected=true} + return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,mismatch,otherPreserved,flag:p.options.SourceRefresh}; + })()""", + ) + assert result["manual"] == {"JU": 100, "GU": 0, "GC": 200, "JC": 0} + assert result["auto"] == {"GC": 0, "JC": 200} + assert result["restored"] == {"GU": 100, "JU": 0} + assert result["rejected"] and result["mismatch"]["JU"] == 5 + assert result["otherPreserved"] and not result["flag"] + + def test_annual_formulas_split_component_amounts_and_preserve_remainder(): result = run( "math", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index e6b26985e..6d36c14bb 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,6 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험. + - 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인. - [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험. - 자체 검증: 현재 단가를 유지한 비목 지정·일치하는 원천으로 복원, 다른 행·재조회 옵션 보존. 원천 누락·중복·단가 불일치는 복원 차단. Edge에서 자동·수동 계산 확인, 관련 시험 32개 통과. - [x] 산출근거 문구 일괄 바꾸기 → 현재 호표·전체 범위, 표지 보존·재계산·실행 취소 확인. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 4ae751c6d..4eb9ed71b 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -18,7 +18,7 @@ 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. -원천 자료에 연결된 내역 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다. +원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다. 인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. ESTX 수정 저장은 서식별 여백과 페이지당 행 수를 반영합니다. 다른 웹 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 579bcaffc..022ab14d8 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -621,8 +621,8 @@ const EstActions = (() => { const previous = row.Code; EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value); if (state.group === previous && Number(row.Index) > 0) state.group = row.Code; - } else if (node.dataset.table === "EstMainDat" && key === "SCheck") - EstBusinessActions.setEstimateClassification(project, row, node.dataset.row, value); + } else if (key === "SCheck") + EstBusinessActions.setClassification(project, row, node, value); else row[key] = value; if (state.view === "escalation" && EstMath.units.includes(key)) project.options.SourceRefresh = false; diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 52358f6e3..16726ca41 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -1,13 +1,13 @@ const EstBusinessActions = (() => { - function setEstimateClassification(project, row, index, value) { + function setEstimateClassification(project, row, index, value, rowTable = "EstMainDat") { index = Number(index); - if (!Number.isInteger(index) || project.tables.EstMainDat[index] !== row) + if (!Number.isInteger(index) || project.tables[rowTable]?.[index] !== row) throw Error("내역 행을 다시 선택하세요."); const kind = Number(row.LinkIndex), table = EstNative.sourceTables[kind]; if ( !table || - Number(row.Level) || + (rowTable === "EstMainDat" ? Number(row.Level) : Number(row.Index) > 0) || [1, 3].includes(Number(row.CalcIndex)) || ![0, 1, 2, 3].includes(value) ) { @@ -46,13 +46,15 @@ const EstBusinessActions = (() => { row.SCheck = value; try { let failures = null; - const calculated = EstNative.calculate(project, { - refreshEstimateRows: [index], - onEstimateRefresh: (errors) => (failures = errors), - }); + const calculated = EstNative.calculate( + project, + rowTable === "HeadMainDat" + ? { refreshHeadRows: [index], onHeadRefresh: (errors) => (failures = errors) } + : { refreshEstimateRows: [index], onEstimateRefresh: (errors) => (failures = errors) }, + ); if (!failures) throw Error("내역 비목 재계산을 확인할 수 없습니다."); if (failures.length) throw Error(failures.join(" / ")); - const refreshed = calculated.tables.EstMainDat[index]; + const refreshed = calculated.tables[rowTable][index]; const refreshedTotal = EstMath.units.reduce( (sum, key) => sum + EstMath.num(refreshed[key]), 0, @@ -70,6 +72,12 @@ const EstBusinessActions = (() => { throw error; } } + function setClassification(project, row, node, value) { + const table = node.dataset.table; + if (["EstMainDat", "HeadMainDat"].includes(table)) + setEstimateClassification(project, row, node.dataset.row, value, table); + else row.SCheck = value; + } let purpose = "", documentKey; const currentRound = () => @@ -435,5 +443,5 @@ const EstBusinessActions = (() => { } }); } - return { handle, change, install, setEstimateClassification }; + return { handle, change, install, setEstimateClassification, setClassification }; })(); diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index 868cce395..3e3b3690f 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -178,14 +178,21 @@ const EstNative = (() => { const group = (kind === 103 ? heads : costs).get(code); if (!group) throw Error(`하위 자료 없음: ${id}`); active.add(id); + const refreshHeadRows = + kind === 103 + ? (request.refreshHeadRows || []) + .filter((index) => index >= group.start && index < group.start + group.rows.length) + .map((index) => index - group.start) + : []; const result = kind === 103 - ? EstRows.head(group.rows, options, resolve) + ? EstRows.head(group.rows, options, resolve, refreshHeadRows) : EstText.calculate( group.rows, options, options.SourceRefresh || options.ResourceTracing ? resolve : () => null, ); + if (refreshHeadRows.length) request.onHeadRefresh?.(result.refreshErrors); const title = { ...tables[sourceTables[kind]].find((row) => row.Code === code), ...result.titles[0], diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js index f02494f82..e2d627ead 100644 --- a/tools/est-test/native-rows.js +++ b/tools/est-test/native-rows.js @@ -82,10 +82,11 @@ const EstRows = (() => { ); return amounts(row, digits); } - function head(rows, options = {}, resolve = () => null) { + function head(rows, options = {}, resolve = () => null, refreshRows = []) { const output = structuredClone(rows), titles = [], - errors = []; + errors = [], + refreshErrors = []; let current = null, body = []; function finish() { @@ -93,7 +94,7 @@ const EstRows = (() => { Object.assign(current, add(body)); titles.push({ ...current }); } - for (const row of output) { + for (const [index, row] of output.entries()) { if (num(row.Index) > 0) { finish(); current = row; @@ -119,12 +120,15 @@ const EstRows = (() => { body = [row]; continue; } else { - if (options.SourceRefresh && ![1, 3].includes(num(row.CalcIndex))) { + const requested = refreshRows.includes(index); + if ((options.SourceRefresh || requested) && ![1, 3].includes(num(row.CalcIndex))) { try { const price = resolve(row); + if (requested && !price) throw Error("원천 단가를 찾을 수 없습니다."); if (price) units.forEach((key, i) => (row[key] = num(price[components[i]]))); } catch (error) { errors.push(`${current.Name}: ${error.message}`); + if (requested) refreshErrors.push(error.message); } } amounts(row, options.HeadDigits || 0, options.TotalFit); @@ -132,7 +136,9 @@ const EstRows = (() => { body.push(row); } finish(); - return { rows: output, titles, errors }; + return refreshRows.length + ? { rows: output, titles, errors, refreshErrors } + : { rows: output, titles, errors }; } function estimate(rows, options = {}, resolve = () => null, refreshRows = []) { const output = structuredClone(rows),