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 };
})();