From cfe78e2e3ba72b12a2927458e3cbebdfce1b498b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 10:54:02 +0900 Subject: [PATCH] fix(est): guard invalid contract review totals --- resources/tester/test_est_review_workflow.py | 17 +++++++++++++ tools/est-test/README.md | 1 + tools/est-test/native-review.js | 25 ++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py index 28a021bc9..47329f9e2 100644 --- a/resources/tester/test_est_review_workflow.py +++ b/resources/tester/test_est_review_workflow.py @@ -1,6 +1,23 @@ from test_est_rea_parity import run +def test_review_rejects_unallocated_fixed_amounts_and_ambiguous_targets(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {Name:'fixed',PkID:1,GN:1,TC:100,CalcIndex:1}, + {Name:'first',Code:'A',GN:1,GU:10},{Name:'second',Code:'A',GN:1,GU:20}]; + p.contractReview={edits:{'row:A':{GU:999}}}; + const out=EstReview.calculate(p);let rejected=false; + try{EstReview.copy(p)}catch{rejected=true} + return {errors:out.errors,rejected,units:out.reviewed.tables.EstMainDat.slice(1).map(row=>row.GU)}; + })()""", + ) + assert len(result["errors"]) == 2 and result["rejected"] + assert result["units"] == [10, 20] + + def test_review_recalculates_user_inputs_and_opens_independent_copy(): result = run( "math", diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 2002ad64d..02f0b2937 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -23,6 +23,7 @@ - **기성 진도 반영**: 예정공정표에서 최근 확정 회차의 누계 수량을 해당 회차 계약 수량으로 나누어 연결된 공정의 진도를 갱신할 수 있습니다. 수량이 0이거나 기성 내역에 연결되지 않은 공정은 기존 진도를 유지합니다. 0~100%를 벗어나는 결과는 갱신을 중단합니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. - **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가·심사 사유를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 항목별 입력을 초기화하거나 실행 취소할 수 있습니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교·사유와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. +- 고정금액과 비목 합계가 다른 내역은 심사 합계를 정확히 산정할 수 없어 오류를 표시합니다. 내역서에서 비목 금액을 확인해 입력한 뒤 심사합니다. Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js index e3fd711cc..59bda4231 100644 --- a/tools/est-test/native-review.js +++ b/tools/est-test/native-review.js @@ -48,13 +48,26 @@ const EstReview = (() => { working.tables.EstMainDat = structuredClone(project.tables.EstMainDat); working.tables.Wonga = structuredClone(project.tables.Wonga); const baseline = EstNative.calculate(working); + const counts = new Map(); working.tables.EstMainDat.forEach((row, index) => { if (!editable(row)) return; const key = rowKey(row, index); - if (seen.has(key)) errors.push(`심사 내역 식별자 중복: ${key}`); + counts.set(key, (counts.get(key) || 0) + 1); + }); + working.tables.EstMainDat.forEach((row, index) => { + if (!editable(row)) return; + const key = rowKey(row, index); + if (counts.get(key) > 1) errors.push(`심사 내역 식별자 중복: ${key}`); seen.add(key); + if ( + fixed(row) && + EstMath.even(EstMath.num(row.TC) * 10000) !== EstMath.even(EstMath.sum(row) * 10000) + ) + errors.push( + `${row.Name || key}: 고정 금액과 비목 합계가 달라 심사 합계를 계산할 수 없습니다.`, + ); const edit = edits[key]; - if (!edit) return; + if (!edit || counts.get(key) > 1) return; for (const field of Object.keys(edit)) { if (field === "Reason") continue; if (!allowed(row).includes(field)) @@ -127,6 +140,14 @@ const EstReview = (() => { const field = node.dataset.review, value = field === "Reason" ? node.value : Number(node.value), key = node.dataset.key; + const matches = EstApp.state.project.tables.EstMainDat.filter( + (row, index) => editable(row) && rowKey(row, index) === key, + ); + if (matches.length !== 1) { + EstApp.state.notice = "심사 내역 식별자가 없거나 중복됩니다."; + EstApp.render(); + return; + } if ( field === "Reason" ? value.length > 10000