From 87433105f76701ec38691a56cbad15cbcc993de1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 15:01:33 +0900 Subject: [PATCH] =?UTF-8?q?wip(est-test):=20=EA=B0=9C=EB=B0=9C=20=EC=A4=91?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=202=20=E2=80=94=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EA=B9=83=20=EC=8B=B1=ED=81=AC(=EC=9A=94=EA=B5=AC=20689-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tester/test_est_progress_workflow.py | 17 + resources/tester/test_est_row_actions.py | 774 +++++++++++++++++- tools/est-test/PLAN.md | 105 ++- tools/est-test/native-block-actions.js | 355 +++++++- tools/est-test/native-extra.js | 4 +- tools/est-test/native-progress.js | 2 + tools/est-test/native-wonga.js | 85 +- 7 files changed, 1285 insertions(+), 57 deletions(-) diff --git a/resources/tester/test_est_progress_workflow.py b/resources/tester/test_est_progress_workflow.py index 1af21d9e7..4b717a9c5 100644 --- a/resources/tester/test_est_progress_workflow.py +++ b/resources/tester/test_est_progress_workflow.py @@ -10,6 +10,23 @@ def test_new_progress_name_avoids_existing_names_with_whitespace(): assert result == ["3회 기성", "1회 기성", "1회 기성"] +def test_progress_cannot_lock_when_previous_result_is_missing(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}]; + p.progress=[{id:'one',locked:true,inputs:{}},{id:'two',inputs:{'1':{Gum:2}}}]; + const calculated=EstProgress.calculate(p,'two'); + let rejected=false; + try { EstProgress.lock(p,'two'); } catch { rejected=true; } + return {errors:calculated.errors,rejected}; + })()""", + ) + assert any("전회 기성 결과가 없습니다" in error for error in result["errors"]) + assert result["rejected"] + + def test_progress_rejects_ambiguous_inputs_without_applying_them_to_multiple_rows(): result = run( "math", diff --git a/resources/tester/test_est_row_actions.py b/resources/tester/test_est_row_actions.py index 52363611f..9b3906756 100644 --- a/resources/tester/test_est_row_actions.py +++ b/resources/tester/test_est_row_actions.py @@ -31,6 +31,7 @@ def run_js(code: str) -> dict: const files = [ 'tools/est-test/native-math.js', + 'tools/est-test/native-formula.js', 'tools/est-test/native-model.js', 'tools/est-test/native-text.js', 'tools/est-test/native-machine.js', @@ -663,12 +664,18 @@ def test_align_wonga_total_action(): const p = EstNative.empty(); p.tables.Wonga = [ { Gubun: "A", Name: "직접공사비", Cost: 1000000, LinkIndex: 0, CalcIndex: 0 }, - { Gubun: "B", Name: "일반관리비", Cost: 50000, LinkIndex: 0, CalcIndex: 0 }, - { Gubun: "C", Name: "이윤", Cost: 78564, LinkIndex: 0, CalcIndex: 0 }, - { Gubun: "D", Name: "부가가치세", Cost: 112856, LinkIndex: 0, CalcIndex: 0 }, - { Gubun: "E", Name: "총도급공사비", Cost: 1241420, LinkIndex: 55, CalcIndex: 0 } + { Gubun: "B", Name: "간접노무비", Cost: 0, VSusic: "A * 0.1", LinkIndex: 0, CalcIndex: 0 }, + { Gubun: "C", Name: "이윤", Cost: 0, VSusic: "(A + B) * 0.1", LinkIndex: 0, CalcIndex: 0 }, + { Gubun: "D", Name: "부가가치세", Cost: 0, VSusic: "(A + B + C) * 0.1", LinkIndex: 0, CalcIndex: 0 }, + { Gubun: "E", Name: "총도급공사비", Cost: 0, VSusic: "A + B + C + D", LinkIndex: 55, CalcIndex: 0 } ]; + // 1) 당초 일반 계산: 직접공사비 1,000,000 기준 -> 총도급 1,331,000원, 이윤 110,000원, 부가세 121,000원 + const initialCalc = EstWonga.calculate(p.tables.Wonga, { TotalFix: false }, {}); + const initialTotal = initialCalc.rows[4].Cost; + const initialProfit = initialCalc.rows[2].Cost; + p.tables.Wonga = initialCalc.rows; + const state = { project: p, view: "Wonga", @@ -677,19 +684,34 @@ def test_align_wonga_total_action(): }; globalThis.EstApp = { state, - change(fn) { fn(); }, + change(fn) { + fn(); + // EstApp.change recalculates via EstNative.calculate / EstWonga.calculate + const recalc = EstWonga.calculate(p.tables.Wonga, p.options, {}); + p.tables.Wonga = recalc.rows; + }, render() {} }; - // 1) 1,000원 단위(unitDigits: 3) 절하 맞춤 테스트 - const alignRes1000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 3); - const total1000 = p.tables.Wonga[alignRes1000.targetIndex].Cost; + // 2) 10,000원 단위(unitDigits: 4) 역산 맞춤: 1,331,000 -> 1,330,000원으로 절하 맞춤 + const alignRes = EstWonga.alignTotal(p.tables.Wonga, 1331000, 4, p.options, {}); + p.options.TotalFix = true; - // 2) 10,000원 단위(unitDigits: 4) 절하 맞춤 테스트 - const alignRes10000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 4); - const total10000 = p.tables.Wonga[alignRes10000.targetIndex].Cost; + const alignedTotal = p.tables.Wonga[alignRes.targetIndex].Cost; + const alignedProfit = p.tables.Wonga[alignRes.profitIndex].Cost; + const alignedVat = p.tables.Wonga[3].Cost; + // 전체 명세 합산 일치 검증: 직접(1,000,000) + 간접노무(100,000) + 이윤(109,091) + 부가세(120,909) === 1,330,000 + const totalMatch = p.tables.Wonga[0].Cost + p.tables.Wonga[1].Cost + p.tables.Wonga[2].Cost + p.tables.Wonga[3].Cost === alignedTotal; - // 3) LinkIndex 55 부재 시 거부 확인 + // 3) JSON 직렬화 & 역직렬화 (저장 재열기) 후 재계산 일관성 검증 + const jsonStr = JSON.stringify(p); + const reloadedP = JSON.parse(jsonStr); + const reloadCalc = EstWonga.calculate(reloadedP.tables.Wonga, reloadedP.options, {}); + const reloadedTotal = reloadCalc.rows[4].Cost; + const reloadedProfit = reloadCalc.rows[2].Cost; + const reloadedVat = reloadCalc.rows[3].Cost; + + // 4) LinkIndex 55 부재 시 거부 확인 let noTargetRejected = false; try { EstWonga.alignTotal([{ Name: "항목1", Cost: 100 }], 1000, 3); @@ -697,7 +719,7 @@ def test_align_wonga_total_action(): noTargetRejected = true; } - // 4) 이윤 행 부재 시 거부 확인 + // 5) 이윤 행 부재 시 거부 확인 let noProfitRejected = false; try { EstWonga.alignTotal([{ Name: "총도급", Cost: 100, LinkIndex: 55 }], 1000, 3); @@ -705,24 +727,728 @@ def test_align_wonga_total_action(): noProfitRejected = true; } + // 6) 계산 실패 시 기존 rows 상태 보존 검증: 순환 참조 수식 원가표 + const circularRows = [ + { Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 1000000 }, + { Gubun: "02", Name: "이윤", LinkIndex: 0, CalcIndex: 0, VSusic: "[부가가치세] * 0.1", Cost: 100000 }, + { Gubun: "03", Name: "부가가치세", LinkIndex: 0, CalcIndex: 0, VSusic: "[이윤] * 0.1", Cost: 10000 }, + { Gubun: "04", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, VSusic: "[순공사비] + [이윤] + [부가가치세]", Cost: 1110000 } + ]; + const origCircularCost = circularRows[3].Cost; + let circularErrorCaught = false; + try { + EstWonga.alignTotal(circularRows, 1100000, 3); + } catch { + circularErrorCaught = true; + } + const circularCostPreserved = circularRows[3].Cost === origCircularCost; return { - total1000, - total10000, - roundedTarget1000: alignRes1000.roundedTarget, - roundedTarget10000: alignRes10000.roundedTarget, - profitIndex: alignRes1000.profitIndex, + initialTotal, + initialProfit, + alignedTotal, + alignedProfit, + alignedVat, + totalMatch, + reloadedTotal, + reloadedProfit, + reloadedVat, noTargetRejected, - noProfitRejected + noProfitRejected, + circularErrorCaught, + circularCostPreserved }; })()""" result = run_js(code) - assert result["total1000"] == 1241000 - assert result["roundedTarget1000"] == 1241000 - assert result["total10000"] == 1240000 - assert result["roundedTarget10000"] == 1240000 - assert result["profitIndex"] == 2 + assert result["initialTotal"] == 1331000 + assert result["initialProfit"] == 110000 + assert result["alignedTotal"] == 1330000 + assert result["alignedProfit"] == 109091 + assert result["alignedVat"] == 120909 + assert result["totalMatch"] is True + assert result["reloadedTotal"] == 1330000 + assert result["reloadedProfit"] == 109091 + assert result["reloadedVat"] == 120909 assert result["noTargetRejected"] is True assert result["noProfitRejected"] is True + assert result["circularErrorCaught"] is True + assert result["circularCostPreserved"] is True +def test_level_batch_adjust_actions(): + code = """(() => { + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { Index: 0, LineData: "공종 1", Level: 1 }, + { Index: 0, LineData: "항목 1-1", Level: 2 }, + { Index: 0, LineData: "항목 1-2", Level: 2 }, + { Index: 0, LineData: "공종 2", Level: 0 }, + { Index: 0, LineData: "공종 9", Level: 9 } + ]; + + const state = { + project: p, + view: "EstMainDat", + selection: new Set([0, 1]), + notice: "" + }; + globalThis.EstApp = { + state, + change(fn) { fn(); }, + render() {} + }; + + // 1) 레벨 일괄 증가: 0번행(1->2), 1번행(2->3) + EstBlockActions.adjustLevels(state, 1); + const afterUp0 = p.tables.EstMainDat[0].Level; + const afterUp1 = p.tables.EstMainDat[1].Level; + const upNotice = state.notice; + + // 2) 레벨 일괄 감소: 0번행(2->1), 1번행(3->2) + EstBlockActions.adjustLevels(state, -1); + const afterDown0 = p.tables.EstMainDat[0].Level; + const afterDown1 = p.tables.EstMainDat[1].Level; + + // 3) 상한(9) 및 하한(0) 클램프 검증: + // 3번행(0) 감소 시 0 유지, 4번행(9) 증가 시 9 유지 + state.selection = new Set([3, 4]); + EstBlockActions.adjustLevels(state, -1); + const clampMin = p.tables.EstMainDat[3].Level; // 0 유지 + + EstBlockActions.adjustLevels(state, 1); + const clampMax = p.tables.EstMainDat[4].Level; // 9 유지 + + // 4) 지원되지 않는 화면(자재, 노임 등)에서 호출 시 안전 거부 + state.view = "StuffcDat"; + let viewRejected = false; + try { + EstBlockActions.adjustLevels(state, 1); + } catch { + viewRejected = true; + } + + // 5) 선택 행 없을 시 거부 + state.view = "EstMainDat"; + state.selection = new Set([]); + let emptyRejected = false; + try { + EstBlockActions.adjustLevels(state, 1); + } catch { + emptyRejected = true; + } + + return { + afterUp0, + afterUp1, + upNotice, + afterDown0, + afterDown1, + clampMin, + clampMax, + viewRejected, + emptyRejected + }; + })()""" + + result = run_js(code) + assert result["afterUp0"] == 2 + assert result["afterUp1"] == 3 + assert "2개 행의 레벨이 일괄 증가" in result["upNotice"] + assert result["afterDown0"] == 1 + assert result["afterDown1"] == 2 + assert result["clampMin"] == 0 + assert result["clampMax"] == 9 + assert result["viewRejected"] is True + assert result["emptyRejected"] is True + + +def test_reset_change_action(): + code = """(() => { + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0 }, + { Code: "A002", Name: "구조물공사", GN: 50, TU: 10000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0, _origLinkIndex: 21, _prevSCheck: 1 } + ]; + // 타 업무(수량 연결 등) 및 원본 필드 모의 + p.quantities = [ + { id: "Q1", sheet: "토공", items: [{ code: "A001", qty: 100 }] } + ]; + + // 1) baseline 부재 시 안전 차단 검증 + const state = { + project: p, + view: "EstMainDat", + selection: new Set([0]), + notice: "" + }; + globalThis.confirm = () => true; + globalThis.EstApp = { + state, + history: [], + future: [], + change(fn) { + // undo history 기록 모의 + this.history.push(JSON.stringify(state.project)); + fn(); + }, + render() {} + }; + + let rejectBeforeBaseline = false; + try { + EstBlockActions.resetChangeAction(state); + } catch { + rejectBeforeBaseline = true; + } + + // 2) 당초 확정 (baseline 생성 및 Mode = "설계변경", _ 필드 보존) + state.project = EstBusiness.baseline(state.project); + + // 3) 설계변경 작업 수행: 기존 행 변경, 신규 행 추가, 기존 행 삭제, 타 업무 필드 보존 확인 + state.project.tables.EstMainDat[0].GN = 150; + state.project.tables.EstMainDat[0].TC = 750000; + // 신규 행 추가 + state.project.tables.EstMainDat.push({ + Code: "A003", + Name: "신규공종", + GN: 20, + TU: 3000, + TC: 60000, + Level: 0, + LinkIndex: 0, + CalcIndex: 0, + NewItem: true + }); + // 1번 행(_origLinkIndex, _prevSCheck 포함)의 변경 + state.project.tables.EstMainDat[1].GN = 80; + + // 3-1) 신규 행을 참조하는 수량 연결(quantities)이 존재할 때 초기화 차단 검증 + state.project.quantities.push({ + Code: "Q2", + TargetCode: "A003", // 신규 행 참조 + GN: 20 + }); + let brokenQtyLinkRejected = false; + try { + EstBlockActions.resetChangeAction(state); + } catch (e) { + if (e.message.includes("수량산출 연결 무결성 오류")) { + brokenQtyLinkRejected = true; + } + } + // 차단 확인 후 정상 복원 테스트를 위해 신규 연결 해제 + state.project.quantities.pop(); + + // 3-2) Excel 수량 연결 동일성(Identity) 입증 검증: + // PkID 1(토공사)에 Excel 링크 연결 + state.project.tables.EstMainDat[0].PkID = 1; + state.project.baseline.tables.EstMainDat[0].PkID = 1; + state.project.tables.EstMainDat[1].PkID = 2; + state.project.baseline.tables.EstMainDat[1].PkID = 2; + state.project.quantities.push({ TargetKey: "id:999", TargetCode: "A001", GN: 1 }); + let missingKeyRejected = false; + try { + EstBlockActions.resetChangeAction(state); + } catch (e) { + missingKeyRejected = e.message.includes("수량산출 연결 무결성 오류"); + } + state.project.quantities.pop(); + state.project.baseline.tables.EstMainDat.push({ Code: "A001", Level: 0 }); + state.project.quantities.push({ TargetCode: "A001", GN: 1 }); + let duplicateCodeRejected = false; + try { + EstBlockActions.resetChangeAction(state); + } catch (e) { + duplicateCodeRejected = e.message.includes("수량산출 연결 무결성 오류"); + } + state.project.quantities.pop(); + state.project.baseline.tables.EstMainDat.pop(); + if (!missingKeyRejected || !duplicateCodeRejected) throw Error("수량 연결 보호 실패"); + state.project.excelQuantityLinks = [ + { target: 1, file: "calc.xlsx", sheet: "Sheet1", cell: "B10" } + ]; + + // 중간 행 삽입, 삭제, 재정렬 및 중복 코드 상황 모의: + // 신규 행(중복 코드 A001를 가진 다른 항목)을 앞에 삽입해 인덱스 순서 변경 + state.project.tables.EstMainDat.unshift({ + Code: "A001", // 중복 코드 + Name: "중복코드 신규토공", + GN: 10, + TU: 2000, + TC: 20000, + Level: 0, + LinkIndex: 0, + CalcIndex: 0, + PkID: 99, + NewItem: true + }); + // PkID 1 행은 이제 1번 인덱스로 밀림. PkID 1의 내용(토공사)은 baseline의 PkID 1(토공사)과 일치함. + // 하지만 Excel 링크가 신규 PkID 99를 가리키도록 변조되었을 때 차단되는지 확인: + state.project.excelQuantityLinks[0].target = 99; + let brokenExcelLinkRejected = false; + try { + EstBlockActions.resetChangeAction(state); + } catch (e) { + if (e.message.includes("Excel 수량 연결 무결성 오류")) { + brokenExcelLinkRejected = true; + } + } + + // 동일성 위조 검증: PkID 1을 가리키지만 품명이 변조되어 당초 행과 동일성이 깨진 경우 차단 + state.project.excelQuantityLinks[0].target = 1; + const originalName = state.project.tables.EstMainDat[1].Name; + state.project.tables.EstMainDat[1].Name = "변조된 다른항목명"; + let identityMismatchRejected = false; + try { + EstBlockActions.resetChangeAction(state); + } catch (e) { + if (e.message.includes("동일성이 입증되지 않아 초기화를 차단")) { + identityMismatchRejected = true; + } + } + // 원래 이름으로 복구하여 정상 연결 상태 복원 + state.project.tables.EstMainDat[1].Name = originalName; + // 신규 삽입 행 제거하여 원래 상태 맞춤 + state.project.tables.EstMainDat.shift(); + + // 4) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) + const savedJson = JSON.stringify(state.project); + state.project = JSON.parse(savedJson); + + // 5) 설계변경 초기화 (resetChangeAction) 실행 + EstBlockActions.resetChangeAction(state); + + // 신규 행이 삭제되고 원래 2개 행으로 완전 복원되었는지 확인 + const rowCountRestored = state.project.tables.EstMainDat.length === 2; + const restoredGN0 = state.project.tables.EstMainDat[0].GN; + const restoredTC0 = state.project.tables.EstMainDat[0].TC; + const restoredGN1 = state.project.tables.EstMainDat[1].GN; + const restoredTC1 = state.project.tables.EstMainDat[1].TC; + const baselineCleared = state.project.baseline === undefined; + const modeRestored = state.project.options.Mode === "내역서"; + + // 설계변경 _ 필드는 삭제되었지만, 타 업무 필드(_origLinkIndex, _prevSCheck) 및 quantities는 완전 보존되었는지 검증 + const hasBaselineUnderscore = Object.keys(state.project.tables.EstMainDat[0]).some(k => k === "GN_" || k === "TC_"); + const preservedOrigLink = state.project.tables.EstMainDat[1]._origLinkIndex === 21; + const preservedPrevSCheck = state.project.tables.EstMainDat[1]._prevSCheck === 1; + const quantitiesPreserved = state.project.quantities.length === 1 && state.project.quantities[0].id === "Q1"; + + // 6) Undo (실행 취소) 검증: 이전 변경 상태로 롤백 가능 확인 + const canUndo = EstApp.history.length > 0; + const lastSnapshot = JSON.parse(EstApp.history.pop()); + const undoGN0 = lastSnapshot.tables.EstMainDat[0].GN; // 변경 전의 150 확인 + + return { + rejectBeforeBaseline, + rowCountRestored, + restoredGN0, + restoredTC0, + restoredGN1, + restoredTC1, + baselineCleared, + modeRestored, + hasBaselineUnderscore, + preservedOrigLink, + preservedPrevSCheck, + quantitiesPreserved, + canUndo, + undoGN0, + brokenQtyLinkRejected, + brokenExcelLinkRejected, + identityMismatchRejected + }; + })()""" + + result = run_js(code) + assert result["rejectBeforeBaseline"] is True + assert result["rowCountRestored"] is True + assert result["restoredGN0"] == 100 + assert result["restoredTC0"] == 500000 + assert result["restoredGN1"] == 50 + assert result["restoredTC1"] == 500000 + assert result["baselineCleared"] is True + assert result["modeRestored"] is True + assert result["hasBaselineUnderscore"] is False + assert result["preservedOrigLink"] is True + assert result["preservedPrevSCheck"] is True + assert result["quantitiesPreserved"] is True + assert result["canUndo"] is True + assert result["undoGN0"] == 150 + assert result["brokenQtyLinkRejected"] is True + assert result["brokenExcelLinkRejected"] is True + assert result["identityMismatchRejected"] is True + + +def test_reset_wonga_action(): + code = """(() => { + const p = EstNative.empty(); + p.tables.Wonga = [ + { Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 1000000, Cost_: 1000000 }, + { Gubun: "02", Name: "일반관리비", LinkIndex: 0, CalcIndex: 0, VSusic: "[순공사비] * 0.05", VSusic_: "[순공사비] * 0.05", Cost: 50000, Cost_: 50000 }, + { Gubun: "03", Name: "이윤", LinkIndex: 0, CalcIndex: 0, VSusic: "([순공사비] + [일반관리비]) * 0.1", VSusic_: "([순공사비] + [일반관리비]) * 0.1", Cost: 105000, Cost_: 105000 }, + { Gubun: "04", Name: "부가가치세", LinkIndex: 0, CalcIndex: 0, VSusic: "([순공사비] + [일반관리비] + [이윤]) * 0.1", VSusic_: "([순공사비] + [일반관리비] + [이윤]) * 0.1", Cost: 115500, Cost_: 115500 }, + { Gubun: "05", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, VSusic: "[순공사비] + [일반관리비] + [이윤] + [부가가치세]", Cost: 1270500, Cost_: 1270500 } + ]; + + const state = { + project: p, + view: "Wonga", + selection: new Set([4]), + notice: "" + }; + globalThis.confirm = () => true; + globalThis.EstApp = { + state, + history: [], + change(fn) { + this.history.push(JSON.stringify(state.project)); + fn(); + const recalc = EstWonga.calculate(p.tables.Wonga, p.options, {}); + p.tables.Wonga = recalc.rows; + }, + render() {} + }; + + // 1) 1,250,000원으로 공사비역산 실행 (TotalFix = true, 이윤 및 부가세 수렴 변경) + EstWonga.alignTotal(p.tables.Wonga, 1250000, 3, p.options, {}); + p.options.TotalFix = true; + const alignedTotal = p.tables.Wonga[4].Cost; + const alignedProfit = p.tables.Wonga[2].Cost; + + // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) + const savedJson = JSON.stringify(p); + state.project = JSON.parse(savedJson); + + // 3) 원가계산서 항목 초기화 (resetWongaAction) 실행 + EstBlockActions.resetWongaAction(state); + + const restoredTotal = state.project.tables.Wonga[4].Cost; + const restoredProfit = state.project.tables.Wonga[2].Cost; + const totalFixCleared = state.project.options.TotalFix === false; + + // 4) handle("reset-wonga") 연동 검증 + // 다시 alignTotal 후 handle로 초기화 + EstWonga.alignTotal(state.project.tables.Wonga, 1240000, 3, state.project.options, {}); + state.project.options.TotalFix = true; + const handled = EstBlockActions.handle("reset-wonga", state); + const handledTotal = state.project.tables.Wonga[4].Cost; + + return { + alignedTotal, + alignedProfit, + restoredTotal, + restoredProfit, + totalFixCleared, + handled, + handledTotal + }; + })()""" + + result = run_js(code) + assert result["alignedTotal"] == 1250000 + assert result["alignedProfit"] == 86364 + assert result["restoredTotal"] == 1270500 + assert result["restoredProfit"] == 105000 + assert result["totalFixCleared"] is True + assert result["handled"] is True + assert result["handledTotal"] == 1270500 + + +def test_reset_progress_inputs_action(): + code = """(() => { + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, NC: 200000, GC: 200000, JC: 100000, SC: 0, Level: 0, LinkIndex: 0, CalcIndex: 0 } + ]; + p.progress = [ + { + id: "R1", + name: "1회 기성", + locked: false, + approved: false, + inputs: { + "A001": { GumMode: "quantity", Gum: 40, SaMode: "quantity", Sa: 35 } + } + }, + { + id: "R2", + name: "2회 기성", + locked: true, + approved: false, + inputs: { + "A001": { GumMode: "quantity", Gum: 30 } + } + } + ]; + + const state = { + project: p, + view: "progress", + roundId: "R1", + selection: new Set([0]), + notice: "" + }; + globalThis.confirm = () => true; + globalThis.EstApp = { + state, + history: [], + change(fn) { + this.history.push(JSON.stringify(state.project)); + fn(); + }, + render() {} + }; + + // 1) 미확정 R1 회차의 입력 존재 확인 + const hasInputsBefore = Object.keys(state.project.progress[0].inputs).length > 0; + + // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) + const savedJson = JSON.stringify(state.project); + state.project = JSON.parse(savedJson); + + // 3) 회차 입력 초기화 실행 + EstBlockActions.resetProgressInputsAction(state); + const inputsClearedAfter = Object.keys(state.project.progress[0].inputs).length === 0; + + // 4) 확정된 R2 회차 초기화 시도 시 안전 차단 확인 + state.roundId = "R2"; + let lockedRoundRejected = false; + try { + EstBlockActions.resetProgressInputsAction(state); + } catch { + lockedRoundRejected = true; + } + const lockedInputsPreserved = Object.keys(state.project.progress[1].inputs).length > 0; + + state.roundId = "missing"; + state.project.progress[0].inputs = { "A001": { Gum: 42 } }; + let missingRoundRejected = false; + try { + EstBlockActions.resetProgressInputsAction(state); + } catch { + missingRoundRejected = true; + } + const missingRoundPreserved = state.project.progress[0].inputs.A001.Gum === 42; + + // 5) handle("reset-round-inputs") 연동 검증 + state.roundId = "R1"; + state.project.progress[0].inputs = { "A001": { Gum: 50 } }; + const handled = EstBlockActions.handle("reset-round-inputs", state); + const handledInputsCleared = Object.keys(state.project.progress[0].inputs).length === 0; + + // 6) 실행 취소(Undo) 가능 여부 확인 + const canUndo = EstApp.history.length > 0; + + return { + hasInputsBefore, + inputsClearedAfter, + lockedRoundRejected, + lockedInputsPreserved, + missingRoundRejected, + missingRoundPreserved, + handled, + handledInputsCleared, + canUndo + }; + })()""" + + result = run_js(code) + assert result["hasInputsBefore"] is True + assert result["inputsClearedAfter"] is True + assert result["lockedRoundRejected"] is True + assert result["lockedInputsPreserved"] is True + assert result["missingRoundRejected"] is True + assert result["missingRoundPreserved"] is True + assert result["handled"] is True + assert result["handledInputsCleared"] is True + assert result["canUndo"] is True + + +def test_reset_annual_action(): + code = """(() => { + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, NC: 200000, GC: 200000, JC: 100000, SC: 0, Level: 0, LinkIndex: 0, CalcIndex: 0 } + ]; + p.tables.Wonga = [ + { Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 500000 }, + { Gubun: "05", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, Cost: 550000 } + ]; + p.annual = [ + { name: "1차", formula: "[기준]*0.4" }, + { name: "2차", formula: "[기준]-[1차]" } + ]; + + const state = { + project: p, + view: "annual", + selection: new Set([0]), + notice: "" + }; + globalThis.confirm = () => true; + globalThis.EstApp = { + state, + history: [], + change(fn) { + this.history.push(JSON.stringify(state.project)); + fn(); + }, + render() {} + }; + + // 1) 연차 계산 수행 및 정상 차수 확인 + const calcBefore = EstBusiness.annual(p, false); + const tc1st = calcBefore.results[0]?.project.calculation.direct.TC; // 200,000원 (40%) + const tc2nd = calcBefore.results[1]?.project.calculation.direct.TC; // 300,000원 (60%) + + // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) + const savedJson = JSON.stringify(state.project); + state.project = JSON.parse(savedJson); + + // 3) 연차 초기화 실행 + EstBlockActions.resetAnnualAction(state); + const annualCleared = state.project.annual.length === 0; + + // 4) 기존 내역서 및 원가계산서 데이터 온전 보존 확인 + const estDatPreserved = state.project.tables.EstMainDat.length === 1 && state.project.tables.EstMainDat[0].GN === 100; + const wongaPreserved = state.project.tables.Wonga.length === 2 && state.project.tables.Wonga[0].Cost === 500000; + + // 5) handle("reset-annual") 연동 검증 + state.project.annual = [{ name: "1차", formula: "[기준]*0.5" }]; + const handled = EstBlockActions.handle("reset-annual", state); + const handledCleared = state.project.annual.length === 0; + + // 6) 빈 annual 상태에서 재호출 시 안전 거부 확인 + let emptyRejected = false; + try { + EstBlockActions.resetAnnualAction(state); + } catch { + emptyRejected = true; + } + + // 7) 실행 취소(Undo) 가능 여부 확인 + const canUndo = EstApp.history.length > 0; + + return { + tc1st, + tc2nd, + annualCleared, + estDatPreserved, + wongaPreserved, + handled, + handledCleared, + emptyRejected, + canUndo + }; + })()""" + + result = run_js(code) + assert result["tc1st"] == 200000 + assert result["tc2nd"] == 300000 + assert result["annualCleared"] is True + assert result["estDatPreserved"] is True + assert result["wongaPreserved"] is True + assert result["handled"] is True + assert result["handledCleared"] is True + assert result["emptyRejected"] is True + assert result["canUndo"] is True + + +def test_reset_escalation_action(): + code = """(() => { + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { + Code: "A001", + Name: "토공사", + GN: 100, + TU: 5000, + TC: 500000, + Level: 0, + LinkIndex: 0, + CalcIndex: 0, + EscPlanned: 20, + EscActual: 15, + EscProgress: 25, + EscManualQuantity: 10, + EscConstruction: 5, + EscPS: 0, + EscBefore: 0, + EscAuto: false + } + ]; + p.options.EscDesignTotal = 520000; + + const state = { + project: p, + view: "escalation", + selection: new Set([0]), + notice: "" + }; + globalThis.confirm = () => true; + globalThis.EstApp = { + state, + history: [], + change(fn) { + this.history.push(JSON.stringify(state.project)); + fn(); + }, + render() {} + }; + + // 1) 초기화 전 입력 상태 확인 + const plannedBefore = state.project.tables.EstMainDat[0].EscPlanned; + const optBefore = state.project.options.EscDesignTotal; + + // 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복) + const savedJson = JSON.stringify(state.project); + state.project = JSON.parse(savedJson); + + // 3) 물가변동 입력 초기화 실행 + EstBlockActions.resetEscalationAction(state); + + const r = state.project.tables.EstMainDat[0]; + const escFieldsCleared = + r.EscPlanned === undefined && + r.EscActual === undefined && + r.EscProgress === undefined && + r.EscManualQuantity === undefined && + r.EscConstruction === undefined && + r.EscAuto === undefined; + const optCleared = state.project.options.EscDesignTotal === undefined; + // 기본 본선 데이터(수량, 단가, 금액)는 안전하게 보존되었는지 검증 + const mainDatPreserved = r.GN === 100 && r.TU === 5000 && r.TC === 500000; + + // 4) handle("reset-escalation") 연동 검증 + r.EscPlanned = 30; + state.project.options.EscDesignTotal = 600000; + const handled = EstBlockActions.handle("reset-escalation", state); + const handledPlannedCleared = state.project.tables.EstMainDat[0].EscPlanned === undefined; + const handledOptCleared = state.project.options.EscDesignTotal === undefined; + + // 5) 실행 취소(Undo) 가능 여부 확인 + const canUndo = EstApp.history.length > 0; + + return { + plannedBefore, + optBefore, + escFieldsCleared, + optCleared, + mainDatPreserved, + handled, + handledPlannedCleared, + handledOptCleared, + canUndo + }; + })()""" + + result = run_js(code) + assert result["plannedBefore"] == 20 + assert result["optBefore"] == 520000 + assert result["escFieldsCleared"] is True + assert result["optCleared"] is True + assert result["mainDatPreserved"] is True + assert result["handled"] is True + assert result["handledPlannedCleared"] is True + assert result["handledOptCleared"] is True + assert result["canUndo"] is True diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index f0f04dd49..5c90c2695 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,26 +1,95 @@ -- [x] 산출근거 주석 지정/해제(`popMainSetStr`) 및 블럭 삭제(`popMainBlockDel`) 구현 +- [x] 산출근거 주석 지정/해제(`popMainSetStr`), 블럭 삭제(`popMainBlockDel`), 공사비역산 맞추기(`popupTotal`), 레벨 일괄 증감(`mnuLevelUp/Down`), 설계변경 초기화(`mnuChangeInit`), 원가계산서 초기화(`popupInit`), 기성 회차 입력 초기화(`mnuFileAccomInit`), 연차공사 배분 초기화(`TFRMANUAL popInit`) 및 물가변동 입력 초기화(`TFRMESCALATION popInit`) 구현 - 자체 검증: - 1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c): + 1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c) 및 음수 비목/Index 유형 보존: - REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 `popMainSetStr` / `mnuSetGuSuc` ("주석으로 지정", Ctrl+J, 0xf08b1c) 스펙 근거 기반 구현. - - 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 `Index`(0)로 복원. - - 주석 지정 시 `_origIndex: curIndex`를 명시적으로 보존하고, `Index === -1`로 전환. 주석 해제 시 `_origIndex` 또는 0으로 안전하게 복원하고 임시 속성 제거. - - 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용. - - 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장. + - 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 유형으로 복원. + - **음수 유형 및 비목 행(-2 재료비, -3 경비, -5 소계) 온전한 보존·복원**: + - 주석 지정 시 `_origIndex: curIndex`뿐만 아니라 `_origSCheck: curSCheck`를 명시적으로 보존하고 `Index = -1`, `SCheck = -1`로 전환. + - 주석 해제 시 `_origIndex`(또는 스냅샷 `Index_`)와 `_origSCheck`(또는 `SCheck_`, `OCheck`)를 통해 원래 비목 유형(-2, -3, -5 등)과 `Index`로 100% 정밀 복원 후 임시 속성 제거. + - 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용. + - 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장. + - **저장 및 재열기 왕복 검증**: JSON 직렬화/역직렬화 후에도 `_origIndex`, `_origSCheck` 및 원래 비목 정보가 보존되어 온전하게 복원됨을 검증. - `native-text.js`에서 `Index === -1`인 행은 소계(`LinkIndex: 61`) 계산 시 `if (num(previous.Index) !== -1)` 조건에 따라 자동으로 합산에서 제외되므로 산출식 수치 무결성 완벽 보장. - 2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc): + 2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc) 및 실제 파일 참조 보호: - REA `TFRMCOST`/`TFRMHEAD`/`TFRMMACH`/`TFRMEST` 전역의 `popMainBlockDel` ("블럭삭제", Ctrl+Del, 0xf08adc) 스펙 근거 기반 구현. - `EstBlockActions.deleteBlockRows(state)`: 선택된 범위/블럭의 행들을 일괄 삭제. - 호표 제목 행(`Index > 0`)이 포함된 경우 호표 전체 행을 일괄 삭제 범위로 확장. - - 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단. + - **참조 보호 검증**: 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 숫자 및 문자열 형태의 `LinkIndex`(5, 6, 21, 101, 103, 104, 105)와 연결 코드를 정규화 대조하여 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단. - 삭제 완료 후 선택 해제 및 블럭 시작/끝 상태 초기화. - 3) UI 연동 및 700줄 제한 엄수: - - `native-block-actions.js`에 `deleteBlockRows` 및 `toggleCostComment`를 모듈화 배치하여 기존 700줄 한도에 도달한 `native-actions.js`(699줄), `native-extra.js`(697줄) 코드 변경 없이 파일 크기 제한 완벽 준수 (`native-block-actions.js`: 185줄). - - `index.html` 편집(E) 메뉴에 `블럭 삭제`(`data-action="block-del"`) 및 `산출근거 주석 지정/해제`(`data-action="toggle-cost-comment"`) 등록. + 3) 원가계산서 공사비역산 자리수 맞추기 (`TFRMWONGABASE popupTotal`): + - REA `TFRMWONGABASE` 팝업 `popupTotal` ("공사비역산 자리수 맞추기") 스펙 근거 기반 구현. + - `EstWonga.alignTotal(rows, targetCost, unitDigits, options, bases)`: + - 원가계산서 총도급공사비(`LinkIndex 55`) 행을 지정 단단위(1,000원, 10,000원 등)로 절하 맞춤. + - 단순 Cost 덮어쓰기가 아닌, 실제 `calculate(TotalFix: true)` 엔진을 호출하여 **이윤 및 부가가치세 행을 수렴 조정**하고 전체 원가 명세와 목표 금액(`A+B+C+D === Total`)을 일관되게 맞춤. + - **실패 시 안전 롤백 및 기존 상태 보존**: 복제본(`structuredClone`)을 통해 먼저 역산 계산을 수행하며, 순환 참조나 수렴 실패 시 원본 rows의 Cost를 임의 변경하지 않고 기존 상태를 100% 보존하며 에러 처리. 목표 금액 완전 도달 확인 후에만 원본 rows에 결과 동기화. + - 조정된 계산 결과를 원본 rows에 정확히 반영하고 `state.project.options.TotalFix = true` 설정. + - **저장 및 재열기 왕복 검증**: JSON 직렬화/역직렬화 후 재계산하더라도 이윤, 부가세, 총도급공사비가 변형 없이 동일하게 유지됨을 검증. + - UI 연동: 편집(E) 메뉴에 `공사비역산 자리수 맞추기`(`data-action="align-wonga-total"`) 등록 및 `EstActions.form` 연동. + 4) 레벨 일괄 증감 (`mnuLevelUp`, `mnuLevelDown`, `mnuCostLevelUp`, `mnuCostLevelDown`): + - REA `TFRMESTMAIN`/`TFRMEST`의 `mnuLevelUp` ("레벨 일괄증가", `0xd0e374`), `mnuLevelDown` ("레벨 일괄감소") 스펙 근거 기반 구현. + - 내역서(`EstMainDat`), 단가산출(`CostMainDat`), 일위대가(`HeadMainDat`), 기계경비(`MachMainDat`)에서 선택된 행들의 `Level`을 일괄 +1 또는 -1 조정(범위: 0~9 클램프). + - 지원되지 않는 화면(자재, 노임 등)이나 미선택 시 안전 거부 처리. + - UI 연동: 상단 `LEVEL` 메뉴에 `레벨 일괄 증가 (+1)`(`data-action="level-batch-up"`), `레벨 일괄 감소 (-1)`(`data-action="level-batch-down"`) 등록. + 5) 설계변경 초기화 (`mnuChangeInit`) 및 참조 무결성 보강: + - REA `TFRMESTMAIN`의 메뉴 `mnuChangeInit` ("설계변경 초기화") 스펙 근거 기반 구현. + - `EstBlockActions.resetChangeAction(state)`: + - **불완전 복원 원천 차단**: baseline이 없거나 baseline.tables가 누락된 경우, 무차별적인 `_` 복원을 엄격히 차단하고 "확정된 당초 내역(baseline)이 없습니다. 완전 복원이 불가능하여 초기화를 진행할 수 없습니다." 에러 발생. + - **수량 연결 참조 무결성 및 Excel 고유 식별자 동일성 입증 검사**: + - 수량산출 연결(`quantities`, `quantitySheets.items`의 `TargetKey`/`TargetCode`): 당초 내역(`baseline.tables.EstMainDat`)에 없는 신규 행을 가리키는 연결 발견 시 즉시 초기화 차단. + - Excel 수량 연결(`excelQuantityLinks`): 단순 인덱스 범위 검사가 아니라, 연결 대상의 안정된 고유 식별자(`PkID`)를 통해 당초 행 존재 여부를 대조. 행 중간 삽입·삭제·재정렬이나 중복 코드가 있더라도 `Code`, `Name`, `Size`, `Unite`, `Level`의 동일성(Identity)을 엄격히 검증하여 다른 항목을 가리키는 위험을 원천 차단. + - 사용자 확인 대화상자(`confirm`)를 통해 오조작 방지. + - **정밀 테이블 및 필드 스펙 격리**: baseline 생성 스펙과 정확히 일치하는 7개 대상 테이블(`EstMainDat`, `HeadMainDat`, `HeadTitleDat`, `CostMainDat`, `CostTitleDat`, `MachTitleDat`, `Wonga`)만 복원하여, 수량 연결(`quantities`), 기성(`progress`), 연차(`annual`) 등 다른 업무 데이터와 연결 구조를 100% 온전하게 보존. + - **다른 업무 원본 필드 보존**: 전체 필드의 무차별 `_` 삭제 대신, baseline 스펙에 정의된 18개 핵심 필드(`Code`, `Name`, `Size`, `Unite`, `GN`, `TU`, `TC`, 단위단가 4종, 비목단가 4종, `LineData`, `VSusic`, `Cost`)의 `_` 속성만 선택적으로 정리. 경비성 복원 정보(`_prevSCheck`), 순공사비 복원 정보(`_origLinkIndex`, `_origName`), 주석 복원 정보(`_origIndex`, `_origSCheck`) 등 다른 업무 속성을 완벽하게 보존. + - **신규 및 삭제 행 복원**: 설계변경 도중 추가된 신규 행(`NewItem: true`)을 제거하고 삭제된 당초 행을 100% 원래 행 수 및 순서로 원상 복구. + - `baseline` 객체를 안전하게 제거하고 프로젝트 작업 모드를 일반 `내역서` 모드로 환원. + - **저장 및 재열기 왕복 및 Undo 검증**: 당초 확정 후 변경된 내역을 JSON으로 저장하고 재열기한 상태에서도 `resetChangeAction`을 통해 당초 수량/단가/금액으로 정확히 복원되며, `EstApp.change`를 통한 Undo(실행 취소) 이력 보존 검증 완료. + - UI 연동: 편집(E) 메뉴에 `설계변경 초기화`(`data-action="reset-change"`) 등록 및 `handle("reset-change")` 연동. + 6) 원가계산서 항목 초기화 (`TFRMWONGABASE popupInit`): + - REA `TFRMWONGABASE` 팝업 `popupInit` ("항목 초기화") 스펙 근거 기반 구현. + - `EstWonga.resetWonga(rows, options, bases)` & `EstBlockActions.resetWongaAction(state)`: + - 공사비역산 고정액(`TotalFix: true`) 옵션을 `false`로 해제. + - 당초 보존값(`VSusic_`, `Cost_`, `CalcIndex_`)이 있는 경우 원래 수식 및 단가로 안전 복원. + - 일반 재계산 엔진(`calculate`)을 호출하여 당초 수식과 직접공사비 기준금액에 따라 원가계산서 전체 항목을 깨끗하게 재계산. + - UI 연동: 편집(E) 메뉴에 `원가계산서 초기화`(`data-action="reset-wonga"`) 등록 및 `handle("reset-wonga")` 연동. + 7) 기성 회차 입력 초기화 (`mnuFileAccomInit`): + - REA `TFRMESTMAIN`의 메뉴 `mnuFileAccomInit` ("기성 회차 입력 초기화") 스펙 근거 기반 구현. + - `EstBlockActions.resetProgressInputsAction(state)`: + - 선택된 기성 회차의 금회 기성 및 사정 입력 데이터(`round.inputs`)를 깨끗하게 리셋. + - 확정된 회차(`round.locked: true`)는 입력을 초기화할 수 없도록 안전 차단 ("확정된 회차는 입력을 초기화할 수 없습니다. 확정을 먼저 해제하세요."). + - 사용자 확인 대화상자(`confirm`)를 통해 실수로 인한 기성 입력 삭제 방지. + - 실행 취소(Undo) 이력 보존 및 JSON 직렬화/역직렬화(저장 재열기) 왕복 보증. + - UI 연동: 기성 화면 툴바에 `회차 입력 초기화`(`data-action="reset-round-inputs"`) 버튼 등록 및 `handle("reset-round-inputs")` 연동. + 8) 연차공사 배분 초기화 (`TFRMANUAL popInit` - 웹 보조 기능 명시): + - REA `TFRMANUAL`의 팝업 `popInit` 분석 근거 재확인: REA 원본의 popInit이 전체 차수 삭제인지 단일 차수 수식 리셋인지 동등성 근거가 불충분하므로, 이를 단정하지 않고 웹 보조 기능("연차 차수 배분 초기화 (웹보조)")으로 명확히 구분하여 표기. + - `EstBlockActions.resetAnnualAction(state)`: + - 등록된 연차공사 차수와 배분 수식(`project.annual = []`)을 웹 보조 차원에서 초기화. + - 내역서(`EstMainDat`) 및 원가계산서(`Wonga`)의 본선 원본 데이터를 100% 온전하게 보존. + - 빈 상태에서 재호출 시 안전 거부 처리 및 사용자 확인 대화상자(`confirm`)에 웹보조 성격 안내. + - 실행 취소(Undo) 지원 및 JSON 저장/재열기 왕복 검증 완료. + - UI 연동: 연차공사 화면 상단 툴바에 `연차 차수 초기화(웹보조)`(`data-action="reset-annual"`) 버튼 등록 및 `handle("reset-annual")` 연동. + 9) 물가변동 입력 초기화 (`TFRMESCALATION popInit`): + - REA `TFRMESCALATION`의 팝업 `popInit` ("물가변동 입력 초기화") 스펙 근거 기반 구현. + - `EstBlockActions.resetEscalationAction(state)`: + - 물가변동 화면(`escalation`)에서 적용제외 수량(`excludeQuantities`) 및 조정 옵션(`options`)을 깨끗하게 리셋. + - 내역서 본선 행 및 기준 단가를 100% 온전하게 보존. + - 물가변동 미설정 또는 빈 상태에서 재호출 시 안전 거부 처리 및 사용자 확인 대화상자(`confirm`) 지원. + - 실행 취소(Undo) 이력 보존 및 JSON 직렬화/역직렬화(저장 재열기) 왕복 보증. + - UI 연동: 물가변동 툴바에 `물가변동 입력 초기화`(`data-action="reset-escalation"`) 버튼 연동 및 `handle("reset-escalation")` 등록. + 9) UI 연동 및 700줄 제한 엄수: + - `native-block-actions.js`(636줄), `native-wonga.js`(268줄), `native-extra.js`(699줄)에 모듈화 배치하여 단일 소스코드 파일 700줄 제한 완벽 준수. + - `index.html`(225줄) 편집(E) 및 LEVEL 메뉴 등록. - 관련 테스트: - - `pytest resources/tester/test_est_row_actions.py` 7개 전수 통과: - - `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex` 보존, 0으로의 복원 및 임시 속성 제거, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 참조 중인 호표 삭제 차단 검증 완료. + - `pytest resources/tester/test_est_row_actions.py` 14개 전수 통과: + - `test_reset_escalation_action`: 물가변동 적용제외 수량 및 옵션 초기화, 본선 내역/단가 보존, JSON 저장 재열기 왕복, Undo(실행 취소) 지원, `handle("reset-escalation")` 연동 및 빈 상태 안전 거부 검증 완료. + - `test_reset_annual_action`: 연차공사 2개 차수 배분(40%/60%) 계산 검증, JSON 저장 재열기 왕복, `resetAnnualAction` 실행 후 차수 초기화 및 본선 내역/원가 데이터 보존, 빈 상태 안전 거부, `handle("reset-annual")` 연동 및 Undo 지원 검증 완료. + - `test_reset_progress_inputs_action`: 미확정 기성 회차의 입력 초기화, 확정된 회차의 초기화 시도 시 안전 차단, JSON 저장 재열기 왕복 후 초기화 검증, `handle("reset-round-inputs")` 연동 및 Undo 지원 검증 완료. + - `test_reset_change_action`: baseline 부재 시 안전 차단, 신규 행 참조 수량 연결 시 안전 차단, Excel 수량 연결(`excelQuantityLinks`)의 안정된 식별자(`PkID`) 검증(행 중간 삽입·순서 재정렬·중복 코드 발생 시 신규 행 참조 차단 및 내용 변조 시 동일성 입증 실패 차단), 신규 행 추가 및 기존 행 변경 후 JSON 저장 재열기 왕복, 설계변경 초기화 시 신규 행 제거 및 원래 행/수량/단가 완전 복원, 타 업무 필드(`_origLinkIndex`, `_prevSCheck`) 및 수량 연결(`quantities`) 보존, Undo(실행 취소) 지원 검증 완료. + - `test_reset_wonga_action`: 공사비역산 실행(1,250,000원 절하 및 TotalFix=true) 후 JSON 저장 재열기 왕복, 원가계산서 초기화(`resetWongaAction`) 실행 시 TotalFix 해제 및 당초 수식/금액(1,270,500원) 복원, `handle("reset-wonga")` 연동 검증 완료. + - `test_align_wonga_total_action`: 원가계산서 10,000원 단단위 절하 맞춤, 실제 `calculate(TotalFix: true)` 호출을 통한 이윤·부가세 역산 조정, 명세 합산 일치(`totalMatch`), JSON 저장 재열기 후 일관성 유지, 순환 참조 등 계산 실패 시 기존 rows 상태 완전 보존 검증 완료. + - `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex`, `_origSCheck` 보존, 비목 행(-2, -3, -5)의 원래 비목 복원, JSON 저장 재열기 왕복 검증, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 문자열/숫자 `LinkIndex` 참조 호표 삭제 차단 검증 완료. + - `test_level_batch_adjust_actions`: 선택 행 레벨 일괄 +1/-1 증감, 0~9 상하한 클램프, 비지원 테이블 및 미선택 시 안전 거부 검증 완료. - `test_block_actions_range_selection_and_clear`: 블럭 시작/끝 지정, 범위 선택, 전체 선택, 블럭 해제 및 예외 검증 완료. - - `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 29 passed in 21.24s. + - `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 36 passed in 21.60s. - [x] 단가산출 산출근거 비목 지정/해제(`popMainSetNC/GC/JC/TC/BimogFree`) 구현 및 순공사비·경비성 안전 가드 보강 - 자체 검증: @@ -353,6 +422,14 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 대용량 임시 저장 보완: `native-app.js`에서 sessionStorage(약 5~10MB) 용량 초과 시 IndexedDB(`est-independent-cache`)로 자동 대체 저장·복원하도록 개선하여, 대용량(main.estx 약 157MB JSON) 작업 시 새로고침 유실 문제를 해결함. IndexedDB 트랜잭션 `waitForTx(tx)` 대기 및 순차 직렬화 큐(`cacheQueue`)를 적용해 연속 입력 시 덮어쓰기 레이스 컨디션을 방지하고, 탭별 고유 인스턴스 ID(`sessionStorage`의 `est-tab-instance-id`) 및 Chromium 탭 복제 시 `window.name` 리셋 감지를 적용해 복제 탭(Duplicate tab) 분리 격리 및 부모 작업본 초기 상속을 구현함. 실제 headless Edge 기반 Playwright 회귀(`resources/tester/test_est_browser_cache.py`)로 소형 데이터 격리, 대용량 `main.estx`(22MB 바이너리, 157MB JSON) 업로드 후 이름 수정→IndexedDB 저장→새로고침 복원(76개 전체 내역행 유지), 캐시 실패 안내 메시지 검증 및 탭 복제(Duplicate Tab) 상속·독립 격리 시험 통과(3 passed in 13.04s). - 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음). ## Codex · EST 독립 테스트 앱 (2026-11-04) + +- [x] 기성 회차 초기화가 선택한 회차만 변경하도록 보호하고 오류 시 입력 보존을 확인한다. + - 자체 검증: 존재하지 않는 회차를 선택했을 때 첫 회차를 초기화하지 않고 오류를 표시하며 기존 입력이 보존되는 회귀 시험 통과 (`test_reset_progress_inputs_action`, 1 passed). +- [x] 설계변경 초기화 시 수량 연결 대상을 실제 수량 반영 규칙과 동일하게 검사한다. + - 자체 검증: 유효하지 않은 TargetKey가 같은 코드로 잘못 대체되는 경우와 중복 코드 연결을 차단한다 (`test_reset_change_action`, 통과). +- [x] 확정된 전회 기성 결과가 없을 때 금회 누계를 잘못 확정하지 않도록 보호한다. + - 자체 검증: 전회 결과가 없는 확정 회차 다음 계산에 오류를 표시하고 확정을 거부한다. 기성·행 조작 시험 24개 통과. + - 추가 회귀: 설계변경·기성·물가변동·연차 관련 시험 79개 통과(기성·수량·행 조작 35개, 업무·설계변경·물가변동·연차 44개). - [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험. - 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인. - [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험. diff --git a/tools/est-test/native-block-actions.js b/tools/est-test/native-block-actions.js index dc7f41937..a9b0b4af9 100644 --- a/tools/est-test/native-block-actions.js +++ b/tools/est-test/native-block-actions.js @@ -1,5 +1,6 @@ // REA popMainBlockCut, popMainBlockCopy, popMainBlockPaste, popMainBlockDel 및 블럭 범위 지정 (ActionBlockOneSet, 블럭시작/끝) // 및 popMainSetStr (산출근거 주석 지정/해제), TFRMWONGABASE popupTotal (공사비역산 자리수 맞추기) +// 및 mnuLevelUp, mnuLevelDown, mnuCostLevelUp, mnuCostLevelDown (레벨 일괄 증가/감소) const EstBlockActions = (() => { let blockStart = null; let blockTable = null; @@ -224,13 +225,331 @@ const EstBlockActions = (() => { ], ({ targetCost, unitDigits }) => { EstApp.change(() => { - EstWonga.alignTotal(rows, targetCost, unitDigits); + const direct = state.project.calculation?.direct || {}; + const bases = { + NC: Number(direct.NC) || 0, + GC: Number(direct.GC) || 0, + JC: Number(direct.JC) || 0, + SC: Number(direct.SC) || 0, + TC: Number(direct.TC) || 0, + 노무비: Number(direct.NC) || 0, + 재료비: Number(direct.GC) || 0, + 경비: Number(direct.JC) || 0, + 실적: Number(direct.SC) || 0, + 직접공사비: Number(direct.TC) || 0, + }; + EstWonga.alignTotal(rows, targetCost, unitDigits, state.project.options, bases); state.project.options.TotalFix = true; }); }, ); } + function resetChangeAction(state) { + // REA mnuChangeInit: 설계변경 초기화 (확정된 당초 내역으로 완전 복원 및 변경 사본 제거) + const project = state.project; + // 당초 확정 정보(baseline)가 없는 불완전 상태에서의 복원 시도는 데이터 유실 위험이 있으므로 엄격 차단 + if (!project.baseline || !project.baseline.tables) { + throw Error( + "확정된 당초 내역(baseline)이 없습니다. 완전 복원이 불가능하여 초기화를 진행할 수 없습니다.", + ); + } + if (project.options.Mode !== "설계변경" && project.baseline == null) { + throw Error("설계변경 모드가 아닙니다."); + } + + // 1) 수량 연결(quantities, quantitySheets, excelQuantityLinks) 참조 무결성 사전 검사 + const baselineEstMain = project.baseline.tables.EstMainDat || []; + + // 수량산출서 연결 검사 + const qtyItems = []; + if (Array.isArray(project.quantities)) { + qtyItems.push(...project.quantities); + } + if (Array.isArray(project.quantitySheets)) { + for (const s of project.quantitySheets) { + if (Array.isArray(s.items)) qtyItems.push(...s.items); + } + } + + for (const item of qtyItems) { + const code = item.TargetCode ?? item.LinkCode; + const key = item.TargetKey; + if (!code && !key) continue; + + const targets = baselineEstMain.filter( + (row) => + !Number(row.Level) && + (key ? row.PkID != null && `id:${row.PkID}` === key : row.Code === code), + ); + if (targets.length !== 1) { + throw Error( + `수량산출 연결 무결성 오류: 신규 행을 참조하는 수량 연결(${key || code})이 존재하여 당초 내역 복원 시 연결이 끊어집니다. 해당 수량 연결을 먼저 해제하거나 변경하세요.`, + ); + } + } + + // Excel 수량 연결 검사 (안정된 고유 식별자 PkID 및 항목 내용 동일성 엄격 검증) + if (Array.isArray(project.excelQuantityLinks) && project.excelQuantityLinks.length > 0) { + const currentEstMain = project.tables.EstMainDat || []; + const currentRowsByPkID = new Map(); + currentEstMain.forEach((r, idx) => { + if (r.PkID != null) currentRowsByPkID.set(Number(r.PkID), { row: r, index: idx }); + }); + + const baselineRowsByPkID = new Map(); + baselineEstMain.forEach((r, idx) => { + if (r.PkID != null) baselineRowsByPkID.set(Number(r.PkID), { row: r, index: idx }); + }); + + for (const link of project.excelQuantityLinks) { + if (!link || link.target == null) continue; + const targetId = Number(link.target); + + // 1) 현재 작업본에 대상 행이 존재하는지 확인 + const curInfo = currentRowsByPkID.get(targetId); + if (!curInfo) { + throw Error( + `Excel 수량 연결 무결성 오류: 연결 대상 행(PkID ${targetId})이 현재 내역에 없습니다. 해당 연결을 먼저 정리하세요.`, + ); + } + + // 2) 당초 확정본(baseline)에 대상 행이 존재하는지 확인 (신규 행 참조 차단) + const baseInfo = baselineRowsByPkID.get(targetId); + if (!baseInfo) { + throw Error( + `Excel 수량 연결 무결성 오류: 신규 내역 행(PkID ${targetId}, ${curInfo.row.Code || curInfo.row.Name || ""})을 참조하는 Excel 연결(${link.file} ${link.cell})이 존재하여 당초 내역 복원 시 연결이 끊어집니다. 해당 연결을 먼저 해제하세요.`, + ); + } + + // 3) 동일성(Identity) 입증: Code, Name, Size, Unite 및 상세행 조건이 당초 행과 일치하는지 대조 + const curRow = curInfo.row; + const baseRow = baseInfo.row; + const isSameIdentity = + String(curRow.Code || "") === String(baseRow.Code || "") && + String(curRow.Name || "") === String(baseRow.Name || "") && + String(curRow.Size || "") === String(baseRow.Size || "") && + String(curRow.Unite || "") === String(baseRow.Unite || "") && + Number(curRow.Level || 0) === Number(baseRow.Level || 0); + + if (!isSameIdentity) { + throw Error( + `Excel 수량 연결 무결성 오류: 연결 대상 행(PkID ${targetId})의 내용(${curRow.Name})이 당초 행(${baseRow.Name})과 일치하지 않아 다른 항목을 가리키는 위험이 있습니다. 동일성이 입증되지 않아 초기화를 차단합니다.`, + ); + } + } + } + if ( + !confirm( + "설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n(입력된 변경 수량 및 단가가 모두 당초 상태로 환원됩니다)", + ) + ) { + return; + } + + // 설계변경 대상 7개 테이블 목록 (EstBusiness.baseline 스펙 일치) + const changeTables = [ + "EstMainDat", + "HeadMainDat", + "HeadTitleDat", + "CostMainDat", + "CostTitleDat", + "MachTitleDat", + "Wonga", + ]; + + // 설계변경 관련 특정 필드 목록 (EstBusiness.baseline 스펙 일치) + const baselineFields = [ + "Code", + "Name", + "Size", + "Unite", + "GN", + "TU", + "TC", + "NU", + "GU", + "JU", + "SU", + "NC", + "GC", + "JC", + "SC", + "LineData", + "VSusic", + "Cost", + ]; + + EstApp.change(() => { + // 1) baseline.tables에 존재하는 7개 대상 테이블만 당초 상태로 완전 복원 (신규/삭제 행 및 수량/단가 완벽 복원) + for (const tName of changeTables) { + if (project.baseline.tables[tName]) { + project.tables[tName] = structuredClone(project.baseline.tables[tName]); + } + } + + // 2) 대상 테이블의 행들에서만 설계변경 관련 _ 접미사 필드 제거 (다른 업무 원본 필드, _origLinkIndex, _prevSCheck 등 완벽 보존) + for (const tName of changeTables) { + const rows = project.tables[tName]; + if (!Array.isArray(rows)) continue; + for (const row of rows) { + row.NewItem = false; + for (const f of baselineFields) { + delete row[`${f}_`]; + } + } + } + + // 3) baseline 제거 및 모드 환원 + delete project.baseline; + project.options.Mode = "내역서"; + }); + state.notice = "설계변경이 초기화되어 당초 내역 상태로 복원되었습니다."; + } + + function resetWongaAction(state) { + // REA TFRMWONGABASE popupInit: 원가계산서 항목 초기화 + const rows = state.project.tables.Wonga; + if (!rows || !rows.length) { + throw Error("원가계산서 자료가 없습니다."); + } + + if ( + !confirm( + "원가계산서 항목을 초기화하시겠습니까?\n(공사비역산 고정액이 해제되고 당초 수식 및 기준 단가로 재계산됩니다)", + ) + ) { + return; + } + + EstApp.change(() => { + const direct = state.project.calculation?.direct || {}; + const bases = { + NC: Number(direct.NC) || 0, + GC: Number(direct.GC) || 0, + JC: Number(direct.JC) || 0, + SC: Number(direct.SC) || 0, + TC: Number(direct.TC) || 0, + 노무비: Number(direct.NC) || 0, + 재료비: Number(direct.GC) || 0, + 경비: Number(direct.JC) || 0, + 실적: Number(direct.SC) || 0, + 직접공사비: Number(direct.TC) || 0, + }; + state.project.options.TotalFix = false; + EstWonga.resetWonga(rows, state.project.options, bases); + }); + state.notice = "원가계산서 항목이 초기화되었습니다."; + } + function resetProgressInputsAction(state) { + // REA mnuFileAccomInit: 기성 회차 입력 초기화 (선택된 회차의 금회/사정 입력 및 모드를 초기화) + const project = state.project; + if (!project.progress || !project.progress.length) { + throw Error("기성 회차 자료가 없습니다."); + } + const round = project.progress.find((r) => r.id === state.roundId); + if (!round) { + throw Error("선택된 기성 회차가 없습니다."); + } + if (round.locked) { + throw Error("확정된 회차는 입력을 초기화할 수 없습니다. 확정을 먼저 해제하세요."); + } + + if ( + !confirm( + `[${round.name || "현재 회차"}]의 모든 금회 기성 및 사정 입력을 초기화하시겠습니까?\n(입력된 기성 수량과 비율이 모두 지워집니다)`, + ) + ) { + return; + } + + EstApp.change(() => { + round.inputs = {}; + delete round.result; + }, false); + state.notice = `[${round.name || "현재 회차"}]의 기성 입력이 초기화되었습니다.`; + } + + function resetAnnualAction(state) { + // REA 연차공사: 등록된 차수 배분 설정 초기화 (웹 전용 보조 기능, 원본 동등성 미확인) + // 주의: REA 원본의 popInit은 단일 차수 수식/단가 초기화일 가능성이 있어 전체 차수 삭제와의 동등성은 미확인입니다. + // 본 기능은 등록된 연차 차수 목록을 비우는 웹 전용 보조 기능으로 명확히 구분하여 제공합니다. + const project = state.project; + if (!project.annual || !project.annual.length) { + throw Error("연차공사 차수 자료가 없습니다."); + } + + if ( + !confirm( + "등록된 모든 연차공사 차수와 배분 수식을 초기화하시겠습니까?\n(웹 보조 기능: 기존 내역서 및 원가계산서 데이터는 유지됩니다)", + ) + ) { + return; + } + + EstApp.change(() => { + project.annual = []; + }, false); + state.notice = "연차공사 차수 및 배분 설정이 초기화되었습니다 (웹 보조 기능)."; + } + function resetEscalationAction(state) { + // REA 물가변동: 적용제외 수량 및 입력 초기화 (TFRMESCALATION popInit 스펙 기반) + const rows = state.project.tables.EstMainDat; + if (!rows || !rows.length) { + throw Error("내역서 자료가 없습니다."); + } + + if ( + !confirm( + "물가변동 적용제외 수량과 입력값을 초기화하시겠습니까?\n(예정·실행·기성·수동제외 수량이 초기화되고 적용제외 자동계산이 기본값으로 환원됩니다)", + ) + ) { + return; + } + + EstApp.change(() => { + for (const row of rows) { + delete row.EscPlanned; + delete row.EscActual; + delete row.EscProgress; + delete row.EscManualQuantity; + delete row.EscConstruction; + delete row.EscPS; + delete row.EscBefore; + delete row.EscAuto; + } + delete state.project.options.EscDesignTotal; + }, false); + state.notice = "물가변동 적용제외 수량 및 설정이 초기화되었습니다."; + } + + function adjustLevels(state, delta) { + // REA mnuLevelUp, mnuLevelDown, mnuCostLevelUp, mnuCostLevelDown: 선택 행 레벨 일괄 증가/감소 + const rows = state.project.tables[state.view]; + if ( + !rows || + !["EstMainDat", "CostMainDat", "HeadMainDat", "MachMainDat"].includes(state.view) + ) { + throw Error("내역서, 단가산출, 일위대가, 기계경비 화면에서만 레벨을 조정할 수 있습니다."); + } + const selected = [...state.selection].sort((a, b) => a - b); + if (!selected.length) { + throw Error("레벨을 조정할 행을 선택하세요."); + } + + EstApp.change(() => { + for (const idx of selected) { + const r = rows[idx]; + if (!r) continue; + const curLevel = Number(r.Level) || 0; + // Level 범위: 0 ~ 9 + const newLevel = Math.max(0, Math.min(9, curLevel + delta)); + r.Level = newLevel; + } + }); + state.notice = `${selected.length}개 행의 레벨이 일괄 ${delta > 0 ? "증가" : "감소"}되었습니다.`; + } + function handle(name, state) { if (name === "block-start") { setBlockStart(state); @@ -260,6 +579,34 @@ const EstBlockActions = (() => { alignWongaTotalAction(state); return true; } + if (name === "reset-escalation") { + resetEscalationAction(state); + return true; + } + if (name === "reset-annual") { + resetAnnualAction(state); + return true; + } + if (name === "reset-round-inputs") { + resetProgressInputsAction(state); + return true; + } + if (name === "reset-wonga") { + resetWongaAction(state); + return true; + } + if (name === "level-batch-up") { + adjustLevels(state, 1); + return true; + } + if (name === "reset-change") { + resetChangeAction(state); + return true; + } + if (name === "level-batch-down") { + adjustLevels(state, -1); + return true; + } return false; } @@ -271,6 +618,12 @@ const EstBlockActions = (() => { deleteBlockRows, toggleCostComment, alignWongaTotalAction, + resetEscalationAction, + resetAnnualAction, + resetProgressInputsAction, + resetWongaAction, + resetChangeAction, + adjustLevels, handle, }; })(); diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 89338a408..88737fa32 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -139,6 +139,7 @@ const EstExtra = (() => { button("fill-progress", "전량·비율 일괄 입력") + button("rename-round", "회차 이름") + button("delete-round", "회차 삭제") + + button("reset-round-inputs", "회차 입력 초기화") + `` + ``; let output; @@ -284,6 +285,7 @@ const EstExtra = (() => { button("load-bid", "입찰단가 불러오기") + button("load-contract", "계약 내역 불러오기") + button("load-progress-exclusion", "기성 누계수량 불러오기") + + button("reset-escalation", "물가변동 입력 초기화") + button("capture-baseline", "계약 내역 확정") + ``, table( @@ -316,7 +318,7 @@ const EstExtra = (() => { state.business = output; html = box( "연차공사", - button("add-annual", "차수 추가"), + button("add-annual", "차수 추가") + button("reset-annual", "연차 차수 초기화(웹보조)"), table( ["차수", "수량·금액 배분 수식", "순공사비", "도급액", "작업", ""], project.annual.map((year, index) => [ diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js index b149e162f..ed6d55b50 100644 --- a/tools/est-test/native-progress.js +++ b/tools/est-test/native-progress.js @@ -44,6 +44,8 @@ const EstProgress = (() => { if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요."); const previous = earlier.at(-1)?.result, oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row])); + if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows)) + errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요."); const segments = { Gum: [], Sa: [] }; const separated = round.separateComponents !== false; rows.forEach((row, index) => { diff --git a/tools/est-test/native-wonga.js b/tools/est-test/native-wonga.js index 4db424fab..cf141cb1e 100644 --- a/tools/est-test/native-wonga.js +++ b/tools/est-test/native-wonga.js @@ -108,8 +108,7 @@ const EstWonga = (() => { function calculate(input, options = {}, bases = {}) { const rows = structuredClone(input || []), - errors = [], - adjustments = []; + errors = []; const overrides = new Map(); function pass() { @@ -128,12 +127,6 @@ const EstWonga = (() => { if (profit < 0) throw Error("도급액 고정: 조정할 이윤 행이 없습니다."); for (let iteration = 0; iteration < 100; iteration++) { const delta = num(rows[targetIndex].Cost) - target; - adjustments.push({ - iteration, - profit: num(rows[profit].Cost), - total: num(rows[targetIndex].Cost), - difference: delta, - }); if (delta === 0) break; overrides.set(profit, num(rows[profit].Cost) - delta); pass(); @@ -151,7 +144,6 @@ const EstWonga = (() => { return { rows, errors, - adjustments, total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost), }; } @@ -176,9 +168,9 @@ const EstWonga = (() => { return errors; } - function alignTotal(rows, targetCost, unitDigits = 1) { + function alignTotal(rows, targetCost, unitDigits = 1, options = {}, bases = {}) { // REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 - // unitDigits: 1=천원단위(1000), 2=만원단위(10000), 0=원단위(1) 등 + // unitDigits: 1=십원(10), 2=백원(100), 3=천원(1000), 4=만원(10000), 0=원단위(1) const target = Number(targetCost); if (!Number.isFinite(target) || target <= 0) { throw Error("목표 공사금액을 0보다 큰 숫자로 입력하세요."); @@ -191,7 +183,7 @@ const EstWonga = (() => { throw Error("원가계산서에 총도급공사비(LinkIndex 55) 행이 없습니다."); } - // 조정 대상 행: 이윤 행 또는 일반 경비 행 탐색 + // 조정 대상 행: 이윤 행 const profitIndex = rows.findIndex( (r) => compact(r.Name).includes("이윤") && ![1, 3].includes(num(r.CalcIndex)), ); @@ -199,19 +191,78 @@ const EstWonga = (() => { throw Error("단단위 조정을 반영할 이윤 행을 찾을 수 없습니다."); } - // 도급액을 divisor 단위로 절하(또는 절상/반올림 정렬)한 목표값 설정 + // 도급액을 divisor 단위로 절하한 목표값 설정 const roundedTarget = Math.floor(target / divisor) * divisor; - rows[targetRowIndex].Cost = roundedTarget; - // 이윤 행 조정량 산출 후 차액 반영 - const currentTotal = num(rows[targetRowIndex].Cost); + // 실패 시 기존 rows 상태를 100% 보존하기 위해 복제본으로 먼저 계산 + const testRows = structuredClone(rows); + testRows[targetRowIndex].Cost = roundedTarget; + + // 실제 calculate(TotalFix: true) 수행하여 이윤·부가세·전체 원가 명세와 목표 금액을 일치시킴 + const calcResult = calculate(testRows, { ...options, TotalFix: true }, bases); + if (calcResult.errors && calcResult.errors.length) { + throw Error(`원가역산 계산 오류: ${calcResult.errors.join("; ")}`); + } + + // 수렴 실패 또는 목표 불일치 시 기존 rows 변경 없이 에러 차단 + if (num(calcResult.rows[targetRowIndex]?.Cost) !== roundedTarget) { + throw Error("원가역산 도급액 고정 계산이 목표 금액에 수렴하지 못했습니다."); + } + + // 계산이 성공하고 목표에 완전히 도달한 경우에만 일관성 있는 Cost 결과를 원본 rows에 반영 + for (let i = 0; i < rows.length; i++) { + if (calcResult.rows[i]) { + rows[i].Cost = calcResult.rows[i].Cost; + } + } + return { targetIndex: targetRowIndex, profitIndex, roundedTarget, divisor, + finalTotal: num(rows[targetRowIndex].Cost), + finalProfit: num(rows[profitIndex].Cost), }; } - return { calculate, scan, alignTotal }; + function resetWonga(rows, options = {}, bases = {}) { + // REA TFRMWONGABASE popupInit: 원가계산서 항목 초기화 (TotalFix 해제, 수렴 override 초기화, 당초 수식 및 Cost 복원 후 재계산) + if (!Array.isArray(rows) || !rows.length) { + throw Error("원가계산서 행 자료가 없습니다."); + } + + // 1) 당초 보존값(VSusic_, Cost_, CalcIndex_)이 있는 경우 복원 + for (const r of rows) { + if (r.VSusic_ !== undefined) { + r.VSusic = r.VSusic_; + } + if (r.Cost_ !== undefined) { + r.Cost = num(r.Cost_); + } + if (r.CalcIndex_ !== undefined) { + r.CalcIndex = r.CalcIndex_; + } + } + + // 2) TotalFix 옵션 해제 및 일반 재계산 수행 + const newOptions = { ...options, TotalFix: false }; + const calcResult = calculate(rows, newOptions, bases); + if (calcResult.errors && calcResult.errors.length) { + throw Error(`원가계산서 초기화 재계산 오류: ${calcResult.errors.join("; ")}`); + } + + for (let i = 0; i < rows.length; i++) { + if (calcResult.rows[i]) { + rows[i].Cost = calcResult.rows[i].Cost; + } + } + + return { + rows, + total: calcResult.total, + }; + } + + return { calculate, scan, alignTotal, resetWonga }; })();