handlers.click({target:{closest:s=>s==='[data-review-batch-reason]'?{}:null}});
+ clickBatch();
+ const afterSelectBatch = p.contractReview.edits['row:2'].Reason;
+
+ // 5) 일괄 사유 적용 (선택 행 없을 때 표시 행 전체 적용)
+ EstApp.state.reviewSelection.clear();
+ clickBatch();
+ const afterAllBatch = p.contractReview.edits['row:3']?.Reason;
+
+ return {
+ allRowsCount,
+ changedRowsCount,
+ hasReasonRowsCount,
+ noReasonRowsCount,
+ afterSelectBatch,
+ afterAllBatch
+ };
+ })()""",
+ )
+ assert result["allRowsCount"] == 3
+ assert result["changedRowsCount"] == 2
+ assert result["hasReasonRowsCount"] == 1
+ assert result["noReasonRowsCount"] == 1
+ assert result["afterSelectBatch"] == "일괄사유적용"
+ assert result["afterAllBatch"] == "일괄사유적용"
diff --git a/resources/tester/test_est_row_actions.py b/resources/tester/test_est_row_actions.py
new file mode 100644
index 000000000..52363611f
--- /dev/null
+++ b/resources/tester/test_est_row_actions.py
@@ -0,0 +1,728 @@
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def run_js(code: str) -> dict:
+ runner = f"""
+ const fs = require('fs');
+ const vm = require('vm');
+ const ctx = {{
+ console,
+ setTimeout,
+ clearTimeout,
+ structuredClone,
+ URL,
+ TextDecoder,
+ TextEncoder,
+ Buffer,
+ document: {{
+ querySelector: () => ({{ setCustomValidity: () => {{}}, reportValidity: () => true }}),
+ body: {{ append: () => {{}} }},
+ createElement: () => ({{ click: () => {{}} }})
+ }},
+ window: {{}},
+ confirm: () => true
+ }};
+ ctx.window = ctx;
+ vm.createContext(ctx);
+
+ const files = [
+ 'tools/est-test/native-math.js',
+ 'tools/est-test/native-model.js',
+ 'tools/est-test/native-text.js',
+ 'tools/est-test/native-machine.js',
+ 'tools/est-test/native-wonga.js',
+ 'tools/est-test/native-engine.js',
+ 'tools/est-test/native-rows.js',
+ 'tools/est-test/native-business.js',
+ 'tools/est-test/native-extra.js',
+ 'tools/est-test/native-block-actions.js',
+ 'tools/est-test/native-business-actions.js'
+ ];
+ for (const f of files) {{
+ const content = fs.readFileSync(f, 'utf8');
+ vm.runInContext(content, ctx);
+ }}
+
+ const userCode = `{code}`;
+ const promise = vm.runInContext(userCode, ctx);
+ Promise.resolve(promise).then(result => {{
+ process.stdout.write(JSON.stringify(result));
+ }}).catch(err => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+ out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
+ return json.loads(out.decode("utf-8"))
+
+
+def test_toggle_jc_bimog_and_sun_gongsa():
+ code = """(async () => {
+ // 1. 프로젝트 초기 구성
+ const p = EstNative.empty();
+ p.tables.StuffcDat = [
+ { Code: "M01", Name: "자재1", TU: 1000, GU: 1000, Cost1: 1000, ValueIndex: 0, InputTC: 1000, TC: 1000 }
+ ];
+ p.tables.LaborcDat = [
+ { Code: "L01", Name: "노임1", TU: 2000, NU: 2000, Cost1: 2000, ValueIndex: 0, InputTC: 2000, TC: 2000 }
+ ];
+ p.tables.EstMainDat = [
+ { PkID: 1, Code: "M01", Name: "자재1", LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000, SCheck: 0 },
+ { PkID: 2, Code: "L01", Name: "노임1", LinkIndex: 6, GN: 3, TU: 2000, NU: 2000, NC: 6000, TC: 6000, SCheck: 0 },
+ { PkID: 3, Name: "공종", LinkIndex: 0, GN: 0, TC: 0 },
+ { PkID: 4, Name: "순공사비", LinkIndex: 54, GN: 0, TC: 8000, _origLinkIndex: 0, _origName: "공종" }
+ ];
+
+ // 가상 EstApp 환경
+ const state = {
+ project: p,
+ view: "EstMainDat",
+ selection: new Set([0])
+ };
+ const EstApp = {
+ state,
+ change(fn) { fn(); }
+ };
+
+ // 1) toggle-jc-bimog 실행: 자재1 행 SCheck=0 -> 3 (경비성단가 지정)
+ // GU=1000 -> JU=1000, GC=0, JC=2000, SCheck=3
+ let stateCopy = state;
+ globalThis.EstApp = EstApp;
+ globalThis.EstActions = { form: () => {} };
+
+ // 행 0 토글 -> SCheck 3
+ EstBusinessActions.handle("toggle-jc-bimog");
+ const r0_afterToggle = { ...p.tables.EstMainDat[0] };
+
+ // 행 0 재토글 -> SCheck 0 복원
+ EstBusinessActions.handle("toggle-jc-bimog");
+ const r0_restored = { ...p.tables.EstMainDat[0] };
+
+ // 행 1 (노임1, SCheck=2로 사전 설정) 경비성 토글 및 원래 SCheck=2 복원 확인
+ p.tables.EstMainDat[1].SCheck = 2;
+ state.selection = new Set([1]);
+ EstBusinessActions.handle("toggle-jc-bimog");
+ const r1_afterToggle = { ...p.tables.EstMainDat[1] };
+ EstBusinessActions.handle("toggle-jc-bimog");
+ const r1_restored = { ...p.tables.EstMainDat[1] };
+
+ // 2) toggle-sun-gongsa 실행:
+ // 행 2(기존공종)을 선택하고 순공사비 지정 -> 기존 행 3(순공사비)은 LinkIndex=0으로 해제되고 행 2가 54로 전환
+ state.selection = new Set([2]);
+ EstBusinessActions.handle("toggle-sun-gongsa");
+ const r2_afterSun = { ...p.tables.EstMainDat[2] };
+ const r3_afterSun = { ...p.tables.EstMainDat[3] };
+
+ // 행 2를 다시 토글 -> 54 해제
+ EstBusinessActions.handle("toggle-sun-gongsa");
+ const r2_restored = { ...p.tables.EstMainDat[2] };
+
+ // 3) 안전성 거부 확인
+ let rejected = 0;
+ // 3-1) 일위대가나 내역서가 아닌 화면에서 jc-bimog 호출 거부
+ state.view = "StuffcDat";
+ try { await EstBusinessActions.handle("toggle-jc-bimog"); } catch { rejected++; }
+
+ // 3-2) 선택 없는 상태에서 jc-bimog 호출 거부
+ state.view = "EstMainDat";
+ state.selection.clear();
+ try { await EstBusinessActions.handle("toggle-jc-bimog"); } catch { rejected++; }
+
+ // 3-3) sun-gongsa는 2개 이상 선택 시 거부
+ state.selection = new Set([0, 1]);
+ try { await EstBusinessActions.handle("toggle-sun-gongsa"); } catch { rejected++; }
+
+ // 3-4) 자재/원천 연결 행이나 수량이 있는 행(행 0)을 sun-gongsa로 지정 시도 시 안전 차단 거부
+ state.selection = new Set([0]);
+ try { await EstBusinessActions.handle("toggle-sun-gongsa"); } catch { rejected++; }
+
+ return {
+ r0_afterToggle,
+ r0_restored,
+ r1_afterToggle,
+ r1_restored,
+ r2_afterSun,
+ r3_afterSun,
+ r2_restored,
+ rejected
+ };
+ })()"""
+
+ result = run_js(code)
+
+ # 1) 경비성단가 지정 -> SCheck 3, JU=1000, GU=0
+ assert result["r0_afterToggle"]["SCheck"] == 3
+ assert result["r0_afterToggle"]["JU"] == 1000
+ assert result["r0_afterToggle"]["GU"] == 0
+ assert result["r0_afterToggle"]["TU"] == 1000
+
+ # 경비성단가 해제 -> SCheck 0, GU=1000, JU=0
+ assert result["r0_restored"]["SCheck"] == 0
+ assert result["r0_restored"]["GU"] == 1000
+ assert result["r0_restored"]["JU"] == 0
+
+ # 노무비(SCheck=2) 경비성 지정 -> SCheck 3, JU=2000, NU=0
+ assert result["r1_afterToggle"]["SCheck"] == 3
+ assert result["r1_afterToggle"]["JU"] == 2000
+ assert result["r1_afterToggle"]["NU"] == 0
+
+ # 노무비 경비성 해제 -> 원래 SCheck=2로 온전히 복원, NU=2000, JU=0
+ assert result["r1_restored"]["SCheck"] == 2
+ assert result["r1_restored"]["NU"] == 2000
+ assert result["r1_restored"]["JU"] == 0
+
+ # 2) 순공사비 경계 행 전환 -> 행 2가 54, 행 3은 0
+ assert result["r2_afterSun"]["LinkIndex"] == 54
+ assert result["r2_afterSun"]["Name"] == "순공사비"
+ assert result["r3_afterSun"]["LinkIndex"] == 0
+ assert result["r3_afterSun"]["Name"] == "공종"
+
+ # 순공사비 재토글 해제 -> 행 2가 0
+ assert result["r2_restored"]["LinkIndex"] == 0
+
+ # 3) 안전성 거부 4건 모두 통과 확인
+ assert result["rejected"] == 4
+
+
+def test_uses_data_search_finds_usages_across_tables():
+ code = """(() => {
+ const p = EstNative.empty();
+ p.tables.StuffcDat = [
+ { Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", TC: 5000 },
+ { Code: "M002", Name: "모래", Size: null, Unite: "㎥", TC: 20000 }
+ ];
+ p.tables.LaborcDat = [
+ { Code: "L001", Name: "보통인부", Size: null, Unite: "인", TC: 150000 }
+ ];
+ p.tables.HeadMainDat = [
+ { Index: 1, Code: "U001", Name: "콘크리트타설", Size: "구조물", Unite: "㎥", TC: 50000 },
+ { Index: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 2, TC: 10000 },
+ { Index: 0, Code: "L001", Name: "보통인부", Size: null, Unite: "인", GN: 0.1, TC: 15000 },
+ { Index: 2, Code: "U002", Name: "몰탈바름", Size: null, Unite: "㎡", TC: 30000 },
+ { Index: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 0.5, TC: 2500 }
+ ];
+ p.tables.CostMainDat = [
+ { Index: 1, Code: "D001", LineData: "\x0e기초터파기\x0e", TC: 10000 },
+ { Index: 0, Code: "M001", LineData: "\x0e시멘트\x0e", TC: 5000 }
+ ];
+ p.tables.EstMainDat = [
+ { PkID: 1, Level: 0, Code: "U001", Name: "콘크리트타설", Size: "구조물", Unite: "㎥", GN: 10, TC: 500000 },
+ { PkID: 2, Level: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 5, TC: 25000 },
+ { PkID: 3, Level: 0, Code: null, Name: "이름만일치모래", Size: null, Unite: "㎥", GN: 1, TC: 20000 }
+ ];
+
+ const targetM001 = p.tables.StuffcDat[0];
+ const m001Usages = EstExtra.findDataUsages(p, targetM001);
+
+ const targetU001 = p.tables.HeadMainDat[0];
+ const u001Usages = EstExtra.findDataUsages(p, targetU001);
+
+ const targetNoCode = { Name: "이름만일치모래" };
+ const noCodeUsages = EstExtra.findDataUsages(p, targetNoCode);
+
+ const targetNone = { Code: "NON_EXIST", Name: "없는항목" };
+ const noneUsages = EstExtra.findDataUsages(p, targetNone);
+
+ return {
+ m001Usages,
+ u001Usages,
+ noCodeUsages,
+ noneUsages
+ };
+ })()"""
+
+ result = run_js(code)
+ m001 = result["m001Usages"]
+ # M001: HeadMainDat 2건 (U001 그룹 1건, U002 그룹 1건), CostMainDat 1건 (D001 그룹), EstMainDat 1건 = 총 4건
+ assert len(m001) == 4
+ tables = [u["table"] for u in m001]
+ assert tables.count("HeadMainDat") == 2
+ assert tables.count("CostMainDat") == 1
+ assert tables.count("EstMainDat") == 1
+ # 그룹 코드 보존 검증
+ headGroups = [u["group"] for u in m001 if u["table"] == "HeadMainDat"]
+ assert "U001" in headGroups and "U002" in headGroups
+
+ # U001: EstMainDat 1건 검출 (HeadMainDat의 대표행 자신은 제외됨)
+ u001 = result["u001Usages"]
+ assert len(u001) == 1
+ assert u001[0]["table"] == "EstMainDat"
+ assert u001[0]["quantity"] == 10
+
+ # 이름만 있는 항목 검색
+ noCode = result["noCodeUsages"]
+ assert len(noCode) == 1
+ assert noCode[0]["table"] == "EstMainDat"
+
+ # 존재하지 않는 항목
+ assert len(result["noneUsages"]) == 0
+
+
+def test_toggle_safety_guards_prevent_arbitrary_zero_conversion_without_history():
+ code = """(() => {
+ const p = EstNative.empty();
+ // 기존 원본 54 행: _origLinkIndex가 없고 LinkIndex_=54인 상태 (저장 후 재열기 또는 원본)
+ p.tables.EstMainDat = [
+ { PkID: 1, Name: "원래순공사비", LinkIndex: 54, LinkIndex_: 54, GN: 0, TC: 5000 },
+ { PkID: 2, Name: "새로운공종", LinkIndex: 0, GN: 0, TC: 0 },
+ { PkID: 3, Code: "M01", Name: "원래경비성자재", LinkIndex: 5, SCheck: 3, SCheck_: 3, GN: 1, TU: 100, JU: 100, TC: 100 }
+ ];
+
+ const state = { project: p, view: "EstMainDat", selection: new Set([0]) };
+ globalThis.EstApp = { state, change(fn) { fn(); } };
+
+ let blocked = 0;
+
+ // 1) 기존 원본 54 행을 선택하고 해제 시도 -> 복원 정보가 없으므로 임의 0 변환 차단되어야 함
+ try {
+ EstExtra.toggleSunGongsa(state);
+ } catch (err) {
+ if (err.message.includes("임의 0으로 변환할 수 없습니다")) blocked++;
+ }
+
+ // 2) 다른 행(새로운공종)을 54로 지정 시도 -> 기존 54 행의 원래 유형을 알 수 없으므로 차단되어야 함
+ state.selection = new Set([1]);
+ try {
+ EstExtra.toggleSunGongsa(state);
+ } catch (err) {
+ if (err.message.includes("다른 행으로 대체할 수 없습니다")) blocked++;
+ }
+
+ // 3) 원본부터 SCheck 3이었던 행(복원 정보 없는 행)의 경비성 해제 시도 -> 차단되어야 함
+ state.selection = new Set([2]);
+ try {
+ EstExtra.toggleJcBimog(state);
+ } catch (err) {
+ if (err.message.includes("임의 변환할 수 없습니다")) blocked++;
+ }
+
+ // 4) 임의 변환 시도가 모두 차단되었으므로 기존 행 속성이 온전하게 보존되었는지 검증
+ return {
+ blocked,
+ r0_LinkIndex: p.tables.EstMainDat[0].LinkIndex,
+ r1_LinkIndex: p.tables.EstMainDat[1].LinkIndex,
+ r2_SCheck: p.tables.EstMainDat[2].SCheck
+ };
+ })()"""
+
+ result = run_js(code)
+ assert result["blocked"] == 3
+ assert result["r0_LinkIndex"] == 54
+ assert result["r1_LinkIndex"] == 0
+ assert result["r2_SCheck"] == 3
+
+
+def test_set_cost_line_kind_action():
+ code = """(async () => {
+ const p = EstNative.empty();
+ p.tables.CostMainDat = [
+ { Index: 1, Code: "D01", LineData: "\x0e기초터파기\x0e", TC: 1000 },
+ { Index: 0, Code: null, LineData: "노무비 산출라인", TC: 500, OCheck: 0, SCheck: 0 },
+ { Index: 0, Code: null, LineData: "재료비 산출라인", TC: 300, OCheck: -2, SCheck: -2 }
+ ];
+
+ let formCallback = null;
+ globalThis.EstActions = {
+ form(title, fields, cb) {
+ formCallback = cb;
+ }
+ };
+
+ const state = { project: p, view: "CostMainDat", selection: new Set([1]) };
+ globalThis.EstApp = { state, change(fn) { fn(); } };
+
+ // 1) 행 1을 노무비(-1)로 지정
+ await EstBusinessActions.handle("set-cost-line-kind");
+ formCallback({ kind: -1 });
+ const r1_afterNC = { OCheck: p.tables.CostMainDat[1].OCheck, SCheck: p.tables.CostMainDat[1].SCheck };
+
+ // 2) 행 2(기존 재료비 -2)를 일반 해제(0)로 전환
+ state.selection = new Set([2]);
+ await EstBusinessActions.handle("set-cost-line-kind");
+ formCallback({ kind: 0 });
+ const r2_afterFree = { OCheck: p.tables.CostMainDat[2].OCheck, SCheck: p.tables.CostMainDat[2].SCheck };
+
+ // 3) 안전성 거부: CostMainDat가 아닌 화면에서 호출 시 거부
+ state.view = "EstMainDat";
+ let rejected = 0;
+ try {
+ await EstBusinessActions.handle("set-cost-line-kind");
+ } catch (err) {
+ rejected++;
+ }
+
+ return {
+ r1_afterNC,
+ r2_afterFree,
+ rejected
+ };
+ })()"""
+
+ result = run_js(code)
+ assert result["r1_afterNC"] == {"OCheck": -1, "SCheck": -1}
+ assert result["r2_afterFree"].get("OCheck") is None
+ assert result["r2_afterFree"].get("SCheck") is None
+ assert result["rejected"] == 1
+
+
+def test_jc_bimog_multi_selection_atomic_and_json_roundtrip():
+ code = """(() => {
+ const p = EstNative.empty();
+ p.tables.StuffcDat = [
+ { Code: "M01", Name: "자재1", TU: 100, GU: 100, Cost1: 100, ValueIndex: 0, InputTC: 100, TC: 100 }
+ ];
+ p.tables.LaborcDat = [
+ { Code: "L01", Name: "노임1", TU: 200, NU: 200, Cost1: 200, ValueIndex: 0, InputTC: 200, TC: 200 }
+ ];
+ p.tables.EstMainDat = [
+ // 행 0: 정상 비목별(SCheck=0) -> 경비성 지정 가능
+ { PkID: 1, Code: "M01", Name: "자재1", LinkIndex: 5, GN: 1, TU: 100, GU: 100, GC: 100, TC: 100, SCheck: 0, SCheck_: 0 },
+ // 행 1: 기존 파일 원본부터 SCheck 3이고 이력이 없는 행 -> 해제 시 차단되어야 함
+ { PkID: 2, Code: "L01", Name: "노임1", LinkIndex: 6, GN: 1, TU: 200, JU: 200, JC: 200, TC: 200, SCheck: 3, SCheck_: 3 }
+ ];
+
+ const state = { project: p, view: "EstMainDat", selection: new Set([0, 1]) };
+ globalThis.EstApp = { state, change(fn) { fn(); } };
+
+ let atomicBlocked = false;
+ try {
+ // 복수 선택: 행 0(지정 시도)과 행 1(해제 시도인데 이력 없어 거부 대상)
+ EstExtra.toggleJcBimog(state);
+ } catch (err) {
+ if (err.message.includes("임의 변환할 수 없습니다")) atomicBlocked = true;
+ }
+
+ // 원자적 검증: 행 1 때문에 에러 발생 시 앞선 행 0도 변경되지 않고 SCheck=0 유지 확인
+ const r0_SCheck_afterFail = p.tables.EstMainDat[0].SCheck;
+
+ // 행 0 단독 지정 -> SCheck 3 및 _prevSCheck=0 보존
+ state.selection = new Set([0]);
+ EstExtra.toggleJcBimog(state);
+ const r0_afterToggle = { SCheck: p.tables.EstMainDat[0].SCheck, _prevSCheck: p.tables.EstMainDat[0]._prevSCheck };
+
+ // JSON 직렬화 & 역직렬화 (저장 후 재열기 시뮬레이션)
+ const serialized = JSON.stringify(p);
+ const reopened = JSON.parse(serialized);
+
+ // 재열기 후 해제 실행 -> SCheck_=0 및 _prevSCheck=0을 통해 원래 SCheck 0으로 정확히 복원
+ const reopenedState = { project: reopened, view: "EstMainDat", selection: new Set([0]) };
+ globalThis.EstApp.state = reopenedState;
+ EstExtra.toggleJcBimog(reopenedState);
+ const r0_restoredReopened = { SCheck: reopened.tables.EstMainDat[0].SCheck };
+
+ return {
+ atomicBlocked,
+ r0_SCheck_afterFail,
+ r0_afterToggle,
+ r0_restoredReopened
+ };
+ })()"""
+
+ result = run_js(code)
+ assert result["atomicBlocked"] is True
+ # 실패 시 앞선 행 0도 변경되지 않고 0 유지 (원자적 검증)
+ assert result["r0_SCheck_afterFail"] == 0
+ # 행 0 지정 시 SCheck=3 및 _prevSCheck=0 보존
+ assert result["r0_afterToggle"]["SCheck"] == 3
+ assert result["r0_afterToggle"]["_prevSCheck"] == 0
+ # JSON 재열기 후 해제 시 원래 0으로 온전 복원
+ assert result["r0_restoredReopened"]["SCheck"] == 0
+
+
+def test_block_actions_range_selection_and_clear():
+ code = """(() => {
+ const p = EstNative.empty();
+ p.tables.CostMainDat = [
+ { Index: 1, Code: "D01", LineData: "\\x0E기초터파기\\x0E", TC: 1000 },
+ { Index: 0, Code: null, LineData: "1행", TC: 100 },
+ { Index: 0, Code: null, LineData: "2행", TC: 200 },
+ { Index: 0, Code: null, LineData: "3행", TC: 300 },
+ { Index: 0, Code: null, LineData: "4행", TC: 400 },
+ { Index: 0, Code: null, LineData: "5행", TC: 500 }
+ ];
+
+ const state = { project: p, view: "CostMainDat", selection: new Set([1]), notice: "" };
+ globalThis.EstApp = { state, render() {} };
+
+ // 1) 1행(index 1)을 블럭 시작으로 지정
+ EstBlockActions.setBlockStart(state);
+ const startNotice = state.notice;
+
+ // 2) 4행(index 4)을 블럭 끝으로 지정 -> 1~4행(총 4개) 일괄 선택 확인
+ state.selection = new Set([4]);
+ EstBlockActions.setBlockEnd(state);
+ const selectedIndices = [...state.selection].sort((a, b) => a - b);
+ const endNotice = state.notice;
+
+ // 3) 전체 선택 실행
+ EstBlockActions.selectAllRows(state);
+ const allSelectedCount = state.selection.size;
+
+ // 4) 블럭 해제 실행
+ EstBlockActions.clearBlockSelection(state);
+ const clearedCount = state.selection.size;
+
+ // 5) 시작 지정 없이 블럭 끝 호출 시 거부
+ let rejected = 0;
+ try {
+ state.selection = new Set([2]);
+ EstBlockActions.setBlockEnd(state);
+ } catch {
+ rejected++;
+ }
+
+ return {
+ startNotice,
+ endNotice,
+ selectedIndices,
+ allSelectedCount,
+ clearedCount,
+ rejected
+ };
+ })()"""
+
+ result = run_js(code)
+ assert "2행이 블럭 시작점으로 설정" in result["startNotice"]
+ assert "2행부터 5행까지 총 4개 행이 블럭으로 선택" in result["endNotice"]
+ assert result["selectedIndices"] == [1, 2, 3, 4]
+ assert result["allSelectedCount"] == 6
+ assert result["clearedCount"] == 0
+ assert result["rejected"] == 1
+
+
+def test_toggle_cost_comment_and_block_delete():
+ code = """(() => {
+ const p = EstNative.empty();
+ p.tables.CostMainDat = [
+ { Index: 1, Code: "D01", LineData: "\\x0e단가산출 호표1\\x0e", TC: 1000, NC: 1000, GC: 0, JC: 0 },
+ { Index: 0, Code: null, LineData: "Q = 10 / 2 = \\x125\\x12", TC: 5, NC: 5, GC: 0, JC: 0 },
+ { Index: 0, Code: null, LineData: "Q2 = 20 / 2 = \\x1210\\x12", TC: 10, NC: 10, GC: 0, JC: 0 },
+ { Index: -20, Code: null, LineData: "구분선", TC: 0 },
+ { Index: 0, Code: null, LineData: "일반행 3", TC: 30 }
+ ];
+
+ const state = {
+ project: p,
+ view: "CostMainDat",
+ selection: new Set([1]),
+ notice: ""
+ };
+ globalThis.EstApp = {
+ state,
+ change(fn) {
+ fn();
+ },
+ render() {}
+ };
+
+ // 1) 주석 지정: 1번 행(Index=0)을 주석(Index=-1)으로 지정
+ EstBlockActions.toggleCostComment(state);
+ const afterCommentIndex = p.tables.CostMainDat[1].Index;
+ const origIndexSaved = p.tables.CostMainDat[1]._origIndex;
+
+ // 2) 주석 해제: 다시 토글 시 원래 Index(0)로 복원 및 _origIndex 제거
+ EstBlockActions.toggleCostComment(state);
+ const restoredIndex = p.tables.CostMainDat[1].Index;
+ const origIndexDeleted = p.tables.CostMainDat[1]._origIndex === undefined;
+
+ // 3) 호표 제목 행(Index > 0) 및 구분선 행(Index === -20) 주석 지정 거부 확인
+ let titleRejected = false;
+ try {
+ state.selection = new Set([0]); // 호표 제목 행
+ EstBlockActions.toggleCostComment(state);
+ } catch {
+ titleRejected = true;
+ }
+
+ let sepRejected = false;
+ try {
+ state.selection = new Set([3]); // 구분선(-20) 행
+ EstBlockActions.toggleCostComment(state);
+ } catch {
+ sepRejected = true;
+ }
+
+ // 4) 다중 행 선택 및 원자성 검증: 제목 행이 섞여 있으면 전체 거부
+ let mixedRejected = false;
+ try {
+ state.selection = new Set([1, 0, 2]);
+ EstBlockActions.toggleCostComment(state);
+ } catch {
+ mixedRejected = true;
+ }
+ const mixedNotToggled = p.tables.CostMainDat[1].Index === 0;
+
+ // 5) 블럭 삭제 기능 테스트: 1~2번 상세 행 블럭 삭제
+ state.selection = new Set([1, 2]);
+ EstBlockActions.deleteBlockRows(state);
+ const remainingCount = p.tables.CostMainDat.length;
+ const remainingLines = p.tables.CostMainDat.map(r => r.LineData);
+ const selectionCleared = state.selection.size === 0;
+
+ // 6) 다른 테이블에서 참조 중인 호표 삭제 시 거부 확인 (숫자 LinkIndex 및 문자열 "104" LinkIndex)
+ p.tables.EstMainDat = [
+ { Code: "D01", LinkIndex: "104", LineData: "내역서 항목", GN: 10 }
+ ];
+ let refRejected = false;
+ try {
+ state.selection = new Set([0]); // D01 호표 제목 행
+ EstBlockActions.deleteBlockRows(state);
+ } catch {
+ refRejected = true;
+ }
+
+ // 7) 비목 행(-2 재료비, -3 경비, -5 소계) 주석 지정 및 원래 비목 복원 + JSON 저장 재열기 검증
+ p.tables.CostMainDat.push(
+ { Index: 0, SCheck: -2, OCheck: -2, LineData: "재료비 행", TC: 200 },
+ { Index: 0, SCheck: -3, OCheck: -3, LineData: "경비 행", TC: 300 },
+ { Index: 0, SCheck: -5, OCheck: -5, LineData: "소계 행", TC: 500 }
+ );
+ // 재료비 행(index 3) 주석 지정
+ state.selection = new Set([3]);
+ EstBlockActions.toggleCostComment(state);
+ const bimogCommentedSCheck = p.tables.CostMainDat[3].SCheck;
+ const bimogCommentedIndex = p.tables.CostMainDat[3].Index;
+ const bimogOrigSCheck = p.tables.CostMainDat[3]._origSCheck;
+
+ // JSON 직렬화 & 역직렬화 (저장 재열기) 시뮬레이션
+ const jsonStr = JSON.stringify(p);
+ const reloadedP = JSON.parse(jsonStr);
+ state.project = reloadedP;
+
+ // 재열기 후 주석 해제 -> 원래 SCheck(-2) 및 Index(0)로 정확히 복원
+ EstBlockActions.toggleCostComment(state);
+ const restoredBimogSCheck = state.project.tables.CostMainDat[3].SCheck;
+ const restoredBimogIndex = state.project.tables.CostMainDat[3].Index;
+ const origSCheckDeleted = state.project.tables.CostMainDat[3]._origSCheck === undefined;
+
+ // 경비(-3), 소계(-5) 다중 선택 일괄 주석 지정 및 해제 검증
+ state.selection = new Set([4, 5]);
+ EstBlockActions.toggleCostComment(state);
+ const multiCommented = [
+ state.project.tables.CostMainDat[4].SCheck,
+ state.project.tables.CostMainDat[5].SCheck
+ ];
+ EstBlockActions.toggleCostComment(state);
+ const multiRestored = [
+ state.project.tables.CostMainDat[4].SCheck,
+ state.project.tables.CostMainDat[5].SCheck
+ ];
+
+ return {
+ afterCommentIndex,
+ origIndexSaved,
+ restoredIndex,
+ origIndexDeleted,
+ titleRejected,
+ sepRejected,
+ mixedRejected,
+ mixedNotToggled,
+ remainingCount,
+ remainingLines,
+ selectionCleared,
+ refRejected,
+ bimogCommentedSCheck,
+ bimogCommentedIndex,
+ bimogOrigSCheck,
+ restoredBimogSCheck,
+ restoredBimogIndex,
+ origSCheckDeleted,
+ multiCommented,
+ multiRestored
+ };
+ })()"""
+
+ result = run_js(code)
+ assert result["afterCommentIndex"] == -1
+ assert result["origIndexSaved"] == 0
+ assert result["restoredIndex"] == 0
+ assert result["origIndexDeleted"] is True
+ assert result["titleRejected"] is True
+ assert result["sepRejected"] is True
+ assert result["mixedRejected"] is True
+ assert result["mixedNotToggled"] is True
+ assert result["remainingCount"] == 3
+ assert result["selectionCleared"] is True
+ assert result["refRejected"] is True
+ assert result["bimogCommentedSCheck"] == -1
+ assert result["bimogCommentedIndex"] == -1
+ assert result["bimogOrigSCheck"] == -2
+ assert result["restoredBimogSCheck"] == -2
+ assert result["restoredBimogIndex"] == 0
+ assert result["origSCheckDeleted"] is True
+ assert result["multiCommented"] == [-1, -1]
+ assert result["multiRestored"] == [-3, -5]
+
+
+def test_align_wonga_total_action():
+ code = """(() => {
+ 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 }
+ ];
+
+ const state = {
+ project: p,
+ view: "Wonga",
+ selection: new Set([4]),
+ notice: ""
+ };
+ globalThis.EstApp = {
+ state,
+ change(fn) { fn(); },
+ 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) 절하 맞춤 테스트
+ const alignRes10000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 4);
+ const total10000 = p.tables.Wonga[alignRes10000.targetIndex].Cost;
+
+ // 3) LinkIndex 55 부재 시 거부 확인
+ let noTargetRejected = false;
+ try {
+ EstWonga.alignTotal([{ Name: "항목1", Cost: 100 }], 1000, 3);
+ } catch {
+ noTargetRejected = true;
+ }
+
+ // 4) 이윤 행 부재 시 거부 확인
+ let noProfitRejected = false;
+ try {
+ EstWonga.alignTotal([{ Name: "총도급", Cost: 100, LinkIndex: 55 }], 1000, 3);
+ } catch {
+ noProfitRejected = true;
+ }
+
+ return {
+ total1000,
+ total10000,
+ roundedTarget1000: alignRes1000.roundedTarget,
+ roundedTarget10000: alignRes10000.roundedTarget,
+ profitIndex: alignRes1000.profitIndex,
+ noTargetRejected,
+ noProfitRejected
+ };
+ })()"""
+
+ 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["noTargetRejected"] is True
+ assert result["noProfitRejected"] is True
+
+
diff --git a/resources/tester/test_est_standard.py b/resources/tester/test_est_standard.py
index 3a7427d33..ddfef6208 100644
--- a/resources/tester/test_est_standard.py
+++ b/resources/tester/test_est_standard.py
@@ -47,3 +47,267 @@ def test_standard_usage_reports_applied_and_source_prices_without_mutation():
assert result["rows"][0]["difference"] == -20
assert result["rows"][1]["sourcePrice"] is None
assert result["print"]["rows"] == result["rows"]
+
+
+def test_standard_apply_row_and_all_prices():
+ result = run(
+ "math",
+ """(()=>{
+ const p = EstNative.empty();
+ p.options.SourceRefresh = false;
+ p.options.AutoCalc = false;
+ p.tables.SilJukDat = [
+ { Code: 'A', Name: 'standard A', ValueIndex: 4, InputTC: 150, InputGC: 50, InputNC: 70, InputJC: 30, InputSC: 0 },
+ { Code: 'B', Name: 'standard B', ValueIndex: 4, InputTC: 300, InputSC: 300 },
+ { Code: 'D', Name: 'dup', ValueIndex: 4, InputTC: 50 },
+ { Code: 'D', Name: 'dup', ValueIndex: 4, InputTC: 60 },
+ ];
+ p.tables.EstMainDat = [
+ { Code: 'A', Name: 'item A', LinkIndex: 21, GN: 2, TU: 100, GU: 30, NU: 50, JU: 20, GC: 60, NC: 100, JC: 40, TC: 200, SCheck: 1 },
+ { Code: 'B', Name: 'item B', LinkIndex: 21, GN: 3, TU: 200, SU: 200, SC: 600, TC: 600 },
+ { Code: 'C', Name: 'item C', LinkIndex: 21, GN: 1, TU: 50, SU: 50, SC: 50, TC: 50 },
+ { Code: 'D', Name: 'item D', LinkIndex: 21, GN: 1, TU: 40, SU: 40, SC: 40, TC: 40 },
+ { Code: 'OTHER', Name: 'other row', LinkIndex: 5, GN: 5, TU: 10, GC: 50, TC: 50 },
+ ];
+ const initialUsage = EstStandard.usage(p);
+ const otherBefore = JSON.stringify(p.tables.EstMainDat[4]);
+
+ // 행 0(SCheck=1: 재료비 몰아주기 설정)에 단가 적용:
+ // SCheck=1이 보존되어 GU=150, NU=0, JU=0, SU=0 및 GN=2 -> GC=300, TC=300
+ EstStandard.applyRowPrice(p, 0);
+ const afterRow0 = {
+ row0_TU: p.tables.EstMainDat[0].TU,
+ row0_GU: p.tables.EstMainDat[0].GU,
+ row0_NU: p.tables.EstMainDat[0].NU,
+ row0_JU: p.tables.EstMainDat[0].JU,
+ row0_GC: p.tables.EstMainDat[0].GC,
+ row0_TC: p.tables.EstMainDat[0].TC,
+ row0_SCheck: p.tables.EstMainDat[0].SCheck,
+ otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
+ sourceRefresh: p.options.SourceRefresh,
+ autoCalc: p.options.AutoCalc,
+ };
+
+ // SCheck를 0으로 초기화한 뒤 다시 적용하면 원천의 재료/노무/경비 비목 분할(50/70/30)이 유지됨
+ p.tables.EstMainDat[0].SCheck = 0;
+ EstStandard.applyRowPrice(p, 0);
+ const afterRow0_split = {
+ row0_TU: p.tables.EstMainDat[0].TU,
+ row0_GU: p.tables.EstMainDat[0].GU,
+ row0_NU: p.tables.EstMainDat[0].NU,
+ row0_JU: p.tables.EstMainDat[0].JU,
+ row0_GC: p.tables.EstMainDat[0].GC,
+ row0_NC: p.tables.EstMainDat[0].NC,
+ row0_JC: p.tables.EstMainDat[0].JC,
+ row0_TC: p.tables.EstMainDat[0].TC,
+ };
+
+ let errMissing = false;
+ try { EstStandard.applyRowPrice(p, 2); } catch { errMissing = true; }
+ let errDup = false;
+ try { EstStandard.applyRowPrice(p, 3); } catch { errDup = true; }
+
+ // 남은 대상인 행 1(수량 3)에 대해 applyAllPrices 적용
+ const appliedCount = EstStandard.applyAllPrices(p);
+ const afterAll = {
+ row1_TU: p.tables.EstMainDat[1].TU,
+ row1_SU: p.tables.EstMainDat[1].SU,
+ row1_SC: p.tables.EstMainDat[1].SC,
+ row1_TC: p.tables.EstMainDat[1].TC,
+ row2_TU: p.tables.EstMainDat[2].TU,
+ row3_TU: p.tables.EstMainDat[3].TU,
+ otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
+ };
+ const finalUsage = EstStandard.usage(p);
+
+ // AutoCalc=true 전환 후 전체 재계산: 금액이 수량(GN)을 반영해 갱신됨
+ p.options.AutoCalc = true;
+ const calculated = EstNative.calculate(p);
+ const afterRecalc = {
+ row0_GC: calculated.tables.EstMainDat[0].GC,
+ row0_NC: calculated.tables.EstMainDat[0].NC,
+ row0_JC: calculated.tables.EstMainDat[0].JC,
+ row0_TC: calculated.tables.EstMainDat[0].TC,
+ row1_SC: calculated.tables.EstMainDat[1].SC,
+ row1_TC: calculated.tables.EstMainDat[1].TC,
+ };
+
+ return { initialUsage, afterRow0, afterRow0_split, errMissing, errDup, appliedCount, afterAll, finalUsage, afterRecalc };
+ })()""",
+ )
+ assert result["initialUsage"][0]["canApply"] is True
+ assert result["initialUsage"][1]["canApply"] is True
+ assert result["initialUsage"][2]["canApply"] is False
+ assert result["initialUsage"][3]["canApply"] is False
+ assert result["afterRow0"] == {
+ "row0_TU": 150,
+ "row0_GU": 150,
+ "row0_NU": 0,
+ "row0_JU": 0,
+ "row0_GC": 60,
+ "row0_TC": 200,
+ "row0_SCheck": 1,
+ "otherPreserved": True,
+ "sourceRefresh": False,
+ "autoCalc": False,
+ }
+ assert result["afterRow0_split"] == {
+ "row0_TU": 150,
+ "row0_GU": 50,
+ "row0_NU": 70,
+ "row0_JU": 30,
+ "row0_GC": 60,
+ "row0_NC": 100,
+ "row0_JC": 40,
+ "row0_TC": 200,
+ }
+ assert result["errMissing"] is True
+ assert result["errDup"] is True
+ assert result["appliedCount"] == 1
+ assert result["afterAll"] == {
+ "row1_TU": 300,
+ "row1_SU": 300,
+ "row1_SC": 600,
+ "row1_TC": 600,
+ "row2_TU": 50,
+ "row3_TU": 40,
+ "otherPreserved": True,
+ }
+ assert result["afterRecalc"] == {
+ "row0_GC": 100,
+ "row0_NC": 140,
+ "row0_JC": 60,
+ "row0_TC": 300,
+ "row1_SC": 900,
+ "row1_TC": 900,
+ }
+ assert result["finalUsage"][0]["canApply"] is False
+ assert result["finalUsage"][1]["canApply"] is False
+
+
+def test_edge_standard_usage_apply_price_and_undo_when_server_running():
+ from playwright.sync_api import sync_playwright
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(channel="msedge", headless=True)
+ page = browser.new_page()
+ try:
+ page.goto("http://100.65.141.15:5175", timeout=5000)
+ page.wait_for_function("() => Boolean(EstApp.state.project)")
+ except Exception:
+ browser.close()
+ import pytest
+
+ pytest.skip("EST 5175 server not reachable")
+
+ # 1. 테스트 프로젝트 데이터 세팅 (표준시장단가 및 내역 행 주입)
+ page.evaluate(
+ """(() => {
+ const p = EstNative.empty();
+ p.options.SourceRefresh = false;
+ p.tables.SilJukDat = [
+ { Code: 'S001', Name: '표준토공', Size: '일반', Unite: 'm3', ValueIndex: 4, InputTC: 15000 },
+ { Code: 'S002', Name: '표준포장', Size: 't=10cm', Unite: 'm2', ValueIndex: 4, InputTC: 32000 },
+ ];
+ p.tables.EstMainDat = [
+ { PkID: 101, Code: 'S001', Name: '표준토공', Size: '일반', Unite: 'm3', LinkIndex: 21, GN: 10, TU: 12000, SU: 12000, SC: 120000, TC: 120000 },
+ { PkID: 102, Code: 'S002', Name: '표준포장', Size: 't=10cm', Unite: 'm2', LinkIndex: 21, GN: 5, TU: 30000, SU: 30000, SC: 150000, TC: 150000 },
+ ];
+ EstApp.setProject(p);
+ EstApp.state.view = 'standard';
+ EstApp.render();
+ })()"""
+ )
+
+ # 2. 표준시장단가 사용 내역 화면 확인
+ assert page.locator("#view-title:has-text('표준시장단가 사용 내역')").is_visible()
+ btn_apply_all = page.locator("button[data-standard-apply-all]")
+ assert btn_apply_all.is_visible()
+ assert "기초단가 일괄 적용 (2건)" in btn_apply_all.text_content()
+
+ # 3. 개별 단가 적용 버튼 클릭 (1행 S001)
+ btn_row_apply = page.locator("button[data-standard-apply-row='0']")
+ assert btn_row_apply.is_visible()
+ btn_row_apply.click()
+
+ # 1행이 15,000으로 갱신되었는지 확인
+ tu_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU")
+ tc_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TC")
+ assert tu_row0 == 15000
+ assert tc_row0 == 150000
+
+ # 버튼 라벨이 (1건)으로 변경되었는지 확인
+ assert "기초단가 일괄 적용 (1건)" in btn_apply_all.text_content()
+
+ # 4. 일괄 적용 버튼 클릭 (남은 1건 S002 적용)
+ btn_apply_all.click()
+ tu_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU")
+ tc_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TC")
+ assert tu_row1 == 32000
+ assert tc_row1 == 160000
+
+ # 이제 일괄 적용 버튼이 disabled 상태인지 확인
+ assert btn_apply_all.is_disabled()
+
+ # 5. Undo(실행 취소) 검증
+ page.locator(".toolbar button[data-action='undo']").click()
+ # 1단계 취소 시 2행은 원래대로 30,000 복귀
+ assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 30000
+
+ page.locator(".toolbar button[data-action='undo']").click()
+ # 2단계 취소 시 1행도 원래대로 12,000 복귀
+ assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 12000
+
+ browser.close()
+
+
+def test_standard_manual_mode_autocalc_false_and_recalc_switch():
+ result = run(
+ "math",
+ """(()=>{
+ const p = EstNative.empty();
+ p.options.SourceRefresh = false;
+ p.options.AutoCalc = false; // 수동계산 모드
+ p.tables.SilJukDat = [
+ { Code: 'S10', Name: '표준단가 10', ValueIndex: 4, InputTC: 20000, InputSC: 20000 },
+ ];
+ p.tables.EstMainDat = [
+ { Level: 1, Code: '', Name: '공종1', TC: 50000 },
+ { Code: 'S10', Name: '품목 10', LinkIndex: 21, GN: 5, TU: 10000, SU: 10000, SC: 50000, TC: 50000 },
+ ];
+
+ // 1. 수동계산 상태에서 applyRowPrice 실행
+ // 해당 행(인덱스 1)의 단가(TU, SU)는 20,000으로 갱신되나,
+ // AutoCalc=false이므로 자체 금액(SC=50000, TC=50000) 및 상위 공종 행(Level 1, TC=50000) 금액이 모두 보존됨
+ EstStandard.applyRowPrice(p, 1);
+ const afterManual = {
+ row1_TU: p.tables.EstMainDat[1].TU,
+ row1_SC: p.tables.EstMainDat[1].SC,
+ row1_TC: p.tables.EstMainDat[1].TC,
+ row0_Level1_TC: p.tables.EstMainDat[0].TC,
+ };
+
+ // 2. 전체 재계산(AutoCalc=true 전환 및 EstNative.calculate 실행)
+ p.options.AutoCalc = true;
+ const calculated = EstNative.calculate(p);
+ const afterRecalc = {
+ row1_TU: calculated.tables.EstMainDat[1].TU,
+ row1_SC: calculated.tables.EstMainDat[1].SC,
+ row1_TC: calculated.tables.EstMainDat[1].TC,
+ row0_Level1_TC: calculated.tables.EstMainDat[0].TC,
+ };
+
+ return { afterManual, afterRecalc };
+ })()""",
+ )
+ assert result["afterManual"]["row1_TU"] == 20000
+ assert result["afterManual"]["row1_SC"] == 50000
+ assert result["afterManual"]["row1_TC"] == 50000
+ # 수동 상태에서는 자체 금액 및 상위 공종(Level 1) 금액 모두 보존됨
+ assert result["afterManual"]["row0_Level1_TC"] == 50000
+
+ # 전체 재계산 전환 후 수량(GN=5)을 반영해 자체 금액 및 상위 공종 정상 합산됨
+ assert result["afterRecalc"]["row1_TU"] == 20000
+ assert result["afterRecalc"]["row1_SC"] == 100000
+ assert result["afterRecalc"]["row1_TC"] == 100000
+ assert result["afterRecalc"]["row0_Level1_TC"] == 100000
diff --git a/tools/est-test/.gitignore b/tools/est-test/.gitignore
index 82f0c3ac6..55fc6a65f 100644
--- a/tools/est-test/.gitignore
+++ b/tools/est-test/.gitignore
@@ -1 +1,3 @@
/data/
+monitor.log
+monitor.pid
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 6d36c14bb..f0f04dd49 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -1,3 +1,357 @@
+- [x] 산출근거 주석 지정/해제(`popMainSetStr`) 및 블럭 삭제(`popMainBlockDel`) 구현
+ - 자체 검증:
+ 1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c):
+ - REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 `popMainSetStr` / `mnuSetGuSuc` ("주석으로 지정", Ctrl+J, 0xf08b1c) 스펙 근거 기반 구현.
+ - 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 `Index`(0)로 복원.
+ - 주석 지정 시 `_origIndex: curIndex`를 명시적으로 보존하고, `Index === -1`로 전환. 주석 해제 시 `_origIndex` 또는 0으로 안전하게 복원하고 임시 속성 제거.
+ - 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용.
+ - 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장.
+ - `native-text.js`에서 `Index === -1`인 행은 소계(`LinkIndex: 61`) 계산 시 `if (num(previous.Index) !== -1)` 조건에 따라 자동으로 합산에서 제외되므로 산출식 수치 무결성 완벽 보장.
+ 2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc):
+ - REA `TFRMCOST`/`TFRMHEAD`/`TFRMMACH`/`TFRMEST` 전역의 `popMainBlockDel` ("블럭삭제", Ctrl+Del, 0xf08adc) 스펙 근거 기반 구현.
+ - `EstBlockActions.deleteBlockRows(state)`: 선택된 범위/블럭의 행들을 일괄 삭제.
+ - 호표 제목 행(`Index > 0`)이 포함된 경우 호표 전체 행을 일괄 삭제 범위로 확장.
+ - 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단.
+ - 삭제 완료 후 선택 해제 및 블럭 시작/끝 상태 초기화.
+ 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"`) 등록.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_row_actions.py` 7개 전수 통과:
+ - `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex` 보존, 0으로의 복원 및 임시 속성 제거, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 참조 중인 호표 삭제 차단 검증 완료.
+ - `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.
+
+- [x] 단가산출 산출근거 비목 지정/해제(`popMainSetNC/GC/JC/TC/BimogFree`) 구현 및 순공사비·경비성 안전 가드 보강
+ - 자체 검증:
+ 1) 순공사비·경비성 토글 안전 가드 및 원자적 일괄 검증 보강:
+ - **명시적 0/1/2 이력 보존 및 복원**: `toggleJcBimog`에서 경비성(3) 전환 시 원래 `SCheck`(0:비목별, 1:재료, 2:노무)을 `_prevSCheck`에 명시적으로 보존. 해제 시 해당 이력 또는 원본 `SCheck_`가 존재하는 경우에만 정확한 이전 값으로 복원.
+ - **이력 없는 경비성 3 임의 변환 차단**: `_prevSCheck` 및 `SCheck_`가 없는 기존 3 행이나 원래 유형을 알 수 없는 행의 임의 0 변환을 안전하게 차단.
+ - **다중 선택 원자성(Atomic) 보장**: 복수 행 선택 토글 시 사전 검증 계획(`plans`)을 수립하여 한 행이라도 거부 대상이 포함되면 전체 변경을 중단하고 앞선 행도 변경되지 않도록 원자적 일괄 적용 보장.
+ - **JSON 저장 및 재열기 왕복 보증**: JSON 직렬화/역직렬화 후에도 `_prevSCheck` 및 `SCheck_`가 보존되어 당초 SCheck(0/1/2)로 100% 온전하게 복원됨을 검증.
+ - 원본부터 순공사비(54)인 행의 해제 시도나 대체 시도 역시 `_origLinkIndex`나 `LinkIndex_` 등 원래 유효 유형이 확인되지 않으면 오류로 차단하고 기존 행 속성 보존.
+ 2) 단가산출 산출근거 비목 지정/해제 (`popMainSetNC`, `popMainSetGC`, `popMainSetJC`, `popMainSetTC`, `popMainSetBimogFree`):
+ - REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 비목 지정(`0xf0c0f0`, `0x10d815c`) 스펙 근거 기반 구현.
+ - 단가산출(`CostMainDat`) 화면에서 산출근거 행의 `OCheck` 및 `SCheck`를 노무비(-1), 재료비(-2), 경비(-3), 소계(-5)로 지정하거나 일반 산출식(0)으로 해제.
+ - UI 연동: 편집(E) 메뉴에 `산출근거 비목 지정/해제`(`data-action="set-cost-line-kind"`) 등록 및 `EstActions.form` 대화상자 지원.
+ 3) 700줄 제한 엄수:
+ - `native-business-actions.js`: 643줄, `native-extra.js`: 696줄, `index.html`: 211줄, `resources/tester/test_est_row_actions.py`: 422줄.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_row_actions.py` 5개 전수 통과:
+ - `test_toggle_safety_guards_prevent_arbitrary_zero_conversion_without_history`: 복원 정보 없는 원본 54 행 해제/대체 차단 및 원본 경비성(3) 임의 0 변환 차단 검증.
+ - `test_jc_bimog_multi_selection_atomic_and_json_roundtrip`: 다중 선택 시 거부 행 발생 시 앞선 행 변경 롤백(원자성), SCheck=0 지정/해제 및 JSON 재열기 왕복 복원 검증.
+ - `test_set_cost_line_kind_action`: 산출근거 행의 노무비(-1) 지정, 재료비(-2)의 일반 해제(0) 및 타 화면 안전 거부 검증.
+ - `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 27 passed in 19.26s.
+
+- [x] 데이터 사용위치 검색(`popUsesSerch` / `TFRMUSESDATASERCH`) 구현
+ - 자체 검증:
+ 1) REA 스펙 근거:
+ - REA `TfrmUsesDataSerch` (0x10d819c) 및 `popUsesSerch`(내역서, 일위대가, 단가산출, 기계경비 전역 팝업 메뉴) 기반 구현.
+ - 임의의 품목 행(자재, 노임, 견적, 표준, 일위대가, 단가산출, 기계경비, 내역서)에서 `데이터 사용위치 찾기` 실행 지원.
+ 2) `EstExtra.findDataUsages(project, target)` 검색 엔진 구현:
+ - 대상 항목의 `Code`(우선) 또는 정규화된 `Name`+`Size`를 기준으로 내역서(`EstMainDat`), 일위대가(`HeadMainDat`), 단가산출(`CostMainDat`), 기계경비(`MachMainDat`)의 참조 행을 전수 검색.
+ - 대상 항목 자기 자신의 대표 행은 검색 결과에서 안전하게 제외.
+ - 일위/단산/기계의 경우 상위 공종 코드(`curGroup`)를 함께 추적하여 위치 이동 시 정확한 묶음(`state.group`)으로 진입 지원.
+ 3) UI 연동 및 대화상자 이동 기능:
+ - `index.html` 상단 툴바에 `사용위치` 버튼(`data-action="uses-data-search"`) 및 편집 메뉴에 `데이터 사용위치 찾기` 추가.
+ - `EstExtra.showUsesSearchDialog(targetRow)`를 통해 검색된 위치 목록(구분, 코드, 품명/산출근거, 규격, 단위, 수량, 금액) 표시.
+ - `이동`(`goto-use-location`) 클릭 시 대화상자를 닫고 대상 테이블(`navigate`), 대상 묶음 그룹(`state.group`), 페이지(`state.page`), 선택 행(`state.selection`)으로 즉시 포커스 이동.
+ 4) 700줄 제한 엄수:
+ - `native-extra.js`: 554줄, `native-business-actions.js`: 685줄, `native-views.js`: 140줄, `index.html`: 210줄.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_row_actions.py` 통과 (`test_uses_data_search_finds_usages_across_tables`: 자재 M001의 일위/단산/내역 4건 검출 및 그룹 보존, 일위 U001의 내역 1건 검출, 이름 일치 항목 검출, 미사용 항목 0건 반환 검증 완료).
+ - `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py` 6개 전수 통과 (0 실패).
+
+- [x] 일위대가 공종 할증계산(`TFRMHALJUNGBIUL`) 및 자재단가 부가세계산(`TFRMCONVERTTOGANGUB`) 구현
+ - 자체 검증:
+ 1) 일위대가 공종 할증계산 (`TFRMHALJUNGBIUL`):
+ - REA `TFRMHEAD`의 `DoHalJungClick` (0xf5b7fc) 및 `TFRMHALJUNGBIUL`(명칭, 불러올비목, 적용율, 적용비목, 0xdb4b14) 근거 기반 구현.
+ - [native-haljung.js](file:///D:/aislo-wt/CODEX/tools/est-test/native-haljung.js)의 `EstHalJung.apply(project, tableKey, groupCode, { name, size, rate, sourceKind, targetKind })`:
+ - 불러올비목(규격): 0=비목별, 1=재료비의, 2=노무비의, 3=경비의, 4=합계의 (`OCheck`).
+ - 적용비목: 0=비목별, 1=재료비에, 2=노무비에, 3=경비에 (`SCheck`).
+ - **비목 매핑 정확성 보장**: `sourceKind`가 특정 비목(1=재료비, 2=노무비, 3=경비)이고 `targetKind = 0`(비목별)인 경우, 기본 경비(`JC`)로 왜곡되지 않고 해당 원천 비목(`SCheck = sInfo.ocheck`)으로 정확히 가산되도록 보정.
+ - 묶음 내 소계(`LinkIndex = 51, 68`) 직전 위치에 `LinkIndex = 58`(비율) 행을 안전하게 자동 삽입하고 즉시 재계산 반영.
+ - 0 또는 잘못된 할증율 및 존재하지 않는 공종/화면 요청 안전 거부.
+ - UI 연동: `native-views.js`에서 일위대가 화면 활성화 시 `할증계산` 버튼(`data-action="haljung-calc"`) 노출 및 대화상자 연동.
+ 2) 자재단가 부가세계산 (`TFRMCONVERTTOGANGUB`):
+ - REA `TFRMSTUFFC`의 팝업 `popChangeCost` 및 `TFRMCONVERTTOGANGUB`(부가세포함/제외, 단단위계산, 0xf616f0) 근거 기반 구현.
+ - [native-vat.js](file:///D:/aislo-wt/CODEX/tools/est-test/native-vat.js)의 `EstVat.apply(project, indices, { calcMethod, diget, digetMethod })`:
+ - 계산방법: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1).
+ - 단단위계산: 원단위 또는 지정 자리수(1=십원, 2=백원..) 이하에 대해 사사오입/절하/절상 단단위 처리.
+ - 근거 불충분한 `row.Kind` 변경 기능 비활성화: REA 스펙상 `StuffcDat`에 `Kind` 필드가 소비되지 않으므로 혼선 방지를 위해 자재구분 변경 옵션 제거 및 안전 처리.
+ - 선택 행 또는 전체 자재단가(`Cost1~6`, `InputTC`, `TC`) 일괄 변환 및 Undo 지원. `ValueIndex`별(최저/평균/최고/선택/직접) 단가 및 재계산 후 원본 단가 보존 검증 완료.
+ - UI 연동: `native-views.js`에서 자재단가 화면(`StuffcDat`) 활성화 시 `부가세계산` 버튼(`data-action="vat-calc"`) 노출 및 대화상자 연동.
+ 3) 700줄 제한 엄수:
+ - `native-haljung.js`: 91줄, `native-vat.js`: 58줄, `native-business-actions.js`: 593줄, `native-views.js`: 140줄.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_business.py` 18개 통과 (`test_haljung_calculation_apply_to_head_and_cost_groups`, `test_vat_calculation_exclude_include_and_unit_rounding_for_materials` 검증 보강 통과).
+ - 핵심 EST 테스트 45개 전수 통과 (0 실패).
+
+- [x] 일위대가 공종 단위환산 계산(`TFRMUNITECALC`) 및 낙찰률 당초단가(100%) 복원(`TFRMBIMOGBIUL`/`TFRMNACCHALAPPLY`) 구현
+ - 자체 검증:
+ 1) 일위대가 공종 단위환산 계산 (`TFRMUNITECALC`):
+ - REA `TFRMHEAD`의 팝업/도구 버튼 `popUniteCalc` (`단위환산 계산`, 0xf59bd8) 및 `TFRMUNITECALC`(명칭, 규격, 수량, 단위) 근거 기반 구현.
+ - `native-business.js`에 `convertGroupUnit(project, groupCode, { name, size, factor, unit })` 함수 구현:
+ - 선택한 일위대가 공종(묶음)의 제목 행(`Index > 0`)에 명칭(`Name`), 규격(`Size`), 단위(`Unite`) 적용.
+ - 특수행 제외: 비율 행(`LinkIndex=58, 62`), 낙찰률 행(`LinkIndex=52`), 소계 행(`LinkIndex=51, 68`) 및 계산 안 함/고정금액 행(`CalcIndex=1, 3`)은 수량(`GN`) 배수 변경에서 안전하게 제외하여 백분율 왜곡 및 중복 팽창 방지.
+ - 일반 비목 행의 수량(`GN`)에만 환산 계수(`factor`)를 부동소수점 곱으로 적용.
+ - `AutoCalc: false` 등 프로젝트 옵션 보존 및 0 또는 유효하지 않은 계수 거부.
+ - UI 연동: `index.html` 및 `native-views.js`에서 일위대가 화면(`HeadMainDat`) 활성화 시 `단위환산` 버튼(`data-action="unit-calc"`) 자동 노출.
+ - 대화상자(`EstActions.form`)를 통해 사용자 입력 접수 후 `EstApp.change()`로 트랜잭션 및 Undo 지원.
+ 2) 낙찰률 당초단가(100%) 복원 (`TFRMBIMOGBIUL`, `TFRMNACCHALAPPLY`):
+ - REA `btnUndo` (Hint: "당초단가(100%)로 복원합니다.") 근거 구현.
+ - `native-business.js`의 `bid(project, rates)`:
+ - 연속 낙찰률 적용 시에도 최초 단가와 최초 옵션을 잃지 않도록 `row.PreBidRates`가 이미 존재하면 덮어쓰지 않고 최초 단가 유지.
+ - `O${field}`도 최초 단가(`PreBidRates[field]`)를 기준으로 낙찰률을 계산하여 다회차 적용 시의 누적 왜곡 방지.
+ - 원본 프로젝트의 `SourceRefresh` 설정값은 `next.options._preBidSourceRefresh ??= project.options.SourceRefresh`로 최초 1회만 보존.
+ - `restoreBid(project)`:
+ - 원래의 `SourceRefresh`가 `true`이더라도 재계산 도중 원천 단가로 복원 단가가 다시 덮이지 않도록, `SourceRefresh: false` 상태로 복원 재계산 수행 후 마지막에 원래 사용자의 `SourceRefresh` 옵션 상태를 안전하게 복원.
+ - `PreBidRates` 및 생성된 낙찰 단가를 복원하고, 원본 파일에 이미 존재하던 `O*` 필드(`OTU/ONU/OGU/OJU/OSU`)는 임의 삭제하지 않고 영구 보존.
+ - 저장·재열기(JSON 왕복), 수동/자동 계산 모드, 원천 단가와 다른 초기 단가 상태에서도 최초 단가 100% 온전 복원 확인.
+ - 설계변경 화면 도구 모음에 `당초단가(100%) 복원`(`restore-bid`) 버튼 연동.
+ 3) 700줄 제한 엄수:
+ - `native-business.js`: 692줄, `native-business-actions.js`: 505줄, `native-actions.js`: 699줄, `native-views.js`: 136줄.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_business.py` 16개 통과 (`test_unit_calc_group_conversion_preserves_percentages_subtotals_fixed_and_other_groups` 포함).
+ - `pytest resources/tester/test_est_change_workflow.py` 15개 통과 (`test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore` 추가 통과).
+ - 핵심 EST 테스트 43개 전수 통과 (0 실패).
+
+- [x] 자료 오류 검색(issues) 및 불일치 데이터 확인: REA `TFRMNAMESIZECHECK`(명칭·규격·단위 불일치) 및 `TFRMPERSENTCHECK`(단위 백분율(%) 불일치 데이터 검색) 검출 구현
+ - 자체 검증:
+ 1) REA `TFRMNAMESIZECHECK`(명칭 불일치 및 중복 데이타 확인) 근거 기반 검출 및 동기화 구현:
+ - `native-issues.js`의 `scan()`에서 기존 품명(`Name`), 규격(`Size`) 비교에 더해 **단위(`Unite`)** 불일치(`연결 단위 차이`) 검출 로직 추가.
+ - 원천 데이터(자재 `StuffcDat`, 노임 `LaborcDat`, 표준 `SilJukDat`, 견적 `EstiMateDat`, 일위 `HeadMainDat`, 단가 `CostMainDat`, 기계 `MachMainDat`)의 실제 명칭·규격·단위(`sourceName`, `sourceSize`, `sourceUnite`)를 이슈 항목에 보존.
+ - UI 렌더링에 `원천 실제 명칭` 표시 및 `원천명칭 반영`/`원천단위 반영` 개별 버튼(`data-issue-sync-row`), 상단 도구 모음에 `불일치 원천 명칭/규격 일괄 반영` 버튼(`data-issue-sync-all`) 제공.
+ 2) REA `TFRMPERSENTCHECK`(단위 백분율(%) 불일치 데이터 검색) 근거 기반 검출 추가:
+ - REA `TFRMPERSENTCHECK` ("위의 데이타는 백분율(%)로 계산되어야 할 항목들입니다(할증, 낙찰율, 제잡비, 조달수수료 등). 그러나 단위가 %가 아니기 때문에 백분율로 계산하지 않습니다. 확인하세요.") 근거 구현.
+ - 일위대가(`HeadMainDat`), 내역서(`EstMainDat`), 기계경비(`MachMainDat`), 단가산출(`CostMainDat`)의 비목 상세 행 중 백분율 연산 특수행(`LinkIndex = 52, 57, 58, 62, 67`)인데 단위(`Unite`)가 `%` 또는 `%`가 아닌 항목을 검출(`단위 백분율(%) 불일치`).
+ 3) 불일치 필드별 안전 반영 및 단위 왜곡·규격 삭제 방지:
+ - 단위(`Unite`)는 수량 및 단가 산식과 직결되므로 일괄 반영(`sync-all`) 대상에서 완전히 제외하고, 개별 행에서 명시적 승인(`원천단위 반영`) 시에만 처리하도록 분리.
+ - 규격(`Size`)은 원천 규격이 비어 있지 않은 유효 문자열일 때만 반영하여, 빈 원천 규격으로 기존 내역 규격을 덮어써 지우는 현상 방지.
+ - 품명(`Name`) 불일치는 품명만 원천 값으로 안전하게 동기화하고 기존 규격·단위를 보존.
+ - 일괄 반영 범위는 전체 스캔 결과가 아니라 화면의 현재 필터 조건(`tableFilter`, `typeFilter`)에 해당하는 `currentFiltered` 항목만 대상으로 한정.
+ - 반영 시 `EstApp.change()` 트랜잭션을 통해 실행 취소(Undo) 지원 및 데이터 무결성 보존.
+ 4) 700줄 제한 준수:
+ - `native-issues.js` 372줄로 엄수.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_issues.py` 12개 통과 (`test_data_issues_detect_percentage_unit_mismatch` 추가).
+ - 브라우저 Undo 복원 및 원천 단위/규격 안전 처리 검증 완료.
+
+- [x] 인쇄 서식 옵션 검토 및 안전 조치: `PrintApplyField`(단가 0원 누락 위험 방지) 및 `PrintSumBottom`(공종/소계 중복 합산 및 단가 합산 왜곡 차단) 안전 처리
+ - 자체 검증:
+ 1) 결함 분석 및 안전 조치:
+ - `PrintSumBottom`: 표시되는 모든 행을 단순 누적할 경우 공종/소계와 상세 항목이 중복 합산되어 실제 공사금액의 2배 이상으로 뻥튀기되고, 단위가 다른 수량 및 단가(TU)를 합산하는 논리적 왜곡이 발생함.
+ - `PrintApplyField`: REA의 적용 단가 필드 표시 의도를 '단가 0인 행 필터링'으로 오해하여 삭제할 경우, 공종 제목 및 정상적인 0원 항목·음수 정산 항목이 누락되는 중대 위험이 발생함.
+ - REA 원문의 정확한 세부 집계/필터 알고리즘 근거가 불충분하므로, 서식 데이터의 왜곡 및 행 삭제를 즉시 차단하고 미지원 처리.
+ 2) 코드 정리 및 데이터 무결성 보존:
+ - `native-forms.js`: `PrintApplyField`에 의한 행 삭제 필터 및 `PrintSumBottom`에 의한 단순 `
` 합산 렌더링을 완전히 제거하고, 모든 공종(Level>0), 소계, 0원 항목, 음수 변경분 행이 원본 그대로 보존되도록 복원.
+ - `native-report.js`: 불안정한 체크박스 UI 노출을 비활성화하고, 공람용 옵션 등 검증된 서식 기능만 안정적으로 제공.
+ - 바이너리 레코드 오프셋(`import-print.cjs` / `export-print.cjs`)은 데이터 무결성을 위해 안전하게 0/1 값을 읽고 쓸 수 있도록 유지하되 잘못된 화면 처리를 차단.
+ 3) 700줄 제한 준수:
+ - `native-report.js` 352줄, `native-forms.js` 445줄, `export-print.cjs` 67줄, `import-print.cjs` 75줄로 엄수.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_documents.py` 16개 통과 (`test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering` 추가).
+ - 전체 연계 회귀 시험 54개 전수 통과 (0 실패).
+
+- [x] 공람용 서식(내역서 117, 내역서총괄표 118, 일위대가 115, 일위대가총괄표 116) 인쇄 옵션 및 공람방법(수량만/단가만/수량·단가) 렌더링 구현
+ - 자체 검증:
+ 1) REA `TFRMESTPRINTPRIVATE` 및 `DatPrintOption.opt`의 `PrintGongMethod` 설정 연동:
+ - 인쇄 미리보기(`native-report.js`) 서식 선택 시 공람용 서식(115, 116, 117, 118)에 대해 `공람용 방법` 셀렉트(`PrintGongMethod`: 0=수량만, 1=단가만, 2=수량·단가) 노출 및 설정 동기화.
+ 2) `native-forms.js` 열 정의 및 마스킹 로직 구현:
+ - 공람용 내역서(`117`) 및 일위대가(`115`):
+ - `PrintGongMethod = 0`(기본값, 수량만): 단가 및 금액 열(TU, TC, NC, GC, JC, SC / NU, GU, JU, SU, TC)을 숨기고 공통정보 및 수량(GN)만 인쇄.
+ - `PrintGongMethod = 1`(단가만): 수량(GN)을 숨기고 단가 및 금액 열만 인쇄.
+ - `PrintGongMethod = 2`(수량·단가): 수량과 단가/금액을 모두 인쇄.
+ - 공람용 총괄표(`116`, `118`): `PrintGongMethod = 0` 시 금액 비목을 제외하고 공통 레벨·공종 열만 안전하게 출력.
+ - 일반 표준 서식(`1`, `2`, `103`, `106`)은 변경 없이 기존 전체 열 출력 및 데이터 무결성 보존.
+ 3) 700줄 제한 준수:
+ - `native-report.js` 342줄, `native-forms.js` 439줄로 엄수.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_documents.py` 15개 통과 (`test_public_view_forms_print_gong_method_options` 추가).
+ - 전체 연계 회귀 시험 52개 전수 통과 (0 실패).
+
+- [x] 계약심사(review) 화면 변경 행 필터링(전체/변경/사유작성/사유미작성) 및 심사 사유 일괄 적용 구현
+ - 자체 검증:
+ 1) `native-review.js` 보기 필터(`review-filter`) 및 사유 일괄 적용(`review-batch-reason`) 신규 구현:
+ - 계약심사 테이블 상단에 항목 필터 셀렉트(`전체 항목`, `변경 항목만`, `사유 작성 항목`, `사유 미작성 변경항목`) 추가.
+ - 대규모 내역서에서 수량/단가 변경이 발생한 항목이나 사유 작성이 누락된 변경 항목만 즉시 선별하여 검토할 수 있도록 개선.
+ - 개별 행 선택 체크박스(`reviewSelection`) 및 일괄 사유 적용(`data-review-batch-reason`) 지원: 선택된 행들에 대해 공통 사유를 한 번에 입력·반영하며, 선택이 없는 경우 현재 화면에 표시된 필터링 대상 행 전부에 대해 일괄 반영.
+ - 심사 입력 초기화(`review-clear`) 시 선택 세트도 함께 정리되도록 상태 무결성 보존.
+ - 입력된 사유는 `contractReview.edits[key].Reason`으로 안전하게 저장되며, 실행 취소(Undo) 및 심사결과요약서(서식 171) 인쇄/엑셀 내보내기 연동 보존.
+ 2) 모델 및 계산 무결성 검증:
+ - 기존 심사 수량/단가 계산기(`EstReview.calculate`)와 사본 열기(`EstReview.copy`) 로직은 변경 없이 100% 동일하게 유지.
+ 3) 700줄 제한 준수:
+ - `native-review.js` 258줄로 700줄 이하 엄수.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_review_workflow.py` 8개 통과 (`test_review_view_filtering_and_batch_reason_application` 추가).
+ - 전체 연계 회귀 시험 50개 전수 통과 (0 실패).
+
+- [x] 자료 오류 검색(issues)의 원가계산서(Wonga) 산출식(VSusic) 참조 유효성 검사 및 실제 계산기(calculate) 의존성/값/오류 로직 단일화 구현
+ - 자체 검증:
+ 1) `native-wonga.js`의 계산 엔진 로직 단일화(`evaluateRows` 공통화):
+ - `scan`과 `calculate`가 별도의 파서/DFS/참조 로직을 갖지 않고, 동일한 `evaluateRows` 함수를 공유하여 전방 참조, 의존성 순서, 실제 재귀 계산, 범위(CalcIndex 2, 3 제외 행 필터링), 순환 참조 감지(`active` Set) 로직을 100% 동일하게 실행.
+ - 저장된 `Cost=0`이어도 수식이 있는 선행 참조 행(예: `B=A*2`)의 실제 재귀 계산 결과를 평가에 사용하여, `A=1/B`와 같은 유효한 나눗셈이 오래된 0값으로 인해 0 나눗셈으로 오탐되는 결함 원천 해결.
+ - 오류 발생 시(`onError` 콜백) `scan`은 해당 원인 행을 등록하고 기존 `Cost`로 대체하여 후속 행으로의 연쇄 전파를 방지.
+ 2) `native-issues.js`의 실제 프로젝트 기초금액(`direct`) 전달:
+ - `issues.scan(project)` 호출 시 `project.calculation.direct`로부터 실제 `NC`, `GC`, `JC`, `SC`, `TC`, `노무비`, `재료비`, `경비`, `실적`, `직접공사비`를 추출해 `EstWonga.scan`의 `bases` 인자로 전달하여 기초 비목 0 판정 및 오탐 방지.
+ 3) 700줄 제한 준수:
+ - `native-wonga.js` 163줄, `native-issues.js` 274줄로 700줄 이하 엄수.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_issues.py` 9개 통과 (`test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles` 추가).
+ - 전체 연계 회귀 시험 51개 전수 통과 (0 실패).
+
+- [x] 자료 오류 검색(issues)의 단가산출(CostMainDat) 상세 행 연결 자료(자재·노임·기계 등) 부재 및 중복 검출 확장 구현
+ - 자체 검증:
+ 1) `native-issues.js`의 `scan()` 테이블 검사 대상 확장:
+ - 기존 검사 대상(`EstMainDat`, `HeadMainDat`, `MachMainDat`)에 `CostMainDat`를 추가하여, 단가산출 호표 상세 행에서 자재(`LinkIndex: 5`), 노임(`LinkIndex: 6`), 기계(`LinkIndex: 105`), 표준(`LinkIndex: 21`), 견적(`LinkIndex: 101`), 일위(`LinkIndex: 103`), 산근(`LinkIndex: 104`) 등 원천 자료를 참조하는 상세 행(`Index === 0 && Code`)에 대해 연결 자료 존재 여부(`연결 자료 없음`) 및 원천 코드 중복(`연결 코드 중복`)을 전수 검사하도록 개선.
+ - 오류 항목 클릭 시 `data-issue-table="CostMainDat"`로 해당 호표(`group`) 및 상세 행으로 정확히 이동하고 선택 행을 하이라이트 표시하도록 연동 보존.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_issues.py` 5개 통과 (`test_data_issues_detect_cost_main_dat_missing_and_duplicate_links` 추가).
+ - 전체 연계 회귀 시험 76개 전수 통과 (0 실패).
+
+- [x] ESTX 내보내기 시 웹 전용 작업 데이터(예정공정표 schedule, 추가 서식 문서 documents) 누락 방지 및 기본 빈 상태 대조 차단 구현
+ - 자체 검증:
+ 1) `export-estx.cjs` 및 `import-estx.cjs` 검토 및 수정:
+ - `import-estx.cjs`에서 원본 ESTX 로드 시 기본 빈 공정표(`schedule: []`)를 명시적으로 초기화.
+ - `export-estx.cjs`에서 원본 가져오기 기본 상태(`schedule: []`)와 대조하여, 유효 필드 유무와 무관하게 `project.schedule?.length > 0` (빈 문자열·0값만 가진 행, 임의 필드 행 포함) 존재 시 공정 데이터 누락 방지를 위해 JSON 저장 안내와 함께 차단(`웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`).
+ - `project.documents`의 공백 문자열(" ") 역시 사용자가 입력한 내용으로 취급하여 `text.length > 0` 검사로 누락 없이 JSON 저장 안내와 함께 차단(`웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`).
+ - 원본 ESTX에서 불러온 기본 빈 상태(빈 schedule `[]`, 빈 documents `{}` 또는 미입력)에서는 정상적으로 ESTX 바이너리 패치가 통과되어 기존 기능 유지.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_attachments.py` 6개 통과 (일반 행, 빈 문자열/0값 행, 임의 필드 행, documents 공백 문자열 저장 차단 단위 검증).
+ - `pytest resources/tester/test_est_native_export.py` 15개 통과 (`test_estx_export_schedule_and_documents_safety_guards` 실제 파일 기반 정상 패치 및 빈 행/0값/공백 문서 차단 실측 검증).
+ - 전체 연계 회귀 시험 92개 통과 (0 실패).
+
+- [x] 예정공정표(schedule) 공정 항목 상/하 순서 이동(`schedule-move`) 및 전체 비우기(`schedule-clear`) 조작 구현
+ - 자체 검증:
+ 1) `native-schedule.js`에 공정 행 상/하 이동(`moveRow`) 및 전체 비우기(`clearSchedule`) 구현:
+ - 연속된 공정 항목 배열(`project.schedule`)에 대해 인접 행과 위치를 맞교환하는 `moveRow(project, index, direction)` 지원.
+ - 상단 경계(`index === 0, direction === -1`) 및 하단 경계(`index === length - 1, direction === 1`) 도달 시 비활성화(`disabled`) 및 배열 범위 침범 방지.
+ - 공정표 상단 툴바에 `공정 전체 비우기`(`data-action="schedule-clear"`) 버튼을 추가하고 확인 대화상자(`confirm`) 후 안전하게 초기화 지원(목록이 비어있으면 `disabled`).
+ - 모든 조작은 `EstApp.change(..., false)`를 통해 수행되어 실행 취소(Undo)로 변경 전 공정 순서 및 데이터 완전 복원 가능.
+ 2) 모델 검증 및 계산 무결성:
+ - 이동 후에도 `EstSchedule.calculate(project)`를 통해 `left`, `width`, `weight`, 가중평균 `progress`, 전체 시작일/종료일이 정상 재계산됨을 확인.
+ - Excel 내보내기/불러오기(`EstExchange`) 및 JSON 작업본 저장 시 순서 유지 확인.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_documents.py` 14개 통과 (`test_schedule_move_row_and_clear_operations` 추가).
+ - 전체 연계 회귀 시험 85개 전수 통과 (0 실패).
+
+- [x] 동원인원·자재·장비 집계(resources) 화면 비목별(노임·자재·기계·표준·견적) 필터링 및 공종별 전개 조회 구현
+ - 자체 검증:
+ 1) `native-extra.js`의 `view === "resources"` 렌더링 확장:
+ - 전체 집계뿐만 아니라 비목 구분(노임 `kind: 6`, 자재 `kind: 5`, 기계 `kind: 105`, 표준 `kind: 21`, 견적 `kind: 101`, 전체 `all`) 필터 셀렉트(`id="resource-kind-filter"`) 추가.
+ - 내역 공종별 세부 소요 전개 체크박스(`id="resource-by-work"`)를 추가하여, 체크 시 `EstResources.calculate(project, true)`를 호출해 공종코드(`WorkCode`) 및 공종명(`WorkName`) 열을 포함한 세부 전개 테이블 표시.
+ - 필터링 결과 건수 표시(`(총 N건)`) 및 오류 메시지 보존.
+ 2) `native-business-actions.js` 이벤트 연동:
+ - `resource-kind-filter` change 시 `state.resourceKind`를 갱신하고 `EstApp.render()` 호출.
+ - `resource-by-work` change 시 `state.resourceByWork`를 갱신하고 `EstApp.render()` 호출.
+ 3) 원천 서식(`print-catalog.json`) 및 `EstForms.data` 일치 확인:
+ - `112`(동원인원 공종별), `113`(소요자재 공종별), `114`(동원장비 공종별), `163`(동원실적 공종별), `164`(동원견적 공종별) 서식과 `13`(동원인원), `110`(소요자재), `111`(동원장비), `161`(동원실적), `162`(동원견적) 총괄 서식 간의 데이터 일관성 및 금액/수량 무결성 보존.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_documents.py` 13개 통과 (`test_resource_view_filtering_and_by_work_expansion` 추가 검증).
+ - 전체 회귀 시험 69개 전수 통과 (0 실패).
+
+- [x] 기계경비(MachMainDat ↔ MachTitleDat) 당초 계약 단가 비교·설계변경 조회 및 데이터 보호(적용 미지원) 구현
+ - 자체 검증:
+ 1) `native-machine.js` 검토 및 수정:
+ - REA `f18804` 기계경비 계산 엔진(`EstMachine.calculate`)과 `MachTitleDat`를 대조한 결과, 기계경비 호표 단가는 `MachMainDat`의 상세 산출 공식에 의해 결정되며, `MachTitleDat`의 `TC_`/`NC_`/`GC_`/`JC_`는 설계변경 비교를 위한 당초 계약값임.
+ - 당초 계약값은 현재 기준단가의 근거가 아니므로, 현재 단가를 과거 계약값으로 덮어쓰는 파괴적인 개별/일괄 적용 UI를 비활성화(`canApply: false`, 적용 버튼 미노출).
+ - 코드 호출 시에도 안전하게 예외를 발생(`기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.`)시켜 사용자 데이터 오염 및 자동 재계산 충돌을 원천 차단.
+ - 신규 항목(`TC_ === null`)에 대해 0으로 왜곡하지 않고 `originalPrice: null`, `difference: null`, `status: "신규(당초없음)"`으로 안전하게 분기 표시.
+ - 화면 안내 문구에 기계 호표 단가는 `MachMainDat` 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않음을 명시.
+ 2) UI 및 서식 연동:
+ - `index.html`, `native-app.js`, `native-extra.js`, `native-report.js`, `native-model.js`, `native-forms.js` 연동을 통해 사용 내역(`machine-usage`) 및 설계변경(`machine-change`)의 안전한 조회·인쇄 서식 지원.
+ - 관련 테스트:
+ - `pytest resources/tester/test_est_basic_prices.py` 5개 통과 (기계경비 당초/현재 비교, 신규 항목 TC_ null 안전 처리, 파괴적 apply 차단 및 데이터 불변 보존, Edge 브라우저 화면 조회 실측 검증).
+ - 전체 연계 회귀 시험 56개 전수 통과 (0 실패).
+
+- [x] 호표(일위대가·단가산출·기계경비) 상세 행 이동(`move-estimate-up`, `move-estimate-down`) 및 단가산출 다중 -20 구간·참조 수치 무결성 보존 구현
+ - 자체 검증:
+ 1) `native-business.js`에 `moveGroupDetailRows(rows, groupCode, selection, direction, table)` 구현 및 산출근거 다중 구간/참조 수치 무결성 검증 보완:
+ - 일위대가(`HeadMainDat`), 단가산출(`CostMainDat`), 기계경비(`MachMainDat`)의 호표 상세 행에 대해 연속 선택 행의 상/하 이동 지원.
+ - 호표 제목 행(`Index > 0`) 이동 차단 및 제목 행 건너뛰기 방지.
+ - 선택 행이 해당 호표 범위를 벗어나거나 인접 호표의 영역을 침범하지 않도록 상/하 경계 보호(경계 도달 시 이동 없이 원래 인덱스 보존).
+ - 합계/소계/구간비율 행(`LinkIndex` 54, 55, 58, 59, 61, 62) 및 합계 수식표지(`\x1a`) 포함 행의 이동 및 건너뛸 수 없도록 안전 차단.
+ - **다중 소계 구간 시작 표지(`Index === -20`)의 개별 보존 및 다중 선택 경계 보호**:
+ 이동에 참여한 국소 교환 위치 사이에서만 `-20` 표지를 정확히 교환하여, 호표 내 다른 소계 구간의 `-20` 표지(예: `D0000000005`의 다중 소계 구간)가 0으로 초기화되거나 왜곡되지 않고 완전히 유지되도록 수정.
+ 선택 범위 내에 복수 `-20`이 포함되거나 범위 중간에 `-20`이 끼어 있어 단일 경계 스왑으로 보장할 수 없는 경우는 보수적으로 이동 차단.
+ - **고유 식별자(`rowKey`) 매칭 및 앞줄 참조(`\x0c`) 수식/수치 불변 사전 검증(`validateCostOrderIntegrity`)**:
+ PkID 유무와 관계없이 `rowKey(row, index)`를 통해 전수 매칭하여, PkID 누락 행에서도 정확한 전/후 대조 보장.
+ 호표 최종 금액(`rows[0].TC`)뿐 아니라 각 소계(`LinkIndex === 61`)·비율(`LinkIndex === 58`) 행의 최종 금액, 그리고 앞줄 참조(`\x0c`)를 사용하는 모든 행의 `LineData` 수식 문자열이 완전히 동일하게 보존되는지 검증.
+ 앞줄 참조(`\x0c`) 행이 포함된 복수 행 동시 이동은 안전한 구분을 위해 단일 행 이동으로 제한하고 차단.
+ - **동일 결과값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 방지 및 일반 행/변수 재정의 보수적 차단**:
+ 뒤따르는 행이 특정 선행 결과를 직전 참조(`\x0c`)하는 구간에서, 동일 비목과 동일 결과값을 가진 선행 후보 행들이 2개 이상 존재할 때 이들 간의 순서 교환은 참조 대상이 왜곡되므로 보수적으로 이동 차단(`직전 결과를 참조하는 행이 뒤따라 참조 대상 구분이 불명확하므로 이동할 수 없습니다.`).
+ `LinkIndex 58`뿐 아니라 일반 행(`LinkIndex !== 61`)의 `\x0c` 앞줄 참조 역시 선행 후보 행이 2개 이상일 때의 순서 교환을 보수적으로 차단.
+ 동일 변수명 중복 할당(변수 재정의) 구간에 포함된 행들의 교환 시 전역 변수 사전 순서 의존성 왜곡을 방지하기 위해 이동 차단(`동일 변수(...)를 재정의하는 구간에서는 참조 대상 보존을 위해 이동할 수 없습니다.`).
+ 2) `native-business-actions.js` 및 `native-views.js` 연동:
+ - 툴바의 행 이동 버튼(`move-estimate-up`, `move-estimate-down`)이 내역서(`EstMainDat`)뿐 아니라 호표 화면(`HeadMainDat`, `CostMainDat`, `MachMainDat`)에서도 활성화되도록 노출.
+ - 호표 화면 조작 시 `moveGroupDetailRows`를 호출하고 `EstApp.change()`를 통해 작업본 반영 및 실행 취소(Undo/Redo) 지원.
+ - 관련 테스트:
+ 1) `pytest resources/tester/test_est_change_workflow.py` 13개 전수 통과:
+ - 일위대가 상세 행 이동 및 Edge 실측 시험.
+ - 단가산출(`CostMainDat`)의 실제 사례(`D0000000004` 인부소계 구간)에서 상세 행 이동 후 소계(`14,373.6`) 및 호표 총액(`16,421`) 완벽 보존 검증.
+ - 다중 `-20` 구간을 가진 실제 사례(`D0000000005`)에서 상세 행 이동 후에도 `-20` 표지 2개 및 1차 소계(`1,383.4`), 2차 소계(`1,407.8`)가 모두 보존됨을 검증.
+ - A/B 동일값 선행 행 교환 시 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 검증.
+ - `LinkIndex 58` 이외 일반 행의 `\x0c` 앞줄 참조 모호성 차단 검증.
+ - 동일 변수(`W`) 재정의 구간 행 교환 보수적 차단 검증.
+ - 소계 행(`LinkIndex 61`), 비율 행(`LinkIndex 58`), 변수 산식 행(`D0000000000`의 Q식), 소계 수치가 왜곡되는 구간 침범 이동, 범위 중간에 `-20`이 포함된 이동, 앞줄 참조 복수 행 이동의 안전 차단 검증.
+ - Edge 브라우저에서 단가산출 상세 행 이동, 소계 보존 및 실행 취소(Undo) 원복 실측 검증.
+ 2) 원본 ESTX 무결성 보호 시험 14개 통과 (`pytest resources/tester/test_est_native_export.py`).
+ 3) 핵심 비즈니스 및 가격 회귀 시험 28개 통과 (`test_est_basic_prices.py`, `test_est_standard.py`, `test_est_estimate.py`, `test_est_business.py`).
+ 4) 주 수량 회귀 시험 12개 통과 (`test_est_quantity_workflow.py`).
+
+- [x] 자재단가(StuffcDat) 및 노임단가(LaborcDat) 사용 내역 화면과 기초단가 개별/일괄 적용 구현
+ - 자체 검증:
+ative-basic-prices.js를 신규 구현하여 자재단가(LinkIndex === 5) 및 노임단가(LinkIndex === 6)를 원천(StuffcDat, LaborcDat)과 비교하는 사용 내역(material-usage, labor-usage) 및 설계변경(material-change, labor-change) 화면 제공.
+ 1) 자재는 GU, 노임은 NU 비목으로 원천 기초단가 반영 및 SCheck > 0 재분류 비목 보존.
+ 2) 수동계산(AutoCalc = false) 시 원천 단가·비목은 변경하되 해당 행의 기존 금액(GC, NC, TC)을 강제 계산하지 않고 보존하며, AutoCalc=true 전환/재계산 시 수량(GN)을 반영해 전체 전파되도록 규칙 일치.
+ 3) 화면 네비게이션, 인쇄 서식, 모델 검증 등록 및 EstApp.change() 연동 Undo/Redo 지원.
+ - 관련 테스트: pytest resources/tester/test_est_basic_prices.py 4개 통과(단위 시험 및 Edge 브라우저 자재/노임 단가 적용, Undo 원복 검증).
+
+- [x] 견적단가 사용 내역 화면(estimate-usage) 및 기초단가(EstiMateDat) 개별/일괄 적용 구현
+ - 자체 검증:
+ative-estimate.js를 신규 작성하여 견적단가 원천(EstiMateDat, LinkIndex === 101)과 내역 행 간 단가 비교(usage), 차이 검출, 개별 적용(pplyRowPrice), 일괄 적용(pplyAllPrices) 구현.
+ 1) 원천 비목 보존: 원천 비목 분할(InputGC, InputNC, InputJC) 유지 및 SCheck > 0 재분류 비목 보존.
+ 2) 수동계산(AutoCalc = false) 시 원천 단가·비목은 변경하되 해당 행의 기존 금액을 보존하고, AutoCalc=true 전환/재계산 시 수량(GN)을 반영해 갱신되도록 수동계산 규칙 일치.
+ 3) 화면 네비게이션, 인쇄 서식(estimate-usage, estimate-change), 모델 검증 등록 및 EstApp.change() 연동 Undo/Redo 지원.
+ - 관련 테스트: pytest resources/tester/test_est_estimate.py 4개 통과(단위 시험 및 Edge 브라우저 개별/일괄 적용, Undo 원복 검증).
+
+- [x] 표준시장단가 사용 내역 화면에서 개별 및 일괄 기초단가 내역 적용·실행 취소 구현
+ - 자체 검증:
+ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainDat, LinkIndex === 21) 간 단가 차이가 있는 항목을 선별(canApply)하고, 개별 행 단위 적용(pplyRowPrice, data-standard-apply-row) 및 전체 차이 항목 일괄 적용(pplyAllPrices, data-standard-apply-all) 기능을 구현.
+ 1) 적용 시 원천 자료의 고유성 검증: 원천 코드가 없거나 중복된 경우 임의 적용을 차단하고 예외 메시지 출력.
+ 2) 원천 비목 보존 및 계산 규칙 일치:
+ - 원천(SilJukDat)이 재료/노무/경비/실적 비목 분할(InputGC, InputNC, InputJC, InputSC)을 제공할 경우 이를 그대로 단가 비목(GU, NU, JU, SU)으로 반영.
+ - 사용자가 설정한 비목 재분류(SCheck > 0: 재료/노무/경비 몰아주기)가 있을 경우 이를 임의로 해제하지 않고 해당 대상 비목으로 기초 단가 총액을 보존.
+ - 수동계산(AutoCalc = false) 상태에서는 기존 내역 편집 및 est_estimate_classification_refreshes_only_selected_source_and_preserves_manual_mode 규칙과 일치하도록, 원천 단가 및 비목 분할은 반영하되 해당 행의 기존 금액(GC, NC, JC, SC, TC)을 강제 계산하지 않고 보존. 이후 AutoCalc=true로 전환하거나 재계산을 실행할 때 수량(GN)을 반영한 정식 금액으로 전파.
+ 3) 화면 상단 툴바에 적용 대상 건수(기초단가 일괄 적용 (N건))를 표시하고 대상이 없으면 비활성화(disabled).
+ 4) 작업본 반영은 EstApp.change()를 통해 이루어지므로 실행 취소(Undo)로 변경 전 단가 및 금액을 단계별로 완전 복원 가능.
+ - 관련 테스트: pytest resources/tester/test_est_standard.py 5개 통과(수동계산 AutoCalc=false 보존 및 재계산 전환, 단위 시험 및 Edge 브라우저 개별/일괄 적용, Undo 원복, 비목 분할 보존, SCheck 보존 등), 연계 회귀 39개 통과.
+
+- [x] 수량산출 항목 단위(Unite) 편집 및 Title 테이블 메모 필드 보존을 통한 ESTX 저장·재열기
+ - 자체 검증: `native-quantity.cjs` 수량산출 화면에 단위(`Unite`) 편집 열 추가 및 `export-quantity.cjs`의 `withoutLabels`에 `Unite` 라벨 변경 차단 방지 반영. `native-engine.js`의 `saveTitles()`에서 `CostTitleDat`·`HeadTitleDat`·`MachTitleDat`의 식별자(`PkID`, `Index`, `RecNo`, `Flag`)뿐만 아니라 BLOB/메모 필드(`DifineMemo`, `SpaceMemo`, `EscData`)를 원본 Title 행 기준으로 보존(기존 Title에 없을 시 Main 값이 오염 생성되지 않도록 필터링).
+ - 브라우저 검증: 임시 서버(127.0.0.1:5186)와 Edge에서 설치 `main.estx`를 열어 수량 항목 단위를 `m2`로 변경 후 ESTX 내보내기/다운로드, 브라우저 재열기 및 다른 수량 항목 보존·실행 취소 확인. (대용량 프로젝트 약 164MB에 따른 브라우저 임시 저장 용량 부족 경고 확인).
+ - 관련 테스트: `pytest resources/tester/test_est_quantity_workflow.py` 12개 통과, 기존 `test_est_native_export.py` 14개는 수정 전 기반 통과 상태이며 이번 Title 보존 전용 시험(`test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists`) 1개 통과(58.08s, 인덱스 `.idx` 동일성 및 단위 `m2` 보존).
+ - 남은 제한: 원본 EST와의 실행 동등성은 미확인.
+- [x] 수량산출서 항목 추가·삭제 및 내역 연결·JSON 저장/재열기 지원
+ - 자체 검증: `native-quantity.cjs`의 수량산출 화면에 `수량 항목 추가`(`data-quantity-add-item`) 및 각 수량 항목 행에 `삭제`(`data-quantity-delete-item`) 버튼을 구현.
+ 1) 항목 추가 시 기존 항목 코드(`r000000`, `r000001` ...)와 중복되지 않는 새 고유 코드 자동 생성(`r######`), 품명/규격/단위/수량 초기화 및 `EstApp.change()` 등록.
+ 2) 항목 삭제 시 안전성 보장: 산출 행의 산출근거(`LineData`) 결과 표지(`\x12...\x12`) 및 `row.markers`에서 참조 중인 항목은 산출 행 결과 표지 수와 마커 수 불일치로 인한 계산 오류를 방지하기 위해 임의 삭제를 차단하고 사전 안내 메시지 출력. 미참조 항목에 대해서만 확인(`confirm`)을 거쳐 안전하게 삭제.
+ 3) 품명·규격·단위 및 연결 내역 대상 지정(`TargetKey` / `TargetCode`) 후 JSON 작업본 저장 및 재열기 지원.
+ 4) ESTX 바이너리로 표현 불가능한 임의 행 구조 변경(신규 산출서/항목 증감)에 대해서는 기존 ESTX 저장 시의 불변 무결성 보호를 유지하고 JSON 보관 안내.
+ - 관련 테스트: `pytest resources/tester/test_est_quantity_workflow.py` 12개 통과 (신규 항목 추가, 참조 중인 항목 삭제 차단 안내 및 미참조 항목 안전 삭제 단위 시험 반영).
+- [x] 자료 오류 검색의 수량산출 항목 내역 대상 연결 검사, 해당 산출서 이동 및 검색 화면 내역 연결 지정
+ - 자체 검증: `native-issues.js`의 `scan()` 함수를 실제 수량 적용(`totals()`) 규칙과 정확히 일치하도록 개편:
+ 1) `TargetKey` 우선 매칭(`id:${row.PkID}`): 두 행의 코드가 동일해도 `TargetKey`로 고유하게 연결된 경우 정상 판정하여 오탐 방지. 잘못된 키는 `수량 대상 내역 없음`으로 탐지.
+ 2) `TargetKey`가 없을 시 `TargetCode ?? LinkCode` 대체값으로 매칭하며, `Level > 0`인 공종/비고 행 제외 규칙 일치.
+ 3) 수량산출서(`quantitySheets`) 및 수식표 직접 작성(`quantities`) 항목 모두 검사하며 미연결/대상 부재 항목을 `unlinked`로 추출.
+ 4) 오류 검색 화면에서 수량산출 오류 항목에 대해 `산출서 열기` 버튼을 제공하여, 클릭 시 `quantity` 뷰 및 해당 `quantitySheet` 코드로 즉시 전환·선택되도록 구현.
+ 5) 자료 오류 검색 화면에서 미연결 또는 대상 부재인 수량 항목에 대해 바로 내역 행을 선택할 수 있는 드롭다운(`data-issue-target-select`)을 제공하여, 오류 검색 화면을 벗어나지 않고도 `TargetKey`(`id:${row.PkID}`) 및 `TargetCode`를 즉시 지정하고 `EstApp.change()`를 통해 JSON 작업본에 즉시 반영 및 실행 취소 가능하도록 구현.
+ 6) `native-links.cjs`의 `existingTargets()`에서 고유한 `PkID`가 존재할 때만 `TargetKey`(`id:${row.PkID}`)로 안전하게 연결하고, `PkID`가 없는 동일 코드 행 간의 모호한 연결은 기존대로 미연결 유지 및 경고 처리 보존.
+ - 관련 테스트: `pytest resources/tester/test_est_native_export.py::test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets resources/tester/test_est_issues.py` 5개 통과, `test_est_quantity_workflow.py` 12개 통과. Edge 브라우저 '산출서 열기' 클릭 시 수량산출서 전환 이동 및 오류 화면 내역 연결 드롭다운 선택 시 작업본(`TargetKey`) 즉시 연결 실측 확인.
+- [x] 테일스케일 전용 독립 서버 감시 프로세스(`watch-server.cjs`) 및 대용량 IndexedDB 캐시 복원
+ - 자체 검증: `server.cjs`의 외부 바인딩을 `EST_TEST_HOST=100.65.141.15:5175`로 한정(127.0.0.1 접속 시 ECONNREFUSED 차단 확인). Node 내장 `--watch` 기반 감시 프로세스(`watch-server.cjs`)를 구현해 `data/` 및 로그를 제외한 `.js`·`.cjs` 소스 변경 시에만 자동 재기동되도록 구성(파일 touch 시 자동 재기동 및 헬스 200 확인). `server.cjs`를 require하는 테스트 실행 시 watcher hang 없음 확인(`test_est_quantity_workflow.py` 12개 2.69s 통과).
+ - 대용량 임시 저장 보완: `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] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
@@ -181,6 +535,12 @@
- 자체 검증: 수정 전 문제 재현, 수정 후 금액 유지와 비목 변경 재계산 확인. 관련 시험 16개 통과.
- [x] 연차 차수와 미확정 기성 회차·입력 Excel 편집 → 확정 회차의 계약·누계 보존 및 가져온 업무 계산 시험.
- 자체 검증(2026-11-07): 확정 회차는 내보내기 대상에서 제외하고 기존 계약·누계를 그대로 유지. Edge에서 미확정 금회 수량 1→3, 연차 수식 50%→25%를 Excel로 변경해 가져온 뒤 기성 누계 500·연차 250 확인. 없는 행·확정 회차·중복 ID·잘못된 입력 차단, Excel·기성·연차 시험 22개 통과. Edge 오류 0, openpyxl 날짜 API 경고 5건.
+ - [x] 내역·일위대가 행의 경비성단가(ActionSetJCBimog) 및 순공사비 경계 행(popMainSetTotalSumLine) 화면 조작 메뉴와 토글 재계산 지원.
+ - 자체 검증(2026-11-08): REA ActionSetJCBimog(0xf17c10/0x10dba14) 및 popMainSetTotalSumLine 근거를 기반으로 `toggle-jc-bimog`, `toggle-sun-gongsa` 조작 액션 구현.
+ 1) 경비성단가 보존 및 복원: SCheck=1(재료) 또는 2(노무) 상태에서 경비성단가 지정(SCheck=3) 후 해제 시 원래의 SCheck 값(`_prevSCheck`)으로 온전히 복원(기존 비목별 0으로의 왜곡 차단), 노무비(SCheck=2, NU=2000)가 SCheck=3 전환 후 해제 시 SCheck=2, NU=2000으로 복원됨을 검증.
+ 2) 순공사비 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101~105) 또는 코드·수량이 있는 상세 행의 순공사비 경계선 지정을 안전하게 차단(`"단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요."`).
+ 3) 순공사비 속성 보존/복원: 기존 54 행이 있을 때 새 행을 순공사비로 지정하거나 해제 시 원래의 `LinkIndex`와 `Name`(`_origLinkIndex`, `_origName`)으로 정확히 복원됨을 검증.
+ 4) 안전성 거부 4건(비허용 화면 호출, 선택 없음, 복수 순공사비 지정, 원천 연결 상세행 지정 거부) 전수 확인. 신규 전용 시험 `resources/tester/test_est_row_actions.py` 통과.
- [ ] 남은 웹 업무 기능을 이어서 구현하고 각 기능의 입력·계산·저장 흐름 확인.
- [x] ESTX 원본 옵션·공사명 및 기존 BLOB 확장 저장 구현 → 설치 샘플 재읽기와 인덱스·다른 자료 보존 확인. CAD 제외.
@@ -190,6 +550,13 @@
- [x] 원본 결과와 기존 계산이 일치하는 수량산출서의 결과 표지·수량 목록 재계산 저장.
- 자체 검증(2026-11-06): main.estx 수량산출서 폭 9→10 변경 시 표지 및 목록 수량 104→116으로 기록·재읽기, 다른 문서·인덱스·CAD 보존. EST 전용 102개 통과(70.78초). 계산 오류·기존 결과 불일치 문서는 저장을 거부하며 원본 EST 실행 검증은 미실시.
- [ ] 서식 구간을 넘는 수량산출서 편집과 목록·연결 변경 지원; 계산 오류나 기존 결과 불일치 문서의 처리 확인.
+ - 진행 및 미완료 범위(2026-11-08):
+ 1. 서식 편집 지원: 수량산출서 편집기에서 위첨자(`^(2)` 등) 및 복합 서식 태그 경계를 가로지르는 텍스트 수정(`abc^(2)def` -> `abc^(3)xyz`) 시 분할된 서식 런을 유지하며 인라인 패치 적용 확인 (`test_quantity_document_edit_across_styles_and_superscript_preserves_structure` 통과).
+ 2. 불일치 문서 처리: 원본 저장 수량과 산식 계산 결과가 다른 불일치 문서(예: `N0000000083` 원본 286 vs 산식 100.1)의 경우, 품명/규격/단위(Unite) 편집 시 기존 원본 수량(286)을 보존해 ESTX 패치를 허용하며, 산식을 수정해 결과가 변경되는 경우 "수량산출서 원본 결과와 재계산 값이 다릅니다. JSON으로 저장해 확인하세요"로 ESTX 덮어쓰기를 안전 차단함을 확인.
+ 3. 미완료 범위(제한 사항): 수량산출서 목록·연결(항목 추가/삭제, 수량산출서 신규/삭제, 행 구조 증감 등) 변경 시 ESTX 저장(`MulMainDat`, `MulTitleDat`, `Editor`, `MulList`)은 고정 인덱스 및 BLOB 구조 보존을 위해 현재 안전 차단되어 있으며, JSON 저장소(`project.quantitySheets`)에서만 지원됨. ESTX 목록·연결 구조 변경 저장은 미완료 상태임.
+ 4. 관련 회귀 시험 `test_est_quantity_workflow.py` 12개, `test_est_business.py` 18개, `test_est_native_export.py` 수량/위첨자 10개 전수 통과.
+- [x] 수량산출 항목의 단위(Unite) 화면 입력 열과 ESTX 저장 → 재열기·다른 항목 보존·목록 바이너리 검증.
+ - 자체 검증(2026-11-07): native-quantity 렌더러에 단위 입력 열 추가 및 change 핸들러에서 Unite 변경 허용. 설치 main.estx 기존 항목의 품명·규격과 함께 단위(Unite: 'm2')를 수정해 브라우저 이벤트·저장·재열기, 다른 항목·연결·수량 보존 및 실행취소 확인. 단위 변경 시 산식 계산 불변 확인(퍼센트 등 임의 자동변환 없음). test_est_quantity_workflow 12개 및 test_est_native_export 회귀 14개 통과. 원본 EST 실행 검증은 미실시.
- [x] 수량산출서의 연속 글자 서식에 걸친 텍스트 편집 저장 → 서식 태그 보존 및 재읽기 검증.
- 자체 검증(2026-11-07): 서로 다른 세 글자 서식에 걸친 텍스트를 변경한 뒤 태그와 재읽기 결과를 대조. 변경 시작 위치의 서식으로 새 텍스트를 저장하며 위첨자·표지를 넘는 임의 편집은 계속 제한. 관련 시험 10개와 EST 전용 120개 통과(77.60초). 원본 EST 실행 비교는 미실시.
- [x] 설치 수량산출서의 실제 글자 서식 경계 편집을 회귀시험으로 보존 → 해당 문서와 태그 재읽기.
@@ -237,7 +604,12 @@
- 구현: Excel 스칼라 표/자료형과 기존 바이너리 보존, 단가 수정 전파, 코드/수식/중복 검사. XML 작성정보·연결/길이 검사·금액 보존 및 자동계산 해제, UTF-8/CP949/UTF-16 입력. JDLCHK 필드 정의와 EST TAB/ENQ·CP949 처리 경로에서 기본 표/ZIP 생성·읽기, 고정 단가 전개 제한 표시. ESTX 고정 행·비인덱스 필드/BLOB 원가 기록과 재읽기 검산. 재계산이 원본 호표 목록 PkID/인덱스 식별자를 버리던 문제 수정.
- 화면: CAD 제외 서식 이름 40개, 데이터 출력 및 서식별 직접 입력 문서, 용지/방향/여백/글자/행/쪽 번호. 예정공정표 기간·진도·가중치 입력/검사·기간 막대·가중평균, 공종별 동원 분리 출력. JSON 저장/재열기 및 이력 대상.
- 자체 검증(2026-11-06): Edge에서 XML 다운로드→실제 XSD 통과→재열기 금액/상하위 계층/0원 도급액 보존, JDL 다운로드/재열기 직접비·원가 보존, 예정공정 입력/저장 복원, CAD 제외 40개 서식 선택 시 JS 오류 0. Excel 왕복 시 바이너리·소수 유지, 66행 20행/쪽 분할과 쪽 번호 5~8, 본문/설정 저장 재열기 통과. 별도 openpyxl 읽기로 xlsx 구조/자료형 확인. 설치 sample.estx의 수량 변경→수정 저장→독립 재읽기 통과, 모든 .idx 바이트 불변. 원본 EST 실행 검증은 아님.
- - [ ] 남은 완전 호환 범위: ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성, WPTools 편집 저장, 기성/연차 원본 별도 업무 파일; JDL 확장 산식/기성·물가 자료와 원본 검사 DLL 인증; 원본과 같은 인쇄 도형/배치 및 기존 산식 잔여 오류. CAD만 제외한 전체 완료로 판정하지 않음.
+ - [ ] ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성 구현 가능성 조사 및 점검.
+ - 조사 및 한계 분석(2026-11-08):
+ 1. 파일 구조 및 암호화 특성: ESTX 내 각 테이블의 `.idx` 파일은 DBISAM 독자적 B-Tree 인덱스 형식으로, 헤더 8~24바이트에 서명(`06 8a be 8e 59 23 64 cb 40 3d 71 d2 e3 bc 64 d0`)이 포함되어 있으며 전체 블록(4,096바이트 단위)이 암호화·해싱되어 있어 단순 XOR 또는 비트 역변환으로 역공학할 수 없음.
+ 2. 무결성 검증 제약: `exportEstx`는 동일 행 수 및 비인덱스 필드(수치, 일반 텍스트, BLOB 데이터 등)의 수정 저장을 안전하게 지원하지만, 행 추가/삭제나 인덱스 키(`PkID`, `Code`, `Index`, `RecNo` 등)를 변경하면 `.idx`의 B-Tree 구조와 불일치가 발생함. 원본 Delphi/DBISAM 엔진 외부에서 인덱스 재생성을 구현할 수 있는 바이너리 규격이나 소스 근거가 부재함.
+ 3. 구현 범위 및 처리: 따라서 ESTX 내 행 추가/삭제 및 인덱스 키 변경은 기존처럼 명확한 예외 메시지(`{key}: 행 추가/삭제는 인덱스 재생성이 필요합니다`, `{key}.{name}: 인덱스 키 변경은 지원하지 않습니다`)로 안전 차단하고, 신규 파일/행 증감/자유로운 편집이 필요한 작업은 JSON 저장소(`project.tables`)에서 완전 무결하게 지원·보존하도록 범위를 명확히 유지함.
+- [ ] 남은 완전 호환 범위: ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성, WPTools 편집 저장, 기성/연차 원본 별도 업무 파일; JDL 확장 산식/기성·물가 자료와 원본 검사 DLL 인증; 원본과 같은 인쇄 도형/배치 및 기존 산식 잔여 오류. CAD만 제외한 전체 완료로 판정하지 않음.
- [x] 전용 회귀·포맷·700줄 제한·커밋/push 마무리. 검증 서버 종료 완료.
- 자체 검증: EST 전용 전체 99 통과(48.88초). 변경 파일만 prettier/ruff format, git diff --check와 모든 소스 700줄 이하 확인. a5157dd3a 커밋을 CODEX에 push, origin/CODEX와 차이 0·작업 트리 깨끗함 확인. 검증 서버 5175 종료, 이번 일회 UI/분석 스크립트·출력물·전용 시험 저장본 정리. 기존 원본 자료와 앱 data는 수정하지 않음.
diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs
index 56bd65cb8..d0c0e207c 100644
--- a/tools/est-test/export-estx.cjs
+++ b/tools/est-test/export-estx.cjs
@@ -139,6 +139,13 @@ function exportEstx(project) {
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.excelQuantityLinks?.length)
throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
+ if (project.schedule?.length)
+ throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
+ if (
+ project.documents &&
+ Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0)
+ )
+ throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
const original = Buffer.from(project.originalArchive, "base64"),
base = importEstx(original),
files = unzip(original);
@@ -176,6 +183,13 @@ function exportEstx(project) {
throw Error(
"웹 기성/연차/당초 사본/계약심사는 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.",
);
+ if (!same(project.schedule || [], base.schedule || []))
+ throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
+ if (
+ project.documents &&
+ Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0)
+ )
+ throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (!same(project.quantitySheets || [], base.quantitySheets || []))
project = updateQuantities(project, base, cp949, memory);
let changed = 0;
diff --git a/tools/est-test/export-print.cjs b/tools/est-test/export-print.cjs
index 337e5658d..c05f96729 100644
--- a/tools/est-test/export-print.cjs
+++ b/tools/est-test/export-print.cjs
@@ -39,6 +39,22 @@ function updatePrintOptions(buffer, changes) {
value <= 100
)
output.writeInt32LE(value, field.at);
+ else if (
+ name === "PrintGongMethod" &&
+ field?.kind === 1 &&
+ field.width === 4 &&
+ Number.isInteger(value) &&
+ [0, 1, 2].includes(value)
+ )
+ output.writeInt32LE(value, field.at);
+ else if (
+ ["PrintApplyField", "PrintSumBottom"].includes(name) &&
+ field?.kind === 3 &&
+ field.width === 2 &&
+ Number.isInteger(value) &&
+ [0, 1].includes(value)
+ )
+ output.writeInt16LE(value, field.at);
else throw Error(`인쇄 서식 ${id}.${name}: 저장할 수 없는 값`);
}
}
diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs
index f5004612f..87be104cd 100644
--- a/tools/est-test/export-quantity.cjs
+++ b/tools/est-test/export-quantity.cjs
@@ -90,7 +90,7 @@ function editStyledRuns(div, before, after, parse) {
// Change text inside an existing styled run, never WPTools tags or bookmarks.
function editDocument(text, oldSheet, sheet) {
- const withoutLabels = (items) => items.map(({ Name, Size, ...rest }) => rest);
+ const withoutLabels = (items) => items.map(({ Name, Size, Unite, ...rest }) => rest);
if (
!same({ ...oldSheet, rows: [], items: [] }, { ...sheet, rows: [], items: [] }) ||
!same(withoutLabels(oldSheet.items), withoutLabels(sheet.items)) ||
@@ -113,13 +113,22 @@ function editDocument(text, oldSheet, sheet) {
if (direct) return direct;
const styled = editStyledRuns(div, before, after, parse);
if (styled) return styled;
- const split = /([\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
+ const split = /(\^\([^)]+\)|[\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
+ isSpecial = (s) => /^\^\([^)]+\)$/.test(s) || /[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/.test(s),
previous = before.LineData.split(split),
next = after.LineData.split(split);
- if (previous.length === next.length && previous.every((v, i) => i % 2 === 0 || v === next[i])) {
+ if (
+ previous.length === next.length &&
+ previous.every(
+ (v, i) =>
+ !isSpecial(v) ||
+ (isSpecial(next[i]) &&
+ (v === next[i] || (v.startsWith("^(") && next[i].startsWith("^(")))),
+ )
+ ) {
let candidate = div,
current = before;
- for (let i = 0; i < previous.length; i += 2) {
+ for (let i = 0; i < previous.length; i++) {
if (previous[i] === next[i]) continue;
previous[i] = next[i];
const updated = { ...current, LineData: previous.join("") };
diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs
index fce5bf1c6..5b9813984 100644
--- a/tools/est-test/import-estx.cjs
+++ b/tools/est-test/import-estx.cjs
@@ -381,6 +381,7 @@ function importEstx(buffer, name = "가져온 내역서") {
printCatalog,
progress: [],
annual: [],
+ schedule: [],
drawings: [],
importWarnings: [...warnings, "활성 원가 서식과 인덱스 편집 규칙은 자동 확정하지 않았습니다."],
originalArchive: buffer.toString("base64"),
diff --git a/tools/est-test/import-print.cjs b/tools/est-test/import-print.cjs
index f79147ec9..91320a592 100644
--- a/tools/est-test/import-print.cjs
+++ b/tools/est-test/import-print.cjs
@@ -46,7 +46,18 @@ function readPrintOptions(buffer, onRow) {
state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
if (state === 1 || state === 2) continue;
if (state !== 0) throw Error("인쇄 설정 필드 상태 오류");
- if (["PrintTop", "PrintBottom", "PrintLeft", "PrintRight", "PrintLineCount"].includes(name))
+ if (
+ [
+ "PrintTop",
+ "PrintBottom",
+ "PrintLeft",
+ "PrintRight",
+ "PrintLineCount",
+ "PrintGongMethod",
+ "PrintApplyField",
+ "PrintSumBottom",
+ ].includes(name)
+ )
positions[name] = { at, kind, width };
if (kind === 1 && width === 4) row[name] = take(4).readInt32LE();
else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE();
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 93aa61582..c71e4b08d 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -1,4 +1,4 @@
-
+
@@ -20,8 +20,12 @@
+
+
+
+
@@ -30,6 +34,7 @@
+
@@ -74,9 +79,21 @@
>
+
+
+
+
+
+
+
+
@@ -118,6 +135,14 @@
내역서