diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index 2b888dacd..9c1bbcd18 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -102,3 +102,28 @@ def test_insert_and_move_quantity_rows_keeps_item_results_linked(): assert result["linked"]["errors"] == [] assert result["linked"]["items"][1]["GN"] == 6 assert result["forward"]["errors"] + + +def test_manual_and_selected_sheet_totals_validate_before_application(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{Code:'X',Level:0,GN:1,MulCode:{binary:'YWJj'}}]; + p.quantities=[{Code:'A',formula:'2',TargetCode:'X'}, + {Code:'B',formula:'[A]*3',TargetCode:'X'}]; + const manual=EstQuantity.totals(p,'manual');EstQuantity.applyTotals(p,manual); + const a=structuredClone(p.tables.EstMainDat[0]); + p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'4+5 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',GN:0,TargetCode:'X'}]}]; + const sheet=EstQuantity.totals(p,'Q');EstQuantity.applyTotals(p,sheet); + let invalid='';p.quantities.push({Code:'C',formula:'1',TargetCode:'missing'}); + try{EstQuantity.totals(p,'manual')}catch(e){invalid=e.message} + return {a,b:p.tables.EstMainDat[0],invalid}; + })()""", + ) + assert result["a"]["GN"] == 8 + assert result["a"]["MulCode"] == {"binary": "YWJj"} + assert result["b"]["GN"] == 9 + assert result["b"]["MulCode"] == {"binary": "YWJj"} + assert result["invalid"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 506cb91ac..61807ca56 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -29,6 +29,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. +재계산 메뉴의 수량 반영도 현재 선택한 산출서 또는 직접 작성 수식표를 사용합니다. 반영 전에 모든 연결 대상과 수식을 검사하며, 기존 원본 연결 데이터는 그대로 보관합니다. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index ac8f5b35e..121e90aa4 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -410,28 +410,9 @@ const EstActions = (() => { false, ); else if (name === "apply-quantity") { - const values = Object.create(null), - totals = new Map(); - for (const row of project.quantities) { - const value = EstMath.floating( - EstExpression.evaluate(row.formula, (key) => values[key]), - row.digits ?? 2, - 1, - ); - values[row.Code] = value; - if (row.TargetCode) totals.set(row.TargetCode, (totals.get(row.TargetCode) || 0) + value); - } - if (!totals.size) throw Error("연결 내역코드를 입력하세요."); + const totals = EstQuantity.totals(project, state.quantitySheet); if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?")) - EstApp.change(() => { - for (const row of project.tables.EstMainDat) - if (totals.has(row.Code)) { - row.GN = totals.get(row.Code); - row.MullangIndex = 1; - row.ExcelSheetIndex = 0; - row.MulCode = row.Code; - } - }); + EstApp.change(() => EstQuantity.applyTotals(project, totals)); } else if (name === "add-round") { const next = { id: uid(), diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index 2c7fb0a56..acafbbf46 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -101,6 +101,7 @@ const EstApp = (() => { state.selection.clear(); state.group = ""; state.page = 0; + state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual"; state.roundId = state.project.progress.find((round) => round.id === state.roundId)?.id || state.project.progress[0]?.id; diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 43d428cea..4333c7506 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -113,6 +113,44 @@ const EstQuantity = (() => { errors: result.errors, }; } + function totals(project, selected) { + let items; + if (selected && selected !== "manual") { + const sheet = project.quantitySheets?.find((s) => s.Code === selected); + if (!sheet) throw Error("선택한 수량산출서가 없습니다."); + const output = calculate(sheet); + if (output.errors.length) throw Error(output.errors.join(" / ")); + items = output.items; + } else { + const values = Object.create(null); + items = (project.quantities || []).map((row) => { + if (!row.Code || Object.hasOwn(values, row.Code)) + throw Error(`산출코드가 비어 있거나 중복됩니다: ${row.Code || ""}`); + const GN = EstMath.floating( + EstExpression.evaluate(row.formula || "0", (key) => values[key]), + row.digits ?? 2, + 1, + ); + values[row.Code] = GN; + return { ...row, GN }; + }); + } + const quantities = new Map(); + for (const item of items) { + const code = item.TargetCode ?? item.LinkCode; + if (!code) continue; + const targets = project.tables.EstMainDat.filter((r) => r.Code === code && !Number(r.Level)); + if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`); + if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`); + quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0)); + } + if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요."); + return quantities; + } + function applyTotals(project, quantities) { + for (const row of project.tables.EstMainDat) + if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code); + } function selector(state) { const { esc } = EstApp; return `
`; @@ -224,30 +262,14 @@ const EstQuantity = (() => { } if (!event.target.closest("[data-quantity-apply]")) return; try { - const output = calculate(sheet); - if (output.errors.length) throw Error(output.errors.join(" / ")); - const quantities = new Map(); - for (const item of output.items) - if (item.TargetCode ?? item.LinkCode) { - const code = item.TargetCode ?? item.LinkCode; - const targets = state.project.tables.EstMainDat.filter( - (r) => r.Code === code && !Number(r.Level), - ); - if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`); - if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`); - quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0)); - } - if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요."); - EstApp.change(() => { - for (const row of state.project.tables.EstMainDat) - if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code); - }); + const quantities = totals(state.project, state.quantitySheet); + EstApp.change(() => applyTotals(state.project, quantities)); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } }); } - return { entities, fromWpt, calculate, selector, render, install }; + return { entities, fromWpt, calculate, totals, applyTotals, selector, render, install }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;