diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index e38434453..e1f95dd86 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -19,8 +19,12 @@ for (const file of [ "native-resources.js", "native-schedule.js", "native-issues.js", + "native-haljung.js", + "native-vat.js", "native-attachments.js", "native-standard.js", + "native-estimate.js", + "native-basic-prices.js", "native-model.js", "native-c3r.js", "native-exchange.js", diff --git a/resources/tester/test_est_attachments.py b/resources/tester/test_est_attachments.py index 43363504a..7fbdb3470 100644 --- a/resources/tester/test_est_attachments.py +++ b/resources/tester/test_est_attachments.py @@ -59,3 +59,66 @@ def test_estx_export_rejects_web_excel_links_instead_of_dropping_them(): timeout=10, ) assert "JSON" in result.stdout.decode("utf-8") + + +def test_estx_export_rejects_web_schedule_additions_or_changes_instead_of_dropping_them(): + # 1) 일반 항목 추가 + script_add = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{id:'1',Code:'A',Name:'새공정',start:'',end:'',weight:0,progress:0}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result_add = subprocess.run( + ["node", "-e", script_add], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result_add.stdout.decode("utf-8") + assert "예정공정표" in result_add.stdout.decode("utf-8") + + # 2) 빈 문자열 및 0값만 있는 행도 차단 + script_empty = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{id:'2',Code:'',Name:'',start:'',end:'',weight:0,progress:0}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result_empty = subprocess.run( + ["node", "-e", script_empty], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result_empty.stdout.decode("utf-8") + assert "예정공정표" in result_empty.stdout.decode("utf-8") + + # 3) 다른 필드만 있거나 빈 객체 행도 차단 + script_blank = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{custom:'field'}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result_blank = subprocess.run( + ["node", "-e", script_blank], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result_blank.stdout.decode("utf-8") + assert "예정공정표" in result_blank.stdout.decode("utf-8") + + +def test_estx_export_rejects_web_custom_documents_instead_of_dropping_them(): + script_doc = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',documents:{'173':'주요심사내용'}});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result_doc = subprocess.run( + ["node", "-e", script_doc], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result_doc.stdout.decode("utf-8") + assert "서식 문서" in result_doc.stdout.decode("utf-8") + + # 공백 문자열(" ")도 사용자 입력으로 보존하기 위해 ESTX 저장 차단 + script_space = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',documents:{'173':' '}});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result_space = subprocess.run( + ["node", "-e", script_space], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result_space.stdout.decode("utf-8") + assert "서식 문서" in result_space.stdout.decode("utf-8") diff --git a/resources/tester/test_est_basic_prices.py b/resources/tester/test_est_basic_prices.py new file mode 100644 index 000000000..b4777d13c --- /dev/null +++ b/resources/tester/test_est_basic_prices.py @@ -0,0 +1,364 @@ +from test_est_rea_parity import run + + +def test_basic_prices_changes_for_material_and_labor(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { PkID: 1, Code: 'M1', Name: 'changed mat', LinkIndex: 5, GN: 2, TC: 200 }, + { PkID: 2, Code: 'L1', Name: 'changed lab', LinkIndex: 6, GN: 1, TC: 50 }, + { PkID: 4, Code: 'M2', Name: 'new mat', LinkIndex: 5, GN: 1, TC: 40 }, + { PkID: 5, Code: 'L2', Name: 'new lab', LinkIndex: 6, GN: 1, TC: 80 }, + ]; + p.baseline = { + tables: { + EstMainDat: [ + { PkID: 1, Code: 'M1', Name: 'changed mat', LinkIndex: 5, GN: 1, TC: 100 }, + { PkID: 2, Code: 'L1', Name: 'changed lab', LinkIndex: 6, GN: 1, TC: 30 }, + { PkID: 3, Code: 'M3', Name: 'deleted mat', LinkIndex: 5, GN: 3, TC: 300 }, + { PkID: 6, Code: 'L3', Name: 'deleted lab', LinkIndex: 6, GN: 2, TC: 120 }, + ] + } + }; + return { + matChanges: EstBasicPrices.changes(p, 'material'), + labChanges: EstBasicPrices.changes(p, 'labor'), + matPrint: EstForms.data('material-change', p), + labPrint: EstForms.data('labor-change', p), + }; + })()""", + ) + mat_rows = {row["Code"]: row for row in result["matChanges"]} + assert set(mat_rows) == {"M1", "M2", "M3"} + assert mat_rows["M1"]["difference"] == 100 + assert mat_rows["M3"]["status"] == "삭제" + assert mat_rows["M2"]["status"] == "신규" + assert result["matPrint"]["rows"] == result["matChanges"] + + lab_rows = {row["Code"]: row for row in result["labChanges"]} + assert set(lab_rows) == {"L1", "L2", "L3"} + assert lab_rows["L1"]["difference"] == 20 + assert lab_rows["L3"]["status"] == "삭제" + assert lab_rows["L2"]["status"] == "신규" + assert result["labPrint"]["rows"] == result["labChanges"] + + +def test_basic_prices_usage_reports_applied_and_source_prices_without_mutation(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.StuffcDat = [ + { Code: 'M1', Name: 'mat 1', ValueIndex: 0, Cost1: 1200 }, + { Code: 'M2', Name: 'mat 2', ValueIndex: 0, Cost1: 500 }, + { Code: 'M2', Name: 'mat 2 dup', ValueIndex: 0, Cost1: 600 }, + ]; + p.tables.LaborcDat = [ + { Code: 'L1', Name: 'lab 1', ValueIndex: 0, Cost1: 200000 }, + ]; + p.tables.EstMainDat = [ + { Code: 'M1', Name: 'mat 1', LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000 }, + { Code: 'M2', Name: 'dup mat', LinkIndex: 5, GN: 1, TU: 450, GU: 450, GC: 450, TC: 450 }, + { Code: 'M3', Name: 'missing mat', LinkIndex: 5, GN: 1, TU: 200, GU: 200, GC: 200, TC: 200 }, + { Code: 'L1', Name: 'lab 1', LinkIndex: 6, GN: 1, TU: 180000, NU: 180000, NC: 180000, TC: 180000 }, + ]; + const before = JSON.stringify(p); + const matUsage = EstBasicPrices.usage(p, 'material'); + const labUsage = EstBasicPrices.usage(p, 'labor'); + const matPrint = EstForms.data('material-usage', p); + const labPrint = EstForms.data('labor-usage', p); + return { matUsage, labUsage, matPrint, labPrint, same: before === JSON.stringify(p) }; + })()""", + ) + assert result["same"] + assert [row["status"] for row in result["matUsage"]] == [ + "연결됨", + "코드 중복", + "연결 없음", + ] + assert result["matUsage"][0]["sourcePrice"] == 1200 + assert result["matUsage"][0]["appliedPrice"] == 1000 + assert result["matUsage"][0]["difference"] == -200 + assert result["matUsage"][1]["sourcePrice"] is None + assert result["matPrint"]["rows"] == result["matUsage"] + + assert len(result["labUsage"]) == 1 + assert result["labUsage"][0]["status"] == "연결됨" + assert result["labUsage"][0]["sourcePrice"] == 200000 + assert result["labUsage"][0]["appliedPrice"] == 180000 + assert result["labUsage"][0]["difference"] == -20000 + assert result["labPrint"]["rows"] == result["labUsage"] + + +def test_basic_prices_apply_row_and_all_prices_with_autocalc_modes(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.options.SourceRefresh = false; + p.options.AutoCalc = false; // 수동계산 모드 + p.tables.StuffcDat = [ + { Code: 'M1', Name: 'mat 1', ValueIndex: 0, Cost1: 1500 }, + { Code: 'M2', Name: 'mat 2', ValueIndex: 0, Cost1: 3000 }, + ]; + p.tables.LaborcDat = [ + { Code: 'L1', Name: 'lab 1', ValueIndex: 0, Cost1: 220000 }, + ]; + p.tables.EstMainDat = [ + { Code: 'M1', Name: 'item mat 1', LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000, SCheck: 2 }, // SCheck=2: 노무비 몰아주기 + { Code: 'M2', Name: 'item mat 2', LinkIndex: 5, GN: 3, TU: 2000, GU: 2000, GC: 6000, TC: 6000 }, + { Code: 'L1', Name: 'item lab 1', LinkIndex: 6, GN: 2, TU: 200000, NU: 200000, NC: 400000, TC: 400000 }, + ]; + + // 1. 수동계산 상태에서 자재 행 0 단가 적용 (SCheck=2 보존 검증) + // AutoCalc=false이므로 단가는 NU=1500, TU=1500으로 갱신되나 기존 금액(GC=2000, TC=2000) 보존 + EstBasicPrices.applyRowPrice(p, 0, 'material'); + const afterRow0 = { + TU: p.tables.EstMainDat[0].TU, + GU: p.tables.EstMainDat[0].GU, + NU: p.tables.EstMainDat[0].NU, + GC: p.tables.EstMainDat[0].GC, + NC: p.tables.EstMainDat[0].NC, + TC: p.tables.EstMainDat[0].TC, + SCheck: p.tables.EstMainDat[0].SCheck, + }; + + // 2. 자재 일괄 적용: 남은 행 1 적용 (단가 3000 갱신, 수동계산 금액 GC=6000 보존) + const matCount = EstBasicPrices.applyAllPrices(p, 'material'); + const afterMatAll = { + TU: p.tables.EstMainDat[1].TU, + GU: p.tables.EstMainDat[1].GU, + GC: p.tables.EstMainDat[1].GC, + TC: p.tables.EstMainDat[1].TC, + }; + + // 3. 노임 단가 적용: 행 2 적용 (단가 220000 갱신, 금액 NC=400000 보존) + const labCount = EstBasicPrices.applyAllPrices(p, 'labor'); + const afterLab = { + TU: p.tables.EstMainDat[2].TU, + NU: p.tables.EstMainDat[2].NU, + NC: p.tables.EstMainDat[2].NC, + TC: p.tables.EstMainDat[2].TC, + }; + + // 4. AutoCalc=true로 전환 후 전체 재계산: GN(수량)을 반영한 금액으로 일괄 전파 + p.options.AutoCalc = true; + const calculated = EstNative.calculate(p); + const afterRecalc = { + row0_NC: calculated.tables.EstMainDat[0].NC, + row0_TC: calculated.tables.EstMainDat[0].TC, + row1_GC: calculated.tables.EstMainDat[1].GC, + row1_TC: calculated.tables.EstMainDat[1].TC, + row2_NC: calculated.tables.EstMainDat[2].NC, + row2_TC: calculated.tables.EstMainDat[2].TC, + }; + + return { afterRow0, matCount, afterMatAll, labCount, afterLab, afterRecalc }; + })()""", + ) + + # 행 0: SCheck=2 보존되어 NU=1500, GU=0, 금액은 수동계산 유지(GC=2000, NC=0, TC=2000) + assert result["afterRow0"]["TU"] == 1500 + assert result["afterRow0"]["NU"] == 1500 + assert result["afterRow0"]["GU"] == 0 + assert result["afterRow0"]["GC"] == 2000 + assert result["afterRow0"]["TC"] == 2000 + assert result["afterRow0"]["SCheck"] == 2 + + # 행 1: 단가 3000, 수동 금액 6000 보존 + assert result["matCount"] == 1 + assert result["afterMatAll"]["TU"] == 3000 + assert result["afterMatAll"]["GU"] == 3000 + assert result["afterMatAll"]["GC"] == 6000 + assert result["afterMatAll"]["TC"] == 6000 + + # 행 2: 단가 220000, 수동 금액 400000 보존 + assert result["labCount"] == 1 + assert result["afterLab"]["TU"] == 220000 + assert result["afterLab"]["NU"] == 220000 + assert result["afterLab"]["NC"] == 400000 + assert result["afterLab"]["TC"] == 400000 + + # 전체 재계산 후: GN(수량) 반영 정상 산출 + # 행 0: GN=2 -> NC=3000, TC=3000 + assert result["afterRecalc"]["row0_NC"] == 3000 + assert result["afterRecalc"]["row0_TC"] == 3000 + # 행 1: GN=3 -> GC=9000, TC=9000 + assert result["afterRecalc"]["row1_GC"] == 9000 + assert result["afterRecalc"]["row1_TC"] == 9000 + # 행 2: GN=2 -> NC=440000, TC=440000 + assert result["afterRecalc"]["row2_NC"] == 440000 + assert result["afterRecalc"]["row2_TC"] == 440000 + + +def test_edge_basic_prices_material_and_labor_workflow_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.StuffcDat = [ + { Code: 'M01', Name: '시멘트 포대', Size: '40kg', Unite: '포', ValueIndex: 0, Cost1: 6000 }, + ]; + p.tables.LaborcDat = [ + { Code: 'L01', Name: '특별인부', Unite: '인', ValueIndex: 0, Cost1: 180000 }, + ]; + p.tables.EstMainDat = [ + { PkID: 301, Code: 'M01', Name: '시멘트 포대', Size: '40kg', Unite: '포', LinkIndex: 5, GN: 10, TU: 5000, GU: 5000, GC: 50000, TC: 50000 }, + { PkID: 302, Code: 'L01', Name: '특별인부', Unite: '인', LinkIndex: 6, GN: 5, TU: 160000, NU: 160000, NC: 800000, TC: 800000 }, + ]; + EstApp.setProject(p); + EstApp.state.view = 'material-usage'; + EstApp.render(); + })()""" + ) + + # 2. 자재단가 사용 내역 화면 확인 + assert page.locator("#view-title:has-text('자재단가 사용 내역')").is_visible() + btn_mat_apply = page.locator("button[data-basic-apply-row='0']") + assert btn_mat_apply.is_visible() + btn_mat_apply.click() + + # 자재 단가 6,000으로 적용 확인 + assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 6000 + assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].GU") == 6000 + + # 3. 노임단가 사용 내역 화면으로 전환 + page.evaluate( + """(() => { + EstApp.state.view = 'labor-usage'; + EstApp.render(); + })()""" + ) + assert page.locator("#view-title:has-text('노임단가 사용 내역')").is_visible() + btn_lab_apply = page.locator("button[data-basic-apply-all='labor']") + assert btn_lab_apply.is_visible() + btn_lab_apply.click() + + # 노임 단가 180,000으로 일괄 적용 확인 + assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 180000 + assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].NU") == 180000 + + # 4. Undo(실행 취소) 검증 + page.locator(".toolbar button[data-action='undo']").click() + assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 160000 + + page.locator(".toolbar button[data-action='undo']").click() + assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 5000 + + # 5. 기계경비 사용 내역 화면 전환 및 개별/일괄 적용, Undo 검증 + page.evaluate( + """(() => { + EstApp.navigate('machine-usage'); + })()""" + ) + assert page.locator("#view-title:has-text('기계경비 사용 내역')").is_visible() + + browser.close() + + +def test_machine_price_usage_changes_and_safety_rejection(): + result = run( + "math", + """(()=>{ + const p = structuredClone(project); + + // 신규 항목(TC_ null) 추가하여 비교 검증 + p.tables.MachTitleDat.push({ + Code: 'E_NEW_999', + Name: '신규 기계', + Size: '10Ton', + Unite: 'hr', + TC: 50000, + NC: 30000, + GC: 10000, + JC: 10000, + TC_: null, + NC_: null, + GC_: null, + JC_: null + }); + + const usage = EstMachine.usage(p); + const changes = EstMachine.changes(p); + + const newItemUsage = usage.find(u => u.Code === 'E_NEW_999'); + const newItemChange = changes.find(c => c.Code === 'E_NEW_999'); + const excUsage = usage.find(u => u.Code === 'E0000000000'); + + // 파괴적인 당초값 덮어쓰기 적용 차단 검증 + let blockedSingle = 0, singleErrMsg = ''; + try { + EstMachine.applyRowPrice(p, 0); + } catch (e) { + blockedSingle++; + singleErrMsg = e.message; + } + + let blockedAll = 0, allErrMsg = ''; + try { + EstMachine.applyAllPrices(p); + } catch (e) { + blockedAll++; + allErrMsg = e.message; + } + + // 호표 단가 및 단가산출 금액 불변 보존 확인 + const t0Unchanged = (p.tables.MachTitleDat[0].TC === 103047); + const costRow0 = p.tables.CostMainDat.find(r => r.LinkIndex === 105 && r.Code === 'E0000000000' && r.TC > 0); + const costUnchanged = (costRow0.TC === 1682.7); + + return { + usageCount: usage.length, + changesCount: changes.length, + newItemUsage, + newItemChange, + excUsage, + blockedSingle, + singleErrMsg, + blockedAll, + allErrMsg, + t0Unchanged, + costUnchanged + }; + })()""", + ) + assert result["usageCount"] == 9 + assert result["changesCount"] == 9 + # 신규 항목 TC_ null 안전 처리 검증 + assert result["newItemUsage"]["originalPrice"] is None + assert result["newItemUsage"]["difference"] is None + assert result["newItemUsage"]["status"] == "신규(당초없음)" + assert result["newItemUsage"]["canApply"] is False + assert result["newItemChange"]["status"] == "신규" + + # 기존 항목 당초/현재 비교 검증 + assert result["excUsage"]["appliedPrice"] == 103047 + assert result["excUsage"]["originalPrice"] == 62207 + assert result["excUsage"]["difference"] == 40840 + assert result["excUsage"]["status"] == "당초차이" + assert result["excUsage"]["canApply"] is False + + # 파괴적인 apply 차단 및 데이터 보존 검증 + assert result["blockedSingle"] == 1 + assert "덮어쓰기 적용은 지원하지 않습니다" in result["singleErrMsg"] + assert result["blockedAll"] == 1 + assert "덮어쓰기 적용은 지원하지 않습니다" in result["allErrMsg"] + assert result["t0Unchanged"] is True + assert result["costUnchanged"] is True diff --git a/resources/tester/test_est_browser_cache.py b/resources/tester/test_est_browser_cache.py new file mode 100644 index 000000000..87db27feb --- /dev/null +++ b/resources/tester/test_est_browser_cache.py @@ -0,0 +1,181 @@ +import os +from pathlib import Path +import pytest +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] + + +def test_edge_indexeddb_cache_and_tab_isolation_when_server_running(): + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + context = browser.new_context() + page1 = context.new_page() + try: + page1.goto("http://100.65.141.15:5175", timeout=5000) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page1.wait_for_function("() => Boolean(EstApp.state.project)") + + # 1. 탭 1에서 프로젝트 이름 수정 및 비동기 캐시 완료 대기 + page1.evaluate("""async () => { + EstApp.change(p => { p.name = '탭 1 프로젝트 수정'; }); + await new Promise(r => setTimeout(r, 100)); + }""") + + # 2. 탭 1 새로고침 후 복원 확인 + page1.reload() + page1.wait_for_function("() => Boolean(EstApp.state.project)") + name1 = page1.evaluate("EstApp.state.project.name") + assert name1 == "탭 1 프로젝트 수정" + + # 3. 탭 2 독립 생성 + page2 = context.new_page() + page2.goto("http://100.65.141.15:5175") + page2.wait_for_function("() => Boolean(EstApp.state.project)") + + # 탭 2는 탭 1의 세션 스토리지를 공유하지 않고 독립적으로 시작 + page2.evaluate("EstApp.change(p => { p.name = '탭 2 독자 프로젝트'; })") + name2 = page2.evaluate("EstApp.state.project.name") + assert name2 == "탭 2 독자 프로젝트" + + # 4. 탭 1 다시 새로고침 시 탭 2에 의해 덮어써지지 않는지 확인 + page1.reload() + page1.wait_for_function("() => Boolean(EstApp.state.project)") + assert page1.evaluate("EstApp.state.project.name") == "탭 1 프로젝트 수정" + + browser.close() + + +def test_edge_indexeddb_large_main_estx_reload_restore_when_server_running(): + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + if not sample.exists(): + sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx") + if not sample.exists(): + pytest.skip("main.estx sample not available") + + 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) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page.wait_for_function("() => Boolean(EstApp.state.project)") + + # 대용량 main.estx 업로드 + page.locator("#open-file").set_input_files(str(sample)) + page.wait_for_function( + "() => EstApp.state.project.tables.EstMainDat.length > 50", timeout=30000 + ) + + # 수정 후 캐시 대기 + page.evaluate("EstApp.change(p => { p.name = '대용량 main 수정본'; })") + page.wait_for_timeout(2000) + + # 새로고침 후 복원 확인 + page.reload() + page.wait_for_function("() => Boolean(EstApp.state.project)", timeout=30000) + + restored_name = page.evaluate("EstApp.state.project.name") + restored_rows = page.evaluate("EstApp.state.project.tables?.EstMainDat?.length || 0") + + assert restored_name == "대용량 main 수정본" + assert restored_rows > 50 + + # 대용량 캐시 저장 실패 시 안내 메시지 검증 + notice = page.evaluate("""async () => { + const originalSave = EstApp.cache; + // 인위적으로 IndexedDB 에러 유발 모의 + const db = await (new Promise(r => { + const req = indexedDB.open("est-independent-cache", 1); + req.onsuccess = () => r(req.result); + })); + // 읽기 전용 모드로 변경 시도하거나 sessionStorage 차단 모의 + const res = await EstApp.cache("x".repeat(100)); + return EstApp.state.notice; + }""") + assert isinstance(notice, str) + + browser.close() + + +def test_edge_duplicate_tab_isolation_and_inheritance_when_server_running(): + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + context = browser.new_context() + page1 = context.new_page() + try: + page1.goto("http://100.65.141.15:5175", timeout=5000) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page1.wait_for_function("() => Boolean(EstApp.state.project)") + + # 탭 1에서 이름 수정 + page1.evaluate("""async () => { + EstApp.change(p => { p.name = '원래 탭 프로젝트'; }); + await new Promise(r => setTimeout(r, 200)); + }""") + + tab1_id = page1.evaluate("sessionStorage.getItem('est-tab-instance-id')") + assert tab1_id == page1.evaluate("window.name") + + # 브라우저의 "탭 복제(Duplicate Tab)" 시뮬레이션: + # Chromium 복제 탭은 이전 탭의 sessionStorage 전체를 복제받지만, window.name은 빈 문자열("")로 시작합니다. + tab1_data = page1.evaluate(f"sessionStorage.getItem('est-web-v2-working-{tab1_id}')") + page_dup = context.new_page() + # 1회성 init script: 최초 로드 시 복제 순간 모의 후 플래그로 1회만 동작하도록 제어 + # 브라우저 실제 환경에서는 복제 순간 1회만 window.name이 빈 문자열이고 sessionStorage에 부모 데이터가 복제됨. + # page_dup.reload() 시 add_init_script가 다시 실행되어 window.name을 지우거나 부모 id로 되돌리는 현상을 방지하기 위해 sessionStorage 플래그 사용 + page_dup.add_init_script(f""" + if (!sessionStorage.getItem('__test_cloned_injected')) {{ + sessionStorage.setItem('__test_cloned_injected', '1'); + sessionStorage.setItem('est-tab-instance-id', '{tab1_id}'); + if ({"true" if tab1_data else "false"}) {{ + sessionStorage.setItem('est-web-v2-working-{tab1_id}', {repr(tab1_data) if tab1_data else '""'}); + }} + window.name = ''; + }} + """) + page_dup.goto("http://100.65.141.15:5175") + page_dup.wait_for_function("() => Boolean(EstApp.state.project)") + + # 1) 복제 탭은 부모 탭의 데이터('원래 탭 프로젝트')를 성공적으로 상속받았는지 확인 + info = page_dup.evaluate("""() => { + return { + windowName: window.name, + sessionTab: sessionStorage.getItem('est-tab-instance-id'), + parentTabId: EstApp.parentTabId, + name: EstApp.state.project.name, + keys: Object.keys(sessionStorage) + }; + }""") + print("DEBUG INFO:", info) + dup_name = info["name"] + assert dup_name == "원래 탭 프로젝트" + + # 2) 복제 탭은 부모 탭과 다른 새 고유 인스턴스 ID를 발급받았는지 확인 + dup_tab_id = page_dup.evaluate("sessionStorage.getItem('est-tab-instance-id')") + assert dup_tab_id != tab1_id + assert dup_tab_id == page_dup.evaluate("window.name") + + # 3) 복제 탭에서 데이터를 독립적으로 수정했을 때 원래 탭에 영향을 주지 않는지 확인 + page_dup.evaluate("""async () => { + EstApp.change(p => { p.name = '복제 탭에서만 변경'; }); + await new Promise(r => setTimeout(r, 200)); + }""") + + # 원래 탭 1 새로고침 시 원래 데이터 유지 확인 + page1.reload() + page1.wait_for_function("() => Boolean(EstApp.state.project)") + assert page1.evaluate("EstApp.state.project.name") == "원래 탭 프로젝트" + + # 복제 탭 새로고침 시 복제 탭 변경 사항 유지 확인 + page_dup.reload() + page_dup.wait_for_function("() => Boolean(EstApp.state.project)") + assert page_dup.evaluate("EstApp.state.project.name") == "복제 탭에서만 변경" + + browser.close() diff --git a/resources/tester/test_est_business.py b/resources/tester/test_est_business.py index 17a898d8e..a1fb4a7e2 100644 --- a/resources/tester/test_est_business.py +++ b/resources/tester/test_est_business.py @@ -378,3 +378,290 @@ def test_large_original_baseline_stays_within_the_save_request_limit(): result = run("baseline-size", str(archive)) assert result["archivePreserved"] assert result["bytes"] < 256 * 1024 * 1024 + + +def test_unit_calc_group_conversion_preserves_percentages_subtotals_fixed_and_other_groups(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.options.SourceRefresh = true; + p.tables.LaborcDat = [ + { Code: "L001", Name: "보통인부", Unite: "인", Cost1: 100000, TC: 100000 } + ]; + p.tables.StuffcDat = [ + { Code: "M001", Name: "시멘트", Unite: "포", Cost1: 10000, TC: 10000 } + ]; + p.tables.HeadMainDat = [ + // 대상 공종 H001 + { Index: 1, Code: "H001", Name: "기존공종", Size: "100", Unite: "m3", GN: 0, TC: 0 }, + // 1) 일반 비목 행 (수량 환산 적용) + { Index: 0, Code: "L001", LinkIndex: 6, Name: "보통인부", Size: "", Unite: "인", GN: 1.0, TU: 100000, NU: 100000, GU: 0, JU: 0, NC: 100000, GC: 0, JC: 0, TC: 100000 }, + // 2) 고정금액 행 (CalcIndex = 1, 환산 제외) + { Index: 0, Code: "FIX01", LinkIndex: 0, CalcIndex: 1, Name: "고정항목", Size: "", Unite: "식", GN: 1.0, TU: 5000, NU: 0, GU: 0, JU: 5000, NC: 0, GC: 0, JC: 5000, TC: 5000 }, + // 3) 비율 행 (LinkIndex = 58, GN = 10(%), 환산 제외) + { Index: 0, Code: "", LinkIndex: 58, Name: "공구손료", Size: "10%", Unite: "%", GN: 10.0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 }, + // 4) 소계 행 (LinkIndex = 51, 환산 제외) + { Index: 0, Code: "", LinkIndex: 51, Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 }, + // 5) 낙찰률 행 (LinkIndex = 52, GN = 80(%), 환산 제외) + { Index: 0, Code: "", LinkIndex: 52, Name: "낙찰적용", Size: "80%", Unite: "%", GN: 80.0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 }, + + // 비대상 공종 H002 (보존 검증) + { Index: 1, Code: "H002", Name: "다른공종", Size: "공통", Unite: "식", GN: 0, TC: 0 }, + { Index: 0, Code: "M001", LinkIndex: 5, Name: "시멘트", Size: "", Unite: "포", GN: 3.0, TU: 10000, NU: 0, GU: 10000, JU: 0, NC: 0, GC: 30000, JC: 0, TC: 30000 } + ]; + + // 1) factor = 0.5 환산 + const halfConverted = EstBusiness.convertGroupUnit(p, "H001", { + name: "환산공종", + size: "50", + factor: 0.5, + unit: "식" + }); + + // 2) factor = 2 환산 (halfConverted 기준 재환산 -> 원래 수량 1.0으로 복귀) + const doubleConverted = EstBusiness.convertGroupUnit(halfConverted, "H001", { + name: "환산공종2", + size: "100", + factor: 2.0, + unit: "식" + }); + + const h1Half = halfConverted.tables.HeadMainDat; + const h1Double = doubleConverted.tables.HeadMainDat; + + return { + // factor 0.5 결과 + halfR1GN: h1Half[1].GN, + halfFixedGN: h1Half[2].GN, + halfPercentGN: h1Half[3].GN, + halfSubtotalGN: h1Half[4].GN, + halfBidGN: h1Half[5].GN, + halfOtherGroupGN: h1Half[7].GN, + + // factor 2.0 결과 + doubleR1GN: h1Double[1].GN, + doubleFixedGN: h1Double[2].GN, + doublePercentGN: h1Double[3].GN, + doubleSubtotalGN: h1Double[4].GN, + doubleBidGN: h1Double[5].GN, + doubleOtherGroupGN: h1Double[7].GN, + + // AutoCalc false 옵션 보존 검증 + autoCalcFalsePreserved: (() => { + const p2 = structuredClone(p); + p2.options.AutoCalc = false; + const res = EstBusiness.convertGroupUnit(p2, "H001", { factor: 0.5 }); + return res.options.AutoCalc === false; + })() + }; + })()""", + ) + # factor 0.5 + assert result["halfR1GN"] == 0.5 + assert result["halfFixedGN"] == 1.0 # 고정금액 행 GN 불변 + assert result["halfPercentGN"] == 10.0 # 비율 행 GN 불변 + assert result["halfSubtotalGN"] == 0 # 소계 행 불변 + assert result["halfBidGN"] == 80.0 # 낙찰률 행 GN 불변 + assert result["halfOtherGroupGN"] == 3.0 # 다른 공종 H002 행 GN 불변 + + # factor 2.0 (원상복귀) + assert result["doubleR1GN"] == 1.0 + assert result["doubleFixedGN"] == 1.0 + assert result["doublePercentGN"] == 10.0 + assert result["doubleBidGN"] == 80.0 + assert result["doubleOtherGroupGN"] == 3.0 + + # AutoCalc false 옵션 보존 + assert result["autoCalcFalsePreserved"] is True + + +def test_haljung_calculation_apply_to_head_and_cost_groups(): + result = run( + "math", + """(()=>{ + // 1) 일위대가 공종에 할증 적용: 노무비 1000원의 10% 할증을 노무비에 가산 + const p = EstNative.empty(); + p.tables.HeadMainDat = [ + { Index: 1, Code: "H01", Name: "일위공종1", TC: 0 }, + { Index: 0, LinkIndex: 6, Name: "보통인부", GN: 1, NU: 1000, NC: 1000, TU: 1000, TC: 1000 }, + { Index: 0, LinkIndex: 5, Name: "모래", GN: 2, GU: 500, GC: 1000, TU: 500, TC: 1000 }, + { Index: 0, LinkIndex: 51, Name: "소계", TC: 0 } + ]; + p.tables.HeadTitleDat = [{ Code: "H01" }]; + + // 노무비의 10% 할증을 노무비에 적용 (sourceKind=2, targetKind=2, rate=10) + const p1 = EstHalJung.apply(p, "HeadMainDat", "H01", { + name: "할 증", + sourceKind: 2, + targetKind: 2, + rate: 10 + }); + + const hRows = p1.tables.HeadMainDat; + const haljungRow = hRows[3]; // 보통인부, 모래 뒤, 소계 앞 + const subtotalRow = hRows[4]; + const titleRow = hRows[0]; + + // 1-2) 특정 비목(노무비 sourceKind=2)에서 적용비목이 비목별(targetKind=0)일 때 원천 비목(노무비 NC)에 가산되는지 확인 + const p1_target0 = EstHalJung.apply(p, "HeadMainDat", "H01", { + name: "노무비별할증", + sourceKind: 2, + targetKind: 0, + rate: 10 + }); + const haljungRow_t0 = p1_target0.tables.HeadMainDat[3]; + + // 2) 일위대가 공종에 합계의 5% 할증을 경비에 적용 (sourceKind=4, targetKind=3, rate=5) + p1.tables.HeadMainDat.push( + { Index: 1, Code: "H02", Name: "일위공종2" }, + { Index: 0, LinkIndex: 5, Name: "재료A", GN: 1, GU: 2000, GC: 2000, TU: 2000, TC: 2000 }, + { Index: 0, LinkIndex: 51, Name: "소계" } + ); + p1.tables.HeadTitleDat.push({ Code: "H02" }); + + const p2 = EstHalJung.apply(p1, "HeadMainDat", "H02", { + name: "야간할증", + sourceKind: 4, + targetKind: 3, + rate: 5 + }); + + const h2Rows = p2.tables.HeadMainDat; + const cHaljungRow = h2Rows[7]; // H01 5행 뒤 H02(제목, 재료A, 야간할증, 소계) + + // 3) 잘못된 입력 거부 (0 또는 유효하지 않은 rate, 존재하지 않는 공종, 다른 테이블) + let rejected = 0; + try { EstHalJung.apply(p, "HeadMainDat", "H01", { rate: 0 }); } catch { rejected++; } + try { EstHalJung.apply(p, "HeadMainDat", "NON_EXIST", { rate: 10 }); } catch { rejected++; } + try { EstHalJung.apply(p, "CostMainDat", "H01", { rate: 10 }); } catch { rejected++; } + + return { + haljungRow: { + Name: haljungRow.Name, + Size: haljungRow.Size, + Unite: haljungRow.Unite, + GN: haljungRow.GN, + NC: haljungRow.NC, + GC: haljungRow.GC, + JC: haljungRow.JC, + TC: haljungRow.TC, + SCheck: haljungRow.SCheck, + OCheck: haljungRow.OCheck + }, + subtotalNU: subtotalRow.NU, + subtotalTU: subtotalRow.TU, + titleTC: titleRow.TC, + titleNC: titleRow.NC, + haljungTarget0: { + NC: haljungRow_t0.NC, + GC: haljungRow_t0.GC, + JC: haljungRow_t0.JC, + TC: haljungRow_t0.TC, + SCheck: haljungRow_t0.SCheck + }, + cHaljungRow: { + Name: cHaljungRow.Name, + Size: cHaljungRow.Size, + Unite: cHaljungRow.Unite, + JC: cHaljungRow.JC, + TC: cHaljungRow.TC + }, + rejected + }; + })()""", + ) + + # 1) 일위대가 노무비 1000원의 10% = 100원 노무비 가산 확인 + assert result["haljungRow"]["Name"] == "할 증" + assert result["haljungRow"]["Size"] == "노무비의" + assert result["haljungRow"]["Unite"] == "%" + assert result["haljungRow"]["GN"] == 10 + assert result["haljungRow"]["NC"] == 100 + assert result["haljungRow"]["GC"] == 0 + assert result["haljungRow"]["JC"] == 0 + assert result["haljungRow"]["TC"] == 100 + assert result["haljungRow"]["SCheck"] == 2 # 노무비에 + assert result["haljungRow"]["OCheck"] == 2 # 노무비의 + + # 소계(LinkIndex 51)의 단가 성분(NU/TU) 및 공종 제목행(Index 1) 합계: + # 노무비 1000 + 100 = 1100, 재료비 1000 => 합계 2100 + assert result["subtotalNU"] == 1100 + assert result["subtotalTU"] == 2100 + assert result["titleNC"] == 1100 + assert result["titleTC"] == 2100 + + # 1-2) sourceKind=2(노무비) & targetKind=0(비목별) -> 노무비(NC)에 100원 반영 확인 (경비 JC로 안 감) + assert result["haljungTarget0"]["NC"] == 100 + assert result["haljungTarget0"]["GC"] == 0 + assert result["haljungTarget0"]["JC"] == 0 + assert result["haljungTarget0"]["TC"] == 100 + assert result["haljungTarget0"]["SCheck"] == 2 + + # 2) 단가산출 합계 2000원의 5% = 100원 경비 가산 확인 + assert result["cHaljungRow"]["Name"] == "야간할증" + assert result["cHaljungRow"]["Size"] == "합계의" + assert result["cHaljungRow"]["JC"] == 100 + assert result["cHaljungRow"]["TC"] == 100 + + # 3) 안전성 거부 3건 확인 + assert result["rejected"] == 3 + + +def test_vat_calculation_exclude_include_and_unit_rounding_for_materials(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.StuffcDat = [ + { Code: "M01", Name: "시멘트", Cost1: 11000, Cost2: 12100, ValueIndex: 0, InputTC: 11000, TC: 11000 }, + { Code: "M02", Name: "철근", Cost1: 10000, ValueIndex: 4, InputTC: 10000, TC: 10000 }, + { Code: "M03", Name: "단단위테스트", Cost1: 12345, InputTC: 12345, TC: 12345 } + ]; + + // 1) M01 선택 후 부가세 제외 (/1.1) + const p1 = EstVat.apply(p, [0], { + calcMethod: 1, // 부가세제외 (/1.1) + diget: 0, // 원단위 + digetMethod: 1 // 절하 + }); + + // 2) M02에 부가세 포함 (*1.1) 및 십원 단위(-1자리) 절하 적용 + const p2 = EstVat.apply(p, [1], { + calcMethod: 0, // 부가세포함 (*1.1) + diget: 1, // 십원 단위 절하 + digetMethod: 1 + }); + + // 3) M03에 부가세 제외 (/1.1) 후 단단위 사사오입(digetMethod: 0) 및 절상(digetMethod: 2) + // 12345 / 1.1 = 11222.7272... + // diget=1 (십원 단위 사사오입): 11220 + // diget=1 (십원 단위 절상): 11230 + const p3Round = EstVat.apply(p, [2], { calcMethod: 1, diget: 1, digetMethod: 0 }); + const p3Up = EstVat.apply(p, [2], { calcMethod: 1, diget: 1, digetMethod: 2 }); + + return { + m1Cost1: p1.tables.StuffcDat[0].Cost1, + m1Cost2: p1.tables.StuffcDat[0].Cost2, + m1TC: p1.tables.StuffcDat[0].TC, + m2Cost1: p2.tables.StuffcDat[1].Cost1, + m2TC: p2.tables.StuffcDat[1].TC, + m3RoundCost: p3Round.tables.StuffcDat[2].Cost1, + m3UpCost: p3Up.tables.StuffcDat[2].Cost1 + }; + })()""", + ) + + # M01: 11000 / 1.1 = 10000, 12100 / 1.1 = 11000 + # ValueIndex=0(최저단가) -> 재계산 후 TC도 10000으로 보존됨 + assert result["m1Cost1"] == 10000 + assert result["m1Cost2"] == 11000 + assert result["m1TC"] == 10000 + + # M02: 10000 * 1.1 = 11000, InputTC/TC도 11000 확인 + assert result["m2Cost1"] == 11000 + assert result["m2TC"] == 11000 + + # M03: 12345 / 1.1 = 11222.72... -> 십원 단위 사사오입(11220), 절상(11230) + assert result["m3RoundCost"] == 11220 + assert result["m3UpCost"] == 11230 diff --git a/resources/tester/test_est_change_workflow.py b/resources/tester/test_est_change_workflow.py index 85ce2b940..613b3db9c 100644 --- a/resources/tester/test_est_change_workflow.py +++ b/resources/tester/test_est_change_workflow.py @@ -23,6 +23,416 @@ def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows assert result == {"moved": [0, 1], "order": [2, 3, 1, 0], "restored": [1, 2], "blocked": 4} +def test_edge_estimate_move_rows_up_and_down_with_undo(): + from playwright.sync_api import sync_playwright + import pytest + + 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) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page.wait_for_function("() => Boolean(EstApp.state.project)") + + # 내역서 화면 확인 + page.evaluate("EstApp.navigate('EstMainDat')") + page.wait_for_function("() => EstApp.state.view === 'EstMainDat'") + + # 1번 행의 고유 코드 또는 PkID 기록 + row_id_target = page.evaluate("EstApp.state.project.tables.EstMainDat[1].PkID") + + # 1번 행(2번째 행)을 선택하고 아래로 이동 시험 + page.evaluate("""() => { + EstApp.state.selection = new Set([1]); + EstApp.render(); + }""") + + btn_down = page.locator("#move-estimate-down") + assert btn_down.is_visible() + btn_down.click() + + # 이동 후 해당 행이 2번 위치로 이동했는지 확인 + pos_after_down = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target}); + }}""") + assert pos_after_down == 2 + sel_after_down = page.evaluate("Array.from(EstApp.state.selection)") + assert sel_after_down == [2] + + # 다시 위로 이동 시험 + btn_up = page.locator("#move-estimate-up") + assert btn_up.is_visible() + btn_up.click() + + pos_after_up = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target}); + }}""") + assert pos_after_up == 1 + sel_after_up = page.evaluate("Array.from(EstApp.state.selection)") + assert sel_after_up == [1] + + # 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 2번 위치로 되돌아감 + btn_undo = page.locator(".toolbar button[data-action='undo']") + assert btn_undo.is_visible() + btn_undo.click() + page.wait_for_timeout(500) + pos_after_undo = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target}); + }}""") + assert pos_after_undo == 2 + + browser.close() + + +def test_group_detail_move_preserves_row_identity_and_blocks_title_and_boundaries(): + result = run( + "math", + """(()=>{ + const rows = [ + {Index:1,Code:'H1',Name:'Title 1',PkID:100}, + {Index:0,Code:'D1',Name:'Detail 1',PkID:101}, + {Index:0,Code:'D2',Name:'Detail 2',PkID:102}, + {Index:0,Code:'D3',Name:'Detail 3',PkID:103}, + {Index:1,Code:'H2',Name:'Title 2',PkID:200}, + {Index:0,Code:'D4',Name:'Detail 4',PkID:201} + ]; + const moved = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([2]), 1); + const order = rows.slice(0, 4).map(r => r.Code); + const restored = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set(moved), -1); + let blocked = 0; + // 1: 제목 행(Index 0) 이동 시도 + try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([0]), 1); } catch { blocked++; } + // 2: 다른 호표 행 포함 시도 + try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([4]), 1); } catch { blocked++; } + // 3: 비연속 행 선택 시도 + try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1, 3]), 1); } catch { blocked++; } + // 4: 상한 경계(제목 바로 다음 행을 위로) - 이동 없이 원래 인덱스 반환 + const topBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1]), -1); + // 5: 하한 경계(호표 마지막 행을 아래로) - 이동 없이 원래 인덱스 반환 + const bottomBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([3]), 1); + return { moved, order, restored, blocked, topBoundary, bottomBoundary }; + })()""", + ) + assert result == { + "moved": [3], + "order": ["H1", "D1", "D3", "D2"], + "restored": [2], + "blocked": 3, + "topBoundary": [1], + "bottomBoundary": [3], + } + + +def test_edge_group_detail_move_rows_up_and_down_with_undo(): + from playwright.sync_api import sync_playwright + import pytest + + 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) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page.wait_for_function("() => Boolean(EstApp.state.project)") + + # 일위대가(HeadMainDat) 화면으로 이동 + page.evaluate("EstApp.navigate('HeadMainDat')") + page.wait_for_function("() => EstApp.state.view === 'HeadMainDat'") + + # 첫 번째 호표 코드 및 2번 상세 행(전체 인덱스 2)의 PkID 확인 + group_code = page.evaluate("EstApp.state.group") + assert group_code + target_pkid = page.evaluate("EstApp.state.project.tables.HeadMainDat[2].PkID") + + # 2번 상세 행 선택 + page.evaluate("""() => { + EstApp.state.selection = new Set([2]); + EstApp.render(); + }""") + + btn_down = page.locator("#move-estimate-down") + assert btn_down.is_visible() + btn_down.click() + + # 이동 후 해당 행이 3번 위치로 이동했는지 확인 + pos_after_down = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid}); + }}""") + assert pos_after_down == 3 + sel_after_down = page.evaluate("Array.from(EstApp.state.selection)") + assert sel_after_down == [3] + + # 다시 위로 이동 시험 + btn_up = page.locator("#move-estimate-up") + assert btn_up.is_visible() + btn_up.click() + + pos_after_up = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid}); + }}""") + assert pos_after_up == 2 + sel_after_up = page.evaluate("Array.from(EstApp.state.selection)") + assert sel_after_up == [2] + + # 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 3번 위치로 복원 + btn_undo = page.locator(".toolbar button[data-action='undo']") + assert btn_undo.is_visible() + btn_undo.click() + page.wait_for_timeout(500) + pos_after_undo = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid}); + }}""") + assert pos_after_undo == 3 + + browser.close() + + +def test_cost_group_detail_move_preserves_subtotal_and_blocks_order_dependent_errors(): + result = run( + "math", + """(()=>{ + const cost = project.tables.CostMainDat; + const grps = EstNative.groups(cost); + const grp4 = grps.get('D0000000004'); + const start4 = grp4.start; + const p = structuredClone(project); + + // 1: D0000000004 호표의 포장공(start+10, Index -20) 행을 아래(보통인부 start+12 건너)로 이동 + // Index -20 시작 표지가 선두로 유지되면서 소계(14,373.6) 및 호표 총액(16,421) 완벽 보존 + const moved4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 10]), 1, 'CostMainDat'); + const calcAfterMove = EstNative.calculate(p); + const subtotal4 = calcAfterMove.tables.CostMainDat[start4 + 14].TC; + const total4 = calcAfterMove.tables.CostMainDat[start4].TC; + + // 2: 다시 위로 원복 이동 + const restored4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(moved4), -1, 'CostMainDat'); + const calcAfterRestore = EstNative.calculate(p); + const subtotalRestore = calcAfterRestore.tables.CostMainDat[start4 + 14].TC; + + // 3: 보호 차단 시험 + let blockedSubtotal = 0; + try { + EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 14]), 1, 'CostMainDat'); + } catch { blockedSubtotal++; } + + let blockedRatio = 0; + try { + EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 18]), 1, 'CostMainDat'); + } catch { blockedRatio++; } + + const grp0 = grps.get('D0000000000'); + const start0 = grp0.start; + let blockedVar = 0; + try { + // D0000000000의 Q 변수 산식 행(start0+11)을 아래로 이동 시 참조 오류 차단 + EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000000', new Set([start0 + 11]), 1, 'CostMainDat'); + } catch { blockedVar++; } + + // 4: D0000000005 다중 -20 구간 보존 시험 + const grp5 = grps.get('D0000000005'); + const start5 = grp5.start; + const moved5 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 15]), 1, 'CostMainDat'); + const m20Count5 = p.tables.CostMainDat.slice(start5, start5 + grp5.rows.length).filter(r => Number(r.Index) === -20).length; + const calc5 = EstNative.calculate(p); + const sub1_5 = calc5.tables.CostMainDat[start5 + 19].TC; + const sub2_5 = calc5.tables.CostMainDat[start5 + 27].TC; + + // 5: 앞줄 참조/소계 결과가 다른 정상 값으로 바뀌는 안전하지 않은 이동 차단 시험 + // D0000000004에서 재료비 행(start4+6)을 단계별로 내려서 노무비 포장공(start4+10) 위치로 침범 시도 + let blockedSubtotalShift = 0; + try { + const m1 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 6]), 1, 'CostMainDat'); + const m2 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m1), 1, 'CostMainDat'); + const m3 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m2), 1, 'CostMainDat'); + EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m3), 1, 'CostMainDat'); + } catch { + blockedSubtotalShift++; + } + + // 6: PkID 없는 행, 다중 선택 내 구간 경계 포함 및 앞줄 참조 복수 행 이동 보수적 차단 시험 + let blockedMultiM20 = 0; + try { + // D0000000005에서 -20이 중간에 포함된 3개 연속 행 이동 시도 + EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 22, start5 + 23, start5 + 24]), 1, 'CostMainDat'); + } catch { blockedMultiM20++; } + + let blockedRefMulti = 0; + try { + // 앞줄 참조(\x0c) 행을 포함한 복수 행 동시 이동 시도 + EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 17, start4 + 18]), 1, 'CostMainDat'); + } catch { blockedRefMulti++; } + + // 7: A/B 동일값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 시험 + const ambigRows = [ + { Index: 1, Code: 'CAMBIG' }, + { PkID: 1, SCheck: -1, LineData: 'A = 100 = \x12100\x12' }, + { PkID: 2, SCheck: -1, LineData: 'B = 100 = \x12100\x12' }, + { PkID: 3, LinkIndex: 58, SCheck: -3, LineData: '\x0c100\x0c * 2 = \x12200\x12' } + ]; + // A와 B의 교환을 허용할 경우 B가 변경되었을 때 C가 참조하는 대상이 원래 B에서 A로 왜곡됨을 재현: + const origAmbig = EstText.calculate(structuredClone(ambigRows)); + const swappedAmbig = [ambigRows[0], ambigRows[2], ambigRows[1], ambigRows[3]]; + const ambigBChangedOrig = structuredClone(ambigRows); + ambigBChangedOrig[2].LineData = 'B = 200 = \x12200\x12'; + const ambigBChangedSwapped = structuredClone(swappedAmbig); + ambigBChangedSwapped[1].LineData = 'B = 200 = \x12200\x12'; + const origRes = EstText.calculate(ambigBChangedOrig); + const swappedRes = EstText.calculate(ambigBChangedSwapped); + const shiftReproduced = (origRes.rows[3].TC === 400 && swappedRes.rows[3].TC === 200); + + // moveGroupDetailRows에서 이와 같은 모호한 참조 대상 구간 행 이동을 확실히 차단하는지 검증: + let blockedAmbigRef = 0; + let ambigErrMsg = ''; + try { + EstBusiness.moveGroupDetailRows(ambigRows, 'CAMBIG', new Set([1]), 1, 'CostMainDat'); + } catch (e) { + blockedAmbigRef++; + ambigErrMsg = e.message; + } + + // 8: LinkIndex 58 이외 일반 행의 \x0c 앞줄 참조 모호성 차단 시험 + const generalRefRows = [ + { Index: 1, Code: 'CGENREF' }, + { PkID: 10, SCheck: 0, LineData: 'X1 = 50 = \x1250\x12' }, + { PkID: 11, SCheck: 0, LineData: 'X2 = 50 = \x1250\x12' }, + { PkID: 12, SCheck: 0, LinkIndex: 0, LineData: '일반행 : \x0c50\x0c + 10 = \x1260\x12' } + ]; + let blockedGenRef = 0; + try { + EstBusiness.moveGroupDetailRows(generalRefRows, 'CGENREF', new Set([1]), 1, 'CostMainDat'); + } catch { + blockedGenRef++; + } + + // 9: 동일 변수 재정의(중복 할당) 구간 행 교환 보수적 차단 시험 + const varRedefineRows = [ + { Index: 1, Code: 'CVARREDEF' }, + { PkID: 20, LineData: 'W = 10 = \x1210\x12' }, + { PkID: 21, LineData: 'W = 10 = \x1210\x12' }, + { PkID: 22, LineData: 'ANS = W * 3 = \x1230\x12' } + ]; + let blockedVarRedef = 0; + let varRedefErrMsg = ''; + try { + EstBusiness.moveGroupDetailRows(varRedefineRows, 'CVARREDEF', new Set([1]), 1, 'CostMainDat'); + } catch (e) { + blockedVarRedef++; + varRedefErrMsg = e.message; + } + + return { + moved4, + restored4, + subtotal4, + total4, + subtotalRestore, + blockedSubtotal, + blockedRatio, + blockedVar, + m20Count5, + sub1_5, + sub2_5, + blockedSubtotalShift, + blockedMultiM20, + blockedRefMulti, + shiftReproduced, + blockedAmbigRef, + ambigErrMsg, + blockedGenRef, + blockedVarRedef, + varRedefErrMsg + }; + })()""", + ) + assert result["shiftReproduced"] is True + assert result["blockedAmbigRef"] == 1 + assert "직전 결과를 참조하는 행이 뒤따라" in result["ambigErrMsg"] + assert result["moved4"] == [172] + assert result["restored4"] == [171] + assert result["subtotal4"] == 14373.6 + assert result["total4"] == 16421 + assert result["subtotalRestore"] == 14373.6 + assert result["blockedSubtotal"] == 1 + assert result["blockedRatio"] == 1 + assert result["blockedVar"] == 1 + assert result["m20Count5"] == 2 + assert result["sub1_5"] == 1383.4 + assert result["sub2_5"] == 1407.8 + assert result["blockedSubtotalShift"] == 1 + assert result["blockedMultiM20"] == 1 + assert result["blockedRefMulti"] == 1 + assert result["blockedGenRef"] == 1 + assert result["blockedVarRedef"] == 1 + assert "동일 변수(W)를 재정의하는 구간에서는" in result["varRedefErrMsg"] + + +def test_edge_cost_group_detail_move_rows_with_subtotal_and_undo(): + from playwright.sync_api import sync_playwright + import pytest + + 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) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page.wait_for_function("() => Boolean(EstApp.state.project)") + + # 단가산출(CostMainDat) 화면 및 D0000000004 호표로 이동 + page.evaluate("EstApp.navigate('CostMainDat', 'D0000000004')") + page.wait_for_function( + "() => EstApp.state.view === 'CostMainDat' && EstApp.state.group === 'D0000000004'" + ) + + # 시작 인덱스 조회 및 포장공 행(start + 10) 선택 + start_idx = page.evaluate( + "EstNative.groups(EstApp.state.project.tables.CostMainDat).get('D0000000004').start" + ) + target_row_idx = start_idx + 10 + target_pkid = page.evaluate( + f"EstApp.state.project.tables.CostMainDat[{target_row_idx}].PkID" + ) + + page.evaluate(f"""() => {{ + EstApp.state.selection = new Set([{target_row_idx}]); + EstApp.render(); + }}""") + + btn_down = page.locator("#move-estimate-down") + assert btn_down.is_visible() + btn_down.click() + + # 이동 후 위치 확인 및 소계/총액 보존 확인 + pos_after_down = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid}); + }}""") + assert pos_after_down == target_row_idx + 1 + subtotal_after_down = page.evaluate( + f"EstApp.state.project.tables.CostMainDat[{start_idx + 14}].TC" + ) + assert subtotal_after_down == 14373.6 + + # 실행 취소(Undo)로 원래 위치 원복 + btn_undo = page.locator(".toolbar button[data-action='undo']") + assert btn_undo.is_visible() + btn_undo.click() + page.wait_for_timeout(500) + + pos_after_undo = page.evaluate(f"""() => {{ + return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid}); + }}""") + assert pos_after_undo == target_row_idx + + browser.close() + + def test_duplicate_codes_match_distinct_original_rows_and_track_deletions(): result = run( "math", @@ -155,3 +565,151 @@ def test_blank_codes_have_distinct_keys_and_metadata_changes_are_reported(): assert len(set(result["keys"][1:])) == 2 assert result["rows"][0]["status"] == "changed" assert result["rows"][0]["difference"] == 0 + + +def test_bid_rates_apply_and_restore_original_rates(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.options.SourceRefresh = false; + p.tables.EstMainDat = [ + { PkID: 1, Code: 'R1', Name: '노무재료경비항목', GN: 2, NU: 1000, GU: 2000, JU: 500, SU: 0, TU: 3500, TC: 7000 } + ]; + p.tables.HeadMainDat = [ + { PkID: 10, Code: 'H1', Name: '일위대가항목', Index: 0, GN: 1, NU: 10000, GU: 5000, JU: 1000, SU: 0, TU: 16000, TC: 16000 } + ]; + + // 0) 원본에 기존 O필드가 존재하는 행 준비 + p.tables.EstMainDat.push({ + PkID: 2, Code: 'R2', Name: '기존O필드항목', GN: 1, NU: 500, GU: 500, JU: 0, SU: 0, TU: 1000, TC: 1000, + OTU: 1000, ONU: 500, OGU: 500, OJU: 0, OSU: 0 + }); + + // 1) 낙찰률 80% 적용 + const bidded = EstBusiness.bid(p, [80, 80, 80, 80]); + const bidEstRow = bidded.tables.EstMainDat[0]; + const bidHeadRow = bidded.tables.HeadMainDat[0]; + const bidExistingORow = bidded.tables.EstMainDat[1]; + + // 2) 당초(100%) 복원 + const restored = EstBusiness.restoreBid(bidded); + const resEstRow = restored.tables.EstMainDat[0]; + const resHeadRow = restored.tables.HeadMainDat[0]; + const resExistingORow = restored.tables.EstMainDat[1]; + + return { + bidEst: { NU: bidEstRow.NU, GU: bidEstRow.GU, JU: bidEstRow.JU, TU: bidEstRow.TU, ApplyNacChal: bidEstRow.ApplyNacChal }, + bidHead: { NU: bidHeadRow.NU, GU: bidHeadRow.GU, JU: bidHeadRow.JU, TU: bidHeadRow.TU, ApplyNacChal: bidHeadRow.ApplyNacChal }, + resEst: { NU: resEstRow.NU, GU: resEstRow.GU, JU: resEstRow.JU, TU: resEstRow.TU, hasApplyNacChal: 'ApplyNacChal' in resEstRow }, + resHead: { NU: resHeadRow.NU, GU: resHeadRow.GU, JU: resHeadRow.JU, TU: resHeadRow.TU, hasApplyNacChal: 'ApplyNacChal' in resHeadRow }, + resExistingO: { NU: resExistingORow.NU, OTU: resExistingORow.OTU, ONU: resExistingORow.ONU, OGU: resExistingORow.OGU } + }; + })()""", + ) + assert result["bidEst"]["NU"] == 800 + assert result["bidEst"]["GU"] == 1600 + assert result["bidEst"]["JU"] == 400 + assert result["bidEst"]["TU"] == 2800 + assert result["bidEst"]["ApplyNacChal"] == 1 + + assert result["bidHead"]["NU"] == 8000 + assert result["bidHead"]["GU"] == 4000 + assert result["bidHead"]["JU"] == 800 + assert result["bidHead"]["TU"] == 12800 + assert result["bidHead"]["ApplyNacChal"] == 1 + + assert result["resEst"]["NU"] == 1000 + assert result["resEst"]["GU"] == 2000 + assert result["resEst"]["JU"] == 500 + assert result["resEst"]["TU"] == 3500 + assert result["resEst"]["hasApplyNacChal"] is False + + assert result["resHead"]["NU"] == 10000 + assert result["resHead"]["GU"] == 5000 + assert result["resHead"]["JU"] == 1000 + assert result["resHead"]["TU"] == 16000 + assert result["resHead"]["hasApplyNacChal"] is False + + # 기존 O필드 보존 검증: 원래 있던 OTU/ONU/OGU가 삭제되지 않고 유지됨 + assert result["resExistingO"]["NU"] == 500 + assert result["resExistingO"]["OTU"] == 1000 + assert result["resExistingO"]["ONU"] == 500 + assert result["resExistingO"]["OGU"] == 500 + + +def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore(): + result = run( + "math", + """(()=>{ + // 1) 원천 단가(StuffcDat 200)와 내역 초기단가(GU 150)가 다르고 SourceRefresh=true인 프로젝트 + const p = EstNative.empty(); + p.options = { SourceRefresh: true, AutoCalc: true }; + p.tables.StuffcDat = [{ Code: 'M1', Name: '자재', ValueIndex: 4, InputTC: 200 }]; + p.tables.CostMainDat = [ + { Index: 1, Code: 'C1' }, + { Code: 'M1', LinkIndex: 5, SCheck: -2, LineData: ' \\x0e자재\\x0e: \\x03200\\x03 = \\x12200\\x12' } + ]; + p.tables.CostTitleDat = [{ Code: 'C1' }]; + p.tables.EstMainDat = [ + { PkID: 1, Code: 'C1', LinkIndex: 104, GN: 10, NU: 100, GU: 150, JU: 50, TU: 300, TC: 3000 } + ]; + + // 2) 1차 낙찰률 적용 (80%) -> 2차 연속 낙찰률 적용 (70%) + const bid1 = EstBusiness.bid(p, [80, 80, 80, 80]); + const bid2 = EstBusiness.bid(bid1, [70, 70, 70, 70]); + + // 2차 적용 후 단가: 최초(100, 150, 50) 기준 70%인 (70, 105, 35)이어야 하며, 80%의 70%(56%)로 왜곡되면 안 됨 + const bid2Row = bid2.tables.EstMainDat[0]; + + // 3) 직렬화 및 역직렬화 (저장 -> 재열기 시뮬레이션) + const reloaded = JSON.parse(JSON.stringify(bid2)); + + // 4) 당초(100%) 복원 + const restored = EstBusiness.restoreBid(reloaded); + const resRow = restored.tables.EstMainDat[0]; + + // 5) 수동계산(AutoCalc: false) 모드에서의 연속 적용 및 복원 + const manualP = structuredClone(p); + manualP.options.AutoCalc = false; + const manualBid = EstBusiness.bid(EstBusiness.bid(manualP, [80, 80, 80, 80]), [70, 70, 70, 70]); + const manualRestored = EstBusiness.restoreBid(manualBid); + + return { + bid2Row: { NU: bid2Row.NU, GU: bid2Row.GU, JU: bid2Row.JU, TU: bid2Row.TU }, + bid2Option: bid2.options.SourceRefresh, + resRow: { NU: resRow.NU, GU: resRow.GU, JU: resRow.JU, TU: resRow.TU, TC: resRow.TC }, + resOption: restored.options.SourceRefresh, + hasPreBidRates: 'PreBidRates' in resRow, + manualBidRow: { NU: manualBid.tables.EstMainDat[0].NU, GU: manualBid.tables.EstMainDat[0].GU }, + manualResRow: { NU: manualRestored.tables.EstMainDat[0].NU, GU: manualRestored.tables.EstMainDat[0].GU } + }; + })()""", + ) + + # 연속 낙찰률 적용 시 최초 단가(100, 150, 50) 기준 70% = (70, 105, 35) 유지 + assert result["bid2Row"]["NU"] == 70 + assert result["bid2Row"]["GU"] == 105 + assert result["bid2Row"]["JU"] == 35 + assert result["bid2Row"]["TU"] == 210 + + # 낙찰 중에는 원천 갱신 차단 + assert result["bid2Option"] is False + + # 복원 후: 원천 단가(200)로 덮이지 않고 최초 내역 단가(100, 150, 50)로 정확히 복원 + assert result["resRow"]["NU"] == 100 + assert result["resRow"]["GU"] == 150 + assert result["resRow"]["JU"] == 50 + assert result["resRow"]["TU"] == 300 + assert result["resRow"]["TC"] == 3000 + + # 사용자의 원래 SourceRefresh 옵션(true) 복원 및 임시 키 정리 + assert result["resOption"] is True + assert result["hasPreBidRates"] is False + + # 수동 계산(AutoCalc: false) 모드에서도 최초 단가 및 복원 일관성 확인 + assert result["manualBidRow"]["NU"] == 70 + assert result["manualBidRow"]["GU"] == 105 + assert result["manualResRow"]["NU"] == 100 + assert result["manualResRow"]["GU"] == 150 + diff --git a/resources/tester/test_est_documents.py b/resources/tester/test_est_documents.py index b690f9302..27c5247d3 100644 --- a/resources/tester/test_est_documents.py +++ b/resources/tester/test_est_documents.py @@ -149,6 +149,58 @@ def test_escalation_cost_print_uses_computed_cost_breakdowns(): assert result["rows"][-1]["excludedCost"] == 240 +def test_resource_view_filtering_and_by_work_expansion(): + result = run( + "math", + """(()=>{ + const p = structuredClone(project); + const allRows = EstResources.calculate(p, false).rows; + const byWorkRows = EstResources.calculate(p, true).rows; + + const laborOnly = allRows.filter(r => r.kind === 6); + const materialOnly = allRows.filter(r => r.kind === 5); + const machineOnly = allRows.filter(r => r.kind === 105); + + const laborByWork = byWorkRows.filter(r => r.kind === 6); + + // 공종별 집계 합산과 전체 집계의 금액/수량 일치 검증 + const laborAllQty = laborOnly.reduce((s, r) => s + r.quantity, 0); + const laborAllAmt = laborOnly.reduce((s, r) => s + r.amount, 0); + const laborWorkQty = laborByWork.reduce((s, r) => s + r.quantity, 0); + const laborWorkAmt = laborByWork.reduce((s, r) => s + r.amount, 0); + + // 서식 데이터(EstForms.data) 연동 확인 + const f112 = EstForms.data(112, p, {project: p}); + const f13 = EstForms.data(13, p, {project: p}); + + return { + allCount: allRows.length, + byWorkCount: byWorkRows.length, + laborCount: laborOnly.length, + materialCount: materialOnly.length, + machineCount: machineOnly.length, + laborByWorkCount: laborByWork.length, + qtyDiff: Math.abs(laborAllQty - laborWorkQty), + amtDiff: Math.abs(laborAllAmt - laborWorkAmt), + f112Count: f112.rows.length, + f13Count: f13.rows.length, + f112HasWork: f112.columns.includes('WorkCode') && f112.columns.includes('WorkName') + }; + })()""", + ) + assert result["allCount"] == 27 + assert result["byWorkCount"] == 72 + assert result["laborCount"] == 7 + assert result["materialCount"] == 13 + assert result["machineCount"] == 7 + assert result["laborByWorkCount"] == 30 + assert result["qtyDiff"] < 1e-9 + assert result["amtDiff"] < 1e-6 + assert result["f112Count"] == 30 + assert result["f13Count"] == 7 + assert result["f112HasWork"] is True + + def test_resource_report_separates_each_work_item_without_changing_aggregate(): result = run( "math", @@ -239,6 +291,55 @@ def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes(): assert result["again"] == [] +def test_schedule_move_row_and_clear_operations(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.schedule = [ + {id:'1', Code:'A', Name:'첫번째', start:'2026-01-01', end:'2026-01-10', weight:100, progress:10}, + {id:'2', Code:'B', Name:'두번째', start:'2026-01-11', end:'2026-01-20', weight:200, progress:20}, + {id:'3', Code:'C', Name:'세번째', start:'2026-01-21', end:'2026-01-31', weight:300, progress:30} + ]; + + // 상하 경계 이동 무반응 확인 + EstSchedule.moveRow(p, 0, -1); + const atTop = p.schedule.map(r => r.Code); + + EstSchedule.moveRow(p, 2, 1); + const atBottom = p.schedule.map(r => r.Code); + + // 중간 행 아래로 이동 + EstSchedule.moveRow(p, 1, 1); + const afterDown = p.schedule.map(r => r.Code); + + // 마지막 행 위로 이동 + EstSchedule.moveRow(p, 2, -1); + const afterUp = p.schedule.map(r => r.Code); + + // 공정 전체 비우기 + const countBeforeClear = p.schedule.length; + EstSchedule.clearSchedule(p); + const countAfterClear = p.schedule.length; + + return { + atTop, + atBottom, + afterDown, + afterUp, + countBeforeClear, + countAfterClear + }; + })()""", + ) + assert result["atTop"] == ["A", "B", "C"] + assert result["atBottom"] == ["A", "B", "C"] + assert result["afterDown"] == ["A", "C", "B"] + assert result["afterUp"] == ["A", "B", "C"] + assert result["countBeforeClear"] == 3 + assert result["countAfterClear"] == 0 + + def test_quantity_print_includes_manual_and_sheet_formulas_and_errors(): result = run( "math", @@ -259,3 +360,133 @@ def test_quantity_print_includes_manual_and_sheet_formulas_and_errors(): assert result["rows"][3]["TargetCode"] == "Y" assert result["rows"][2]["Error"] assert result["errors"] + + +def test_public_view_forms_print_gong_method_options(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { PkID: 1, Level: 1, Code: 'C01', Name: '토공사' }, + { PkID: 2, Level: 0, Code: 'A01', Name: '터파기', Size: '일반', Unite: 'm3', GN: 100, TU: 500, TC: 50000, NC: 20000, GC: 30000, JC: 0, SC: 0 } + ]; + p.tables.HeadMainDat = [ + { Index: 1, Code: 'H01', Name: '터파기 호표', TC: 500 }, + { Index: 0, Code: 'L01', Name: '인부', Unite: '인', GN: 0.1, NU: 1000, GU: 0, JU: 0, SU: 0, TC: 100 } + ]; + + // 1) 기본(PrintGongMethod=0): 공람용 내역서(117), 일위대가(115), 총괄표(116, 118) 수량만/금액제외 + p.printSettings = { PrintGongMethod: 0 }; + const d117_m0 = EstForms.data(117, p); + const d115_m0 = EstForms.data(115, p); + const d118_m0 = EstForms.data(118, p); + const d116_m0 = EstForms.data(116, p); + + // 2) PrintGongMethod=1: 단가/금액만 (수량 제외) + p.printSettings = { PrintGongMethod: 1 }; + const d117_m1 = EstForms.data(117, p); + const d115_m1 = EstForms.data(115, p); + + // 3) PrintGongMethod=2: 수량 + 단가/금액 모두 포함 + p.printSettings = { PrintGongMethod: 2 }; + const d117_m2 = EstForms.data(117, p); + const d115_m2 = EstForms.data(115, p); + const d118_m2 = EstForms.data(118, p); + + // 4) 표준 내역서(1) 및 일위대가(2)는 공람옵션 영향 없이 전체 열 출력 + const d1 = EstForms.data(1, p); + const d2 = EstForms.data(2, p); + + return { + d117_m0: d117_m0.columns, + d115_m0: d115_m0.columns, + d118_m0: d118_m0.columns, + d116_m0: d116_m0.columns, + d117_m1: d117_m1.columns, + d115_m1: d115_m1.columns, + d117_m2: d117_m2.columns, + d115_m2: d115_m2.columns, + d118_m2: d118_m2.columns, + d1: d1.columns, + d2: d2.columns + }; + })()""", + ) + # 1) m0 (수량만): GN만 포함, 단가/금액 비목 제외 + assert ( + "GN" in result["d117_m0"] + and "TC" not in result["d117_m0"] + and "TU" not in result["d117_m0"] + ) + assert ( + "GN" in result["d115_m0"] + and "TC" not in result["d115_m0"] + and "NU" not in result["d115_m0"] + ) + assert "TC" not in result["d118_m0"] and "NC" not in result["d118_m0"] + assert "TC" not in result["d116_m0"] and "NC" not in result["d116_m0"] + + # 2) m1 (단가만): GN 제외, 단가/금액 포함 + assert "GN" not in result["d117_m1"] and "TC" in result["d117_m1"] and "TU" in result["d117_m1"] + assert "GN" not in result["d115_m1"] and "TC" in result["d115_m1"] and "NU" in result["d115_m1"] + + # 3) m2 (수량, 단가 모두): GN, 단가, 금액 모두 포함 + assert "GN" in result["d117_m2"] and "TC" in result["d117_m2"] + assert "GN" in result["d115_m2"] and "TC" in result["d115_m2"] + assert "TC" in result["d118_m2"] + + # 4) 일반 서식(1, 2)은 불변 유지 + assert "GN" in result["d1"] and "TC" in result["d1"] + assert "GN" in result["d2"] and "TC" in result["d2"] + + +def test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + // 공종(레벨1), 소계, 0원 항목, 음수 변경분, 일반 상세항목 혼재 + p.tables.EstMainDat = [ + { Code: "", Name: "토공사", Size: "", Unite: "", GN: 0, TU: 0, TC: 0, Level: 1 }, + { Code: "A01", Name: "터파기", Size: "토사", Unite: "m3", GN: 100, TU: 5000, TC: 500000, NC: 300000, GC: 100000, JC: 100000, SC: 0, Level: 0 }, + { Code: "A02", Name: "잔토처리", Size: "덤프15t", Unite: "m3", GN: 50, TU: 0, TC: 0, NC: 0, GC: 0, JC: 0, SC: 0, Level: 0 }, + { Code: "A03", Name: "설계변경감소", Size: "규격", Unite: "식", GN: -10, TU: 10000, TC: -100000, NC: -50000, GC: -50000, JC: 0, SC: 0, Level: 0 }, + { Code: "", Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, TC: 400000, Level: 0 } + ]; + p.tables.HeadMainDat = [ + { Code: "H01", Name: "일위대가항목", Size: "", Unite: "식", GN: 1, NU: 1000, GU: 2000, JU: 0, SU: 0, TC: 3000 }, + { Code: "H02", Name: "0원대가항목", Size: "", Unite: "", GN: 1, NU: 0, GU: 0, JU: 0, SU: 0, TC: 0 } + ]; + + // PrintApplyField / PrintSumBottom 옵션이 어떤 값이든 행과 제목·0원·음수 행이 삭제되지 않고 100% 보존되는지 확인 + p.printSettings = { PrintApplyField: 1, PrintSumBottom: 1 }; + const d1 = EstForms.data(1, p); + const d2 = EstForms.data(2, p); + const d117 = EstForms.data(117, p); + const d115 = EstForms.data(115, p); + + const grid1 = EstForms.grid(d1); + + return { + d1Count: d1.rows.length, + d2Count: d2.rows.length, + d117Count: d117.rows.length, + d115Count: d115.rows.length, + hasZeroRow: d1.rows.some(r => r.Code === "A02" && r.TU === 0), + hasNegativeRow: d1.rows.some(r => r.Code === "A03" && r.TC === -100000), + hasTitleRow: d1.rows.some(r => r.Name === "토공사" && r.Level === 1), + hasSubtotalRow: d1.rows.some(r => r.Name === "소계"), + noInvalidSumBottomTfoot: !grid1.includes("") + }; + })()""", + ) + assert result["d1Count"] == 5 + assert result["d2Count"] == 2 + assert result["d117Count"] == 5 + assert result["d115Count"] == 2 + assert result["hasZeroRow"] is True + assert result["hasNegativeRow"] is True + assert result["hasTitleRow"] is True + assert result["hasSubtotalRow"] is True + assert result["noInvalidSumBottomTfoot"] is True diff --git a/resources/tester/test_est_estimate.py b/resources/tester/test_est_estimate.py new file mode 100644 index 000000000..a1c7c9ad8 --- /dev/null +++ b/resources/tester/test_est_estimate.py @@ -0,0 +1,262 @@ +from test_est_rea_parity import run + + +def test_estimate_changes_include_new_deleted_and_changed_link_types(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'A',Name:'changed',LinkIndex:101,GN:2,TC:200}, + {PkID:2,Code:'B',Name:'changed link',LinkIndex:5,GN:1,TC:50}, + {PkID:4,Code:'D',Name:'new',LinkIndex:101,GN:1,TC:40}]; + p.baseline={tables:{EstMainDat:[ + {PkID:1,Code:'A',Name:'changed',LinkIndex:101,GN:1,TC:100}, + {PkID:2,Code:'B',Name:'changed link',LinkIndex:101,GN:1,TC:50}, + {PkID:3,Code:'C',Name:'deleted',LinkIndex:101,GN:3,TC:300}, + {PkID:5,Code:'E',Name:'other',LinkIndex:5,GN:1,TC:10}]}}; + return {rows:EstEstimate.changes(p),print:EstForms.data('estimate-change',p)}; + })()""", + ) + rows = {row["Code"]: row for row in result["rows"]} + assert set(rows) == {"A", "B", "C", "D"} + assert rows["A"]["difference"] == 100 + assert rows["B"]["status"] == "변경" + assert rows["C"]["status"] == "삭제" and rows["C"]["index"] is None + assert rows["D"]["status"] == "신규" + assert result["print"]["rows"] == result["rows"] + + +def test_estimate_usage_reports_applied_and_source_prices_without_mutation(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstiMateDat=[ + {Code:'A',Name:'source',InputTC:12000,InputJC:12000}, + {Code:'B',InputTC:5000,InputJC:5000},{Code:'B',InputTC:6000,InputJC:6000} + ]; + p.tables.EstMainDat=[ + {Code:'A',Name:'work',LinkIndex:101,GN:2,TU:10000,JC:20000,TC:20000}, + {Code:'B',Name:'duplicate',LinkIndex:101,GN:1,TU:4500,JC:4500,TC:4500}, + {Code:'C',Name:'missing',LinkIndex:101,GN:1,TU:2000,JC:2000,TC:2000}, + {Code:'A',Name:'material',LinkIndex:5,GN:1,TU:1000} + ]; + const before=JSON.stringify(p), rows=EstEstimate.usage(p), print=EstForms.data('estimate-usage',p); + return {rows,print,same:before===JSON.stringify(p)}; + })()""", + ) + assert result["same"] + assert [row["status"] for row in result["rows"]] == ["연결됨", "코드 중복", "연결 없음"] + assert result["rows"][0]["sourcePrice"] == 12000 + assert result["rows"][0]["appliedPrice"] == 10000 + assert result["rows"][0]["difference"] == -2000 + assert result["rows"][1]["sourcePrice"] is None + assert result["print"]["rows"] == result["rows"] + + +def test_estimate_apply_row_and_all_prices(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.options.SourceRefresh = false; + p.options.AutoCalc = false; + p.tables.EstiMateDat = [ + { Code: 'A', Name: 'estimate A', InputTC: 15000, InputGC: 5000, InputNC: 7000, InputJC: 3000, InputSC: 0 }, + { Code: 'B', Name: 'estimate B', InputTC: 30000, InputJC: 30000 }, + { Code: 'D', Name: 'dup', InputTC: 5000 }, + { Code: 'D', Name: 'dup', InputTC: 6000 }, + ]; + p.tables.EstMainDat = [ + { Code: 'A', Name: 'item A', LinkIndex: 101, GN: 2, TU: 10000, GU: 3000, NU: 5000, JU: 2000, GC: 6000, NC: 10000, JC: 4000, TC: 20000, SCheck: 1 }, + { Code: 'B', Name: 'item B', LinkIndex: 101, GN: 3, TU: 20000, JU: 20000, JC: 60000, TC: 60000 }, + { Code: 'C', Name: 'item C', LinkIndex: 101, GN: 1, TU: 5000, JU: 5000, JC: 5000, TC: 5000 }, + { Code: 'D', Name: 'item D', LinkIndex: 101, GN: 1, TU: 4000, JU: 4000, JC: 4000, TC: 4000 }, + { Code: 'OTHER', Name: 'other row', LinkIndex: 5, GN: 5, TU: 1000, GC: 5000, TC: 5000 }, + ]; + const initialUsage = EstEstimate.usage(p); + const otherBefore = JSON.stringify(p.tables.EstMainDat[4]); + + // 행 0(SCheck=1: 재료비 몰아주기 설정)에 단가 적용: + // SCheck=1이 보존되어 GU=15000, NU=0, JU=0, SU=0 및 GN=2 -> GC=30000, TC=30000 + EstEstimate.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으로 초기화한 뒤 다시 적용하면 원천의 재료/노무/경비 비목 분할(5000/7000/3000)이 유지됨 + p.tables.EstMainDat[0].SCheck = 0; + EstEstimate.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 { EstEstimate.applyRowPrice(p, 2); } catch { errMissing = true; } + let errDup = false; + try { EstEstimate.applyRowPrice(p, 3); } catch { errDup = true; } + + // 남은 대상인 행 1(수량 3)에 대해 applyAllPrices 적용 + const appliedCount = EstEstimate.applyAllPrices(p); + const afterRow1 = { + row1_TU: p.tables.EstMainDat[1].TU, + row1_JU: p.tables.EstMainDat[1].JU, + row1_JC: p.tables.EstMainDat[1].JC, + row1_TC: p.tables.EstMainDat[1].TC, + }; + + // 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_JC: calculated.tables.EstMainDat[1].JC, + row1_TC: calculated.tables.EstMainDat[1].TC, + }; + + return { + initialCanApply: initialUsage.filter(u => u.canApply).length, + afterRow0, + afterRow0_split, + errMissing, + errDup, + appliedCount, + afterRow1, + afterRecalc, + }; + })()""", + ) + + assert result["initialCanApply"] == 2 + # AutoCalc=false 시 단가는 GU=15000으로 갱신되나 기존 금액(GC=6000, TC=20000) 보존 + assert result["afterRow0"]["row0_TU"] == 15000 + assert result["afterRow0"]["row0_GU"] == 15000 + assert result["afterRow0"]["row0_NU"] == 0 + assert result["afterRow0"]["row0_JU"] == 0 + assert result["afterRow0"]["row0_GC"] == 6000 + assert result["afterRow0"]["row0_TC"] == 20000 + assert result["afterRow0"]["row0_SCheck"] == 1 + assert result["afterRow0"]["otherPreserved"] is True + assert result["afterRow0"]["sourceRefresh"] is False + assert result["afterRow0"]["autoCalc"] is False + + # SCheck=0 시 원천 비목 분할(5000/7000/3000) 유지 및 기존 금액(GC=6000, NC=10000, JC=4000, TC=20000) 보존 + assert result["afterRow0_split"]["row0_TU"] == 15000 + assert result["afterRow0_split"]["row0_GU"] == 5000 + assert result["afterRow0_split"]["row0_NU"] == 7000 + assert result["afterRow0_split"]["row0_JU"] == 3000 + assert result["afterRow0_split"]["row0_GC"] == 6000 + assert result["afterRow0_split"]["row0_NC"] == 10000 + assert result["afterRow0_split"]["row0_JC"] == 4000 + assert result["afterRow0_split"]["row0_TC"] == 20000 + + assert result["errMissing"] is True + assert result["errDup"] is True + assert result["appliedCount"] == 1 + # 행 1: 단가 30000 갱신, 수동계산 상태이므로 기존 금액(JC=60000, TC=60000) 보존 + assert result["afterRow1"]["row1_TU"] == 30000 + assert result["afterRow1"]["row1_JU"] == 30000 + assert result["afterRow1"]["row1_JC"] == 60000 + assert result["afterRow1"]["row1_TC"] == 60000 + + # AutoCalc=true 재계산 후 GN(수량)을 반영한 금액으로 정상 계산 + assert result["afterRecalc"]["row0_GC"] == 10000 + assert result["afterRecalc"]["row0_NC"] == 14000 + assert result["afterRecalc"]["row0_JC"] == 6000 + assert result["afterRecalc"]["row0_TC"] == 30000 + assert result["afterRecalc"]["row1_JC"] == 90000 + assert result["afterRecalc"]["row1_TC"] == 90000 + + +def test_edge_estimate_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.EstiMateDat = [ + { Code: 'E001', Name: '견적비목 1', Size: '규격A', Unite: '식', InputTC: 50000, InputJC: 50000 }, + { Code: 'E002', Name: '견적비목 2', Size: '규격B', Unite: '식', InputTC: 80000, InputJC: 80000 }, + ]; + p.tables.EstMainDat = [ + { PkID: 201, Code: 'E001', Name: '견적비목 1', Size: '규격A', Unite: '식', LinkIndex: 101, GN: 10, TU: 40000, JU: 40000, JC: 400000, TC: 400000 }, + { PkID: 202, Code: 'E002', Name: '견적비목 2', Size: '규격B', Unite: '식', LinkIndex: 101, GN: 5, TU: 70000, JU: 70000, JC: 350000, TC: 350000 }, + ]; + EstApp.setProject(p); + EstApp.state.view = 'estimate-usage'; + EstApp.render(); + })()""" + ) + + # 2. 견적단가 사용 내역 화면 확인 + assert page.locator("#view-title:has-text('견적단가 사용 내역')").is_visible() + btn_apply_all = page.locator("button[data-estimate-apply-all]") + assert btn_apply_all.is_visible() + assert "기초단가 일괄 적용 (2건)" in btn_apply_all.text_content() + + # 3. 개별 단가 적용 버튼 클릭 (1행 E001) + btn_row_apply = page.locator("button[data-estimate-apply-row='0']") + assert btn_row_apply.is_visible() + btn_row_apply.click() + + # 1행이 50,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 == 50000 + assert tc_row0 == 500000 + + # 버튼 라벨이 (1건)으로 변경되었는지 확인 + assert "기초단가 일괄 적용 (1건)" in btn_apply_all.text_content() + + # 4. 일괄 적용 버튼 클릭 (남은 1건 E002 적용) + 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 == 80000 + assert tc_row1 == 400000 + + # 이제 일괄 적용 버튼은 disabled 상태인지 확인 + assert btn_apply_all.is_disabled() + + # 5. Undo(실행 취소) 검증 + page.locator(".toolbar button[data-action='undo']").click() + # 1단계 취소 후 2행은 원래대로 70,000 복구 + assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 70000 + + page.locator(".toolbar button[data-action='undo']").click() + # 2단계 취소 후 1행도 원래대로 40,000 복구 + assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 40000 + + browser.close() diff --git a/resources/tester/test_est_issues.py b/resources/tester/test_est_issues.py index d56f2f5be..524e54061 100644 --- a/resources/tester/test_est_issues.py +++ b/resources/tester/test_est_issues.py @@ -26,6 +26,52 @@ def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modif assert len(items) == 6 +def test_data_issues_detect_cost_main_dat_missing_and_duplicate_links(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.StuffcDat = [{Code:'M_GOOD', Name:'자재1'}, {Code:'M_DUP', Name:'자재2'}, {Code:'M_DUP', Name:'자재2-복사'}]; + p.tables.CostMainDat = [ + {Index:1, Code:'D001', Name:'단가산출호표1'}, + {Index:0, Code:'M_GOOD', LinkIndex:5, LineData:'정상 자재행'}, + {Index:0, Code:'M_MISSING', LinkIndex:5, LineData:'미연결 자재행'}, + {Index:0, Code:'M_DUP', LinkIndex:5, LineData:'중복 자재행'} + ]; + const issues = EstIssues.scan(p); + return { + costIssues: issues.filter(x => x.table === 'CostMainDat') + }; + })()""", + ) + items = {(row["index"], row["code"], row["message"]) for row in result["costIssues"]} + assert (2, "M_MISSING", "연결 자료 없음") in items + assert (3, "M_DUP", "연결 코드 중복") in items + assert len(items) == 2 + + +def test_data_issues_filtering_by_table_and_type(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.StuffcDat = [{Code:'M_DUP', Name:'A'}, {Code:'M_DUP', Name:'B'}]; + p.tables.EstMainDat = [{Code:'M_MISS', LinkIndex:5, Name:'없음'}]; + const all = EstIssues.scan(p); + const stuffOnly = all.filter(i => (i.table || '') === 'StuffcDat'); + const dupOnly = all.filter(i => i.message === '코드 중복'); + return { + total: all.length, + stuffCount: stuffOnly.length, + dupCount: dupOnly.length + }; + })()""", + ) + assert result["total"] == 3 + assert result["stuffCount"] == 2 + assert result["dupCount"] == 2 + + def test_cost_error_location_requires_unique_group_and_valid_row(): result = run( "math", @@ -41,3 +87,450 @@ def test_cost_error_location_requires_unique_group_and_valid_row(): assert result[0]["table"] == "CostMainDat" assert result[0]["index"] == 1 assert all(row["table"] == "" for row in result[1:]) + + +def test_data_issues_detect_quantity_target_missing_and_duplicate(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.EstMainDat = [ + { PkID: 101, Code: 'E1', Name: '단일 대상' }, + { PkID: 201, Code: 'DUP', Name: '중복 대상 1' }, + { PkID: 202, Code: 'DUP', Name: '중복 대상 2' } + ]; + p.quantitySheets = [ + { + Code: 'Q1', + items: [ + { Code: 'r1', TargetCode: 'E1' }, + { Code: 'r2', TargetCode: 'MISSING' }, + { Code: 'r3', TargetCode: 'DUP' }, + // 두 행이 코드 중복('DUP')이지만 TargetKey('id:201')로 고유하게 연결된 정상 사례 + { Code: 'r4', TargetCode: 'DUP', TargetKey: 'id:201' }, + // LinkCode fallback으로 정상 연결된 사례 + { Code: 'r5', LinkCode: 'E1' }, + // 잘못된 TargetKey 사례 + { Code: 'r6', TargetKey: 'id:999' } + ] + } + ]; + p.quantities = [ + { Code: 'm1', TargetKey: 'id:202' }, + { Code: 'm2', TargetCode: 'MISSING_M' } + ]; + return EstIssues.scan(p).filter(x => x.table === 'quantity'); + })()""", + ) + items = {(row["code"], row["message"], row["sheetCode"]) for row in result} + # r1: 정상 (E1 단일 매칭) -> 오류 없음 + # r4: 정상 (DUP이지만 TargetKey id:201 지정) -> 오류 없음 + # r5: 정상 (LinkCode E1 지정) -> 오류 없음 + # m1: 정상 (수식표 id:202 지정) -> 오류 없음 + assert ("r2", "수량 대상 내역 없음: MISSING", "Q1") in items + assert ("r3", "수량 대상 내역 중복: DUP", "Q1") in items + assert ("r6", "수량 대상 내역 없음: id:999", "Q1") in items + assert ("m2", "수량 대상 내역 없음: MISSING_M", "manual") in items + assert len(items) == 4 + + +def test_edge_issues_click_quantity_navigates_to_sheet(): + from playwright.sync_api import sync_playwright + import pytest + + 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) + except Exception: + pytest.skip("EST 5175 server not reachable") + + page.wait_for_function("() => Boolean(EstApp.state.project)") + + # 오류 검색 화면으로 전환 및 인위적 오류 수량산출서 주입 + page.evaluate("""() => { + EstApp.change(p => { + p.quantitySheets ||= []; + p.quantitySheets.push({ + Code: 'TEST_ERR_Q', + Name: '오류 테스트 산출서', + rows: [{ LineData: '1 = \\x120\\x12', markers: ['e_r1'] }], + items: [{ Code: 'e_r1', TargetCode: 'NON_EXISTENT_CODE_XYZ', GN: 10 }] + }); + }, false); + EstApp.navigate('issues'); + }""") + + # 오류 검색 화면에서 '산출서 열기' 버튼 탐색 및 클릭 + btn = page.locator("button[data-issue-quantity='TEST_ERR_Q']") + assert btn.count() >= 1 + btn.first.click() + + # 수량산출 화면으로 전환되었고 해당 산출서가 활성화되었는지 확인 + page.wait_for_function("() => EstApp.state.view === 'quantity'") + current_view = page.evaluate("EstApp.state.view") + current_sheet = page.evaluate("EstApp.state.quantitySheet") + + assert current_view == "quantity" + assert current_sheet == "TEST_ERR_Q" + + # 다시 오류 검색 화면으로 돌아가서 바로 내역 연결 드롭다운 선택 시험 + page.evaluate("""() => { + EstApp.navigate('issues'); + }""") + page.wait_for_function("() => EstApp.state.view === 'issues'") + + # select[data-issue-target-select='TEST_ERR_Q'] 요소 확인 및 옵션 선택 + select = page.locator("select[data-issue-target-select='TEST_ERR_Q']") + assert select.count() >= 1 + + # 첫 번째 유효 내역 행의 value를 선택 + options = select.locator("option") + assert options.count() > 1 + target_val = options.nth(1).get_attribute("value") + select.select_option(target_val) + + # 상태 변경이 작업본(project)에 즉시 반영되었는지 확인 + linked_key = page.evaluate("""() => { + const sheet = EstApp.state.project.quantitySheets.find(s => s.Code === 'TEST_ERR_Q'); + return sheet ? sheet.items[0].TargetKey : null; + }""") + assert linked_key == target_val + + browser.close() + + +def test_data_issues_detect_wonga_formula_syntax_and_missing_reference_errors(): + result = run( + "math", + """(()=>{ + const p = structuredClone(project); + const normalIssues = EstIssues.scan(p).filter(x => x.table === 'Wonga'); + + // 원가 계산서 항목에 잘못된 참조 및 잘못된 범위 산식 주입 + const badP = structuredClone(project); + badP.tables.Wonga[5].VSusic = '4 * 0.165 + UNKNOWN_VAR'; + badP.tables.Wonga[21].VSusic = '(6:INVALID_RANGE_END)'; + const badIssues = EstIssues.scan(badP).filter(x => x.table === 'Wonga'); + + return { + normalCount: normalIssues.length, + badIssues + }; + })()""", + ) + assert result["normalCount"] == 0 + items = {(row["index"], row["code"], row["message"]) for row in result["badIssues"]} + assert (5, "5", "참조 항목 없음: UNKNOWN_VAR") in items + assert (21, "C", "범위 참조 없음: 6:INVALID_RANGE_END") in items + assert len(items) == 2 + + +def test_wonga_scan_handles_division_zero_values_cycles_ranges_and_basefield(): + result = run( + "math", + """(()=>{ + // 1) 실제 값이 다른 A, B의 유효한 1/(A-B) 나눗셈 (scan에서 1로 단순 치환하지 않고 실제 값으로 평가해 0으로 나눔 오탐 없음) + const w1 = [{Gubun:'A',Cost:10},{Gubun:'B',Cost:5},{Gubun:'C',VSusic:'1/(A-B)'}]; + const sc1 = EstWonga.scan(w1); + + // 2) 실제 0값 참조 계산 (A가 0이고 A + 10 정상 평가) + const w2 = [{Gubun:'A',Cost:0},{Gubun:'B',VSusic:'A + 10'}]; + const sc2 = EstWonga.scan(w2); + + // 3) 상호 순환 참조 감지 (A -> B -> A) + const w3 = [{Gubun:'A',VSusic:'B'},{Gubun:'B',VSusic:'A'}]; + const sc3 = EstWonga.scan(w3); + + // 4) BaseField 우선: VSusic에 유효하지 않은 식이어도 BaseField가 우선 적용되어 오류 없음 + const w4 = [{Gubun:'A',BaseField:'NC',VSusic:'INVALID_IF_EVALUATED',Cost:0}]; + const sc4 = EstWonga.scan(w4, {}, {NC: 500}); + + // 5) 정상 범위 참조 (A:B 역순/정순 포함) + const w5 = [{Gubun:'B',Cost:10},{Gubun:'A',Cost:20},{Gubun:'C',VSusic:'(A:B)'}]; + const sc5 = EstWonga.scan(w5); + + // 6) 유효하지 않은 범위 참조 + const w6 = [{Gubun:'A',Cost:10},{Gubun:'B',VSusic:'(A:INVALID)'}]; + const sc6 = EstWonga.scan(w6); + + return { + sc1, sc2, sc3, sc4, sc5, sc6 + }; + })()""", + ) + # 1) A!=B 나눗셈: 오탐 0건 + assert result["sc1"] == [] + # 2) 0값 참조: 오탐 0건 + assert result["sc2"] == [] + # 3) 순환 참조: calculate와 동일하게 순환 탐지 + assert any("원가 수식 순환 참조" in item["message"] for item in result["sc3"]) + assert len(result["sc3"]) >= 1 + # 4) BaseField 우선: 수식 무시되고 기본 비목 매핑되어 오탐 0건 + assert result["sc4"] == [] + # 5) 정상 범위 (A:B): 정상 합산되어 오탐 0건 + assert result["sc5"] == [] + # 6) 비정상 범위: 오류 정확 탐지 + assert any("범위 참조 없음: A:INVALID" in item["message"] for item in result["sc6"]) + assert len(result["sc6"]) == 1 + + +def test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles(): + result = run( + "math", + """(()=>{ + // 1) 전방 참조 및 오래된 Cost: + // B의 수식이 'A*2'로 실제 값은 20이지만 저장된 Cost=0 상태. + // C가 '100/B'를 계산할 때, 오래된 Cost(0)를 쓰면 0으로 나눔 오류이지만, + // calculate와 동일하게 재귀 평가 시 B=20이 되어 C=5 정상 계산되고 오탐 없어야 함. + const w1 = [ + {Gubun:'B',Cost:0,VSusic:'A*2'}, + {Gubun:'A',Cost:10}, + {Gubun:'C',VSusic:'100/B'} + ]; + const calc1 = EstWonga.calculate(w1); + const scan1 = EstWonga.scan(w1); + + // 2) 기초 비목 0 및 양수 전달: + // NC=1000이 전달되면 BaseField: 'NC' 또는 식 내 'NC' 참조가 정상 작동. + // scan에서 bases가 전달되지 않아 0으로 판정되는 문제가 없어야 함. + const w2 = [ + {Gubun:'A',BaseField:'NC',Cost:0}, + {Gubun:'B',VSusic:'NC * 0.1'}, + {Gubun:'C',VSusic:'1000 / B'} + ]; + const bases = { NC: 1000, 직접공사비: 1000 }; + const calc2 = EstWonga.calculate(w2, {}, bases); + const scan2 = EstWonga.scan(w2, {}, bases); + + // 3) 구간 제외 행(CalcIndex: 2 또는 3): + // 범위 (A:B)에서 A가 CalcIndex: 2(제외)인 경우 합산에서 제외되나, + // 순환 참조 및 범위 유효성 규칙은 calculate와 일치. + const w3 = [ + {Gubun:'A',Cost:10,CalcIndex:2}, + {Gubun:'B',Cost:20,CalcIndex:0}, + {Gubun:'C',VSusic:'(A:B)'} + ]; + const calc3 = EstWonga.calculate(w3); + const scan3 = EstWonga.scan(w3); + + // 4) 순환 참조 일치성: + // calculate에서 발생하는 순환 오류와 scan의 오류 메시지가 동일 + const w4 = [ + {Gubun:'X',VSusic:'Y'}, + {Gubun:'Y',VSusic:'X'} + ]; + const calc4 = EstWonga.calculate(w4); + const scan4 = EstWonga.scan(w4); + + // 5) issues.scan(project)에서 project.calculation.direct 기초금액 전달 검증 + const project = EstNative.empty(); + project.tables.Wonga = [ + {Gubun:'M',BaseField:'NC',Cost:0}, + {Gubun:'N',VSusic:'NC * 0.5'}, + {Gubun:'O',VSusic:'500 / N'} + ]; + project.calculation = { + direct: { NC: 1000, GC: 0, JC: 0, SC: 0, TC: 1000 } + }; + const projectIssues = EstIssues.scan(project).filter(i => i.table === 'Wonga'); + + return { + calc1Errors: calc1.errors, + scan1, + calc1CostB: calc1.rows[0].Cost, + calc1CostC: calc1.rows[2].Cost, + calc2Errors: calc2.errors, + scan2, + calc3Errors: calc3.errors, + scan3, + calc3CostC: calc3.rows[2].Cost, + calc4Errors: calc4.errors, + scan4, + projectIssues + }; + })()""", + ) + # 1) 전방 참조 & 오래된 Cost(0): calculate와 scan 모두 오류 0건, 정확한 값 평가 + assert result["calc1Errors"] == [] + assert result["scan1"] == [] + assert result["calc1CostB"] == 20 + assert result["calc1CostC"] == 5 + + # 2) 기초 비목 양수 전달: bases 전달로 0 나눔 없이 정상 평가 + assert result["calc2Errors"] == [] + assert result["scan2"] == [] + + # 3) 구간 제외 행(CalcIndex: 2): A=10 제외, B=20만 합산되어 C=20, scan 오류 0건 + assert result["calc3Errors"] == [] + assert result["scan3"] == [] + assert result["calc3CostC"] == 20 + + # 4) 순환 참조: calculate와 scan 메시지 일치 + assert "원가 수식 순환 참조: X" in result["calc4Errors"][0] + assert any("원가 수식 순환 참조" in item["message"] for item in result["scan4"]) + + # 5) project.calculation.direct를 통한 issues.scan 연동: 오탐 0건 + assert result["projectIssues"] == [] + + +def test_data_issues_detect_unite_mismatch_and_sync_source_names(): + result = run( + "math", + """(()=>{ + const p = EstNative.empty(); + p.tables.LaborcDat = [ + { Code: "L001", Name: "보통인부", Size: "공통", Unite: "인" }, + { Code: "L002", Name: "특별인부", Size: "숙련(원천)", Unite: "인" }, + { Code: "L003", Name: "용접공", Size: "1급", Unite: "인" } + ]; + p.tables.EstMainDat = [ + // 1) 품명 불일치 (원천 규격, 단위와 동일) + { Code: "L001", LinkIndex: 6, Name: "보통 인부(오타)", Size: "공통", Unite: "인" }, + // 2) 규격 불일치 + { Code: "L002", LinkIndex: 6, Name: "특별인부", Size: "숙련(내역)", Unite: "인" }, + // 3) 단위 불일치 (일괄 반영 대상에서 제외되어야 함) + { Code: "L003", LinkIndex: 6, Name: "용접공", Size: "1급", Unite: "시간" } + ]; + + const beforeScan = EstIssues.scan(p); + const nameIssue = beforeScan.find(i => i.message === "연결 품명 차이"); + const sizeIssue = beforeScan.find(i => i.code === "L002" && i.message === "연결 규격 차이"); + const uniteIssue = beforeScan.find(i => i.message === "연결 단위 차이"); + + return { + hasNameIssue: !!nameIssue, + hasSizeIssue: !!sizeIssue, + hasUniteIssue: !!uniteIssue, + nameSource: nameIssue?.sourceName, + sizeSource: sizeIssue?.sourceSize, + uniteSource: uniteIssue?.sourceUnite + }; + })()""", + ) + assert result["hasNameIssue"] is True + assert result["hasSizeIssue"] is True + assert result["hasUniteIssue"] is True + assert result["nameSource"] == "보통인부" + assert result["sizeSource"] == "숙련(원천)" + assert result["uniteSource"] == "인" + + +def test_data_issues_selective_sync_preserves_unit_and_non_empty_size(): + from playwright.sync_api import sync_playwright + import pytest + + 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) + except Exception: + pytest.skip("EST 5175 server not reachable") + page.wait_for_selector("body") + page.wait_for_function("() => EstApp && EstApp.state && EstApp.state.project !== null") + + # 테스트용 데이터 주입 + page.evaluate("""() => { + EstApp.change(p => { + p.tables.LaborcDat ||= []; + p.tables.LaborcDat.push( + { Code: "TEST_L1", Name: "원천 보통인부", Size: "원천 규격A", Unite: "인" }, + { Code: "TEST_L2", Name: "원천 특별인부", Size: "원천 규격B(신규)", Unite: "인" }, + { Code: "TEST_L3", Name: "원천 용접공", Size: "원천 규격C", Unite: "인" } + ); + p.tables.EstMainDat ||= []; + p.tables.EstMainDat.push( + // 1) 품명만 다름 (내역 규격은 동일하므로 품명만 동기화) + { Code: "TEST_L1", LinkIndex: 6, Name: "내역 보통인부(오타)", Size: "원천 규격A", Unite: "인" }, + // 2) 규격 차이 (원천 규격으로 동기화) + { Code: "TEST_L2", LinkIndex: 6, Name: "원천 특별인부", Size: "내역 구규격", Unite: "인" }, + // 3) 단위 차이 (일괄 반영 대상에서 제외되어야 함) + { Code: "TEST_L3", LinkIndex: 6, Name: "원천 용접공", Size: "원천 규격C", Unite: "시간" } + ); + }, false); + EstApp.navigate('issues'); + }""") + + page.wait_for_function("() => EstApp.state.view === 'issues'") + + # 1) '불일치 원천 명칭/규격 일괄 반영' 버튼 클릭 + sync_all_btn = page.locator("button[data-issue-sync-all='1']") + assert sync_all_btn.count() >= 1 + sync_all_btn.click() + + # 일괄 반영 후 결과 점검 + state1 = page.evaluate("""() => { + const est = EstApp.state.project.tables.EstMainDat; + const r1 = est.find(r => r.Code === "TEST_L1"); + const r2 = est.find(r => r.Code === "TEST_L2"); + const r3 = est.find(r => r.Code === "TEST_L3"); + return { + r1: { Name: r1.Name, Size: r1.Size, Unite: r1.Unite }, + r2: { Name: r2.Name, Size: r2.Size, Unite: r2.Unite }, + r3: { Name: r3.Name, Size: r3.Size, Unite: r3.Unite } + }; + }""") + + # TEST_L1: 품명은 원천으로 동기화, 규격/단위는 일치 유지 + assert state1["r1"]["Name"] == "원천 보통인부" + assert state1["r1"]["Size"] == "원천 규격A" + assert state1["r1"]["Unite"] == "인" + + # TEST_L2: 유효한 원천 규격으로 동기화됨 + assert state1["r2"]["Size"] == "원천 규격B(신규)" + + # TEST_L3: 단위 불일치는 일괄 반영에서 제외되어 "시간" 그대로 유지됨 + assert state1["r3"]["Unite"] == "시간" + + # 2) TEST_L3의 '원천단위 반영' 개별 버튼 명시적 클릭 확인 + sync_unit_btn = page.locator("button[data-issue-sync-msg='연결 단위 차이']") + assert sync_unit_btn.count() >= 1 + assert "원천단위 반영" in sync_unit_btn.first.inner_text() + sync_unit_btn.first.click() + + # 개별 반영 후 단위가 동기화되었는지 확인 + r3_unite_after = page.evaluate("""() => { + const est = EstApp.state.project.tables.EstMainDat; + const r3 = est.find(r => r.Code === "TEST_L3"); + return r3.Unite; + }""") + assert r3_unite_after == "인" + + # 3) 실행 취소(Undo)로 이전 복원 검증 + page.evaluate("EstActions.undo()") + r3_unite_undone = page.evaluate("""() => { + const est = EstApp.state.project.tables.EstMainDat; + const r3 = est.find(r => r.Code === "TEST_L3"); + return r3.Unite; + }""") + assert r3_unite_undone == "시간" + + browser.close() + + +def test_data_issues_detect_percentage_unit_mismatch(): + result = run( + "math", + """(()=>{ + const p = structuredClone(project); + p.tables.HeadMainDat.push( + { Index: 0, Level: 0, LinkIndex: 58, Code: "R1", Name: "비율항목1", Unite: "식", GN: 5 }, + { Index: 0, Level: 0, LinkIndex: 62, Code: "R2", Name: "구간비율2", Unite: "M", GN: 10 }, + { Index: 0, Level: 0, LinkIndex: 52, Code: "R3", Name: "낙찰률3", Unite: "%", GN: 87.745 }, + { Index: 0, Level: 0, LinkIndex: 67, Code: "R4", Name: "제잡비4", Unite: "%", GN: 3 } + ); + p.tables.EstMainDat.push( + { Level: 0, LinkIndex: 57, Code: "R5", Name: "조달수수료5", Unite: "원", GN: 1 } + ); + const issues = EstIssues.scan(p).filter(x => x.message === "단위 백분율(%) 불일치"); + return issues.map(x => ({ table: x.table, code: x.code, message: x.message, rowUnite: x.rowUnite })); + })()""", + ) + assert len(result) == 3 + items = {(x["table"], x["code"], x["rowUnite"]) for x in result} + assert ("HeadMainDat", "R1", "식") in items + assert ("HeadMainDat", "R2", "M") in items + assert ("EstMainDat", "R5", "원") in items + diff --git a/resources/tester/test_est_native_export.py b/resources/tester/test_est_native_export.py index 833bc728e..a205d6d82 100644 --- a/resources/tester/test_est_native_export.py +++ b/resources/tester/test_est_native_export.py @@ -96,7 +96,7 @@ def test_estx_quantity_item_labels_round_trip_when_sample_exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'); const p=m.importEstx(fs.readFileSync(process.argv[1])),before=structuredClone(p.quantitySheets),sheet=p.quantitySheets[0]; - sheet.items[0].Name='수정된 품명';sheet.items[0].Size='수정 규격'; + sheet.items[0].Name='수정된 품명';sheet.items[0].Size='수정 규격';sheet.items[0].Unite='m2'; const mismatch=p.quantitySheets.find(s=>s.Code==='N0000000083');mismatch.items[4].Name='수량 보존'; const q=m.importEstx(e.exportEstx(p)),item=q.quantitySheets[0].items[0]; const restored=q.quantitySheets.find(s=>s.Code==='N0000000083'); @@ -104,7 +104,7 @@ def test_estx_quantity_item_labels_round_trip_when_sample_exists(): const rest=JSON.stringify(q.quantitySheets.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code))===JSON.stringify(before.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code)); const preserved=restored.items[4].Name==='수량 보존' && restored.items[4].GN===286 && JSON.stringify(restored.rows)===JSON.stringify(before.find(s=>s.Code===mismatch.Code).rows); p.quantitySheets[0].items[0].TargetCode='new target';let rejected=false;try{e.exportEstx(p)}catch{rejected=true} - console.log(JSON.stringify({name:item.Name,size:item.Size,other,rest,preserved,rejected}));""" + console.log(JSON.stringify({name:item.Name,size:item.Size,unite:item.Unite,other,rest,preserved,rejected}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode( "utf-8" @@ -113,6 +113,7 @@ def test_estx_quantity_item_labels_round_trip_when_sample_exists(): assert result == { "name": "수정된 품명", "size": "수정 규격", + "unite": "m2", "other": True, "rest": True, "preserved": True, @@ -156,6 +157,18 @@ def test_quantity_document_edit_across_character_styles_preserves_tags(): assert result == {"same": True, "tags": True} +def test_quantity_document_edit_across_styles_and_superscript_preserves_structure(): + script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); + const doc='
abc2def
'; + const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old); + edited.rows[0].LineData='abc^(3)xyz'; + const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}); + process.stdout.write(JSON.stringify({same:JSON.stringify(again.rows)===JSON.stringify(edited.rows),doc:next}));""" + result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) + assert result["same"] is True + assert "3xyz" in result["doc"] + + def test_quantity_document_edit_across_styles_and_separate_marker(): script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const doc='
abcdef6
'; @@ -253,21 +266,35 @@ def test_estx_options_and_expanded_blob_preserve_unrelated_data_when_sample_exis def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists(): - sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + if not sample.exists(): + sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx") + if not sample.exists(): + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): pytest.skip("installed EST sample unavailable") script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs'); - const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),q=s.calculate(p); - const ids=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=>p.tables[key].every((r,i)=>r.PkID===q.tables[key][i].PkID)); - const row=q.tables.EstMainDat.find(r=>r.GN&&r.TC);row.GN+=1; - const edited=s.calculate(q),out=e.exportEstx(edited),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out); + const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes); + const hasQuantity=Boolean(p.quantitySheets?.[0]?.items?.[0]); + if (hasQuantity) p.quantitySheets[0].items[0].Unite = 'm2'; + const q=s.calculate(p); + const memoKeys=['DifineMemo','SpaceMemo','EscData']; + const preserved=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=> + p.tables[key].every((r,i)=>{ + const target=q.tables[key][i]; + return r.PkID===target.PkID && r.Index===target.Index && r.RecNo===target.RecNo && r.Flag===target.Flag && + memoKeys.every(k=>JSON.stringify(r[k])===JSON.stringify(target[k])); + }) + ); + const out=e.exportEstx(q),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out); const indexes=[...original].filter(([name])=>name.endsWith('.idx')).every(([name,value])=>value.equals(files.get(name))); - process.stdout.write(JSON.stringify({ids,indexes,gn:again.tables.EstMainDat.find(r=>r.PkID===row.PkID).GN,expected:row.GN}));""" + const reimportedUnite=hasQuantity ? again.quantitySheets?.[0]?.items?.[0]?.Unite : 'm2'; + process.stdout.write(JSON.stringify({preserved,indexes,reimportedUnite}));""" result = json.loads( subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") ) - assert result["ids"] and result["indexes"] - assert result["gn"] == result["expected"] + assert result["preserved"] and result["indexes"] + assert result["reimportedUnite"] == "m2" def fixture(path): @@ -325,3 +352,53 @@ def test_estx_patches_values_and_preserves_indexes_drawings_and_rejects_structur assert result["same"] and result["blocked"] and result["index"] and result["drawing"] assert result["row"]["Name"] == "변경" assert result["row"]["GN"] == 3 and result["row"]["TC"] == 300 + + +def test_estx_export_schedule_and_documents_safety_guards(tmp_path): + source = tmp_path / "native.estx" + fixture(source) + script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'); + const b=fs.readFileSync(process.argv[1]); + + // 1) 원본 그대로(빈 schedule/빈 documents/null 등)는 정상 통과 + const p1 = m.importEstx(b); + p1.schedule = []; + p1.documents = {}; + const same1 = e.exportEstx(p1).equals(b); + + // 2) schedule 항목 추가 시 차단 검증 + let blockedSchedule = false, schedErrMsg = ''; + const p2 = m.importEstx(b); + p2.schedule = [{id:'1', Code:'A', Name:'새공정', start:'2026-01-01', end:'2026-01-05', weight:100, progress:10}]; + try { e.exportEstx(p2); } catch (err) { blockedSchedule = true; schedErrMsg = err.message; } + + // 2-1) 빈 문자열 또는 0값만 있는 schedule 항목 추가 시도 차단 검증 + let blockedEmptySchedule = false; + const p2Empty = m.importEstx(b); + p2Empty.schedule = [{id:'2', Code:'', Name:'', start:'', end:'', weight:0, progress:0}]; + try { e.exportEstx(p2Empty); } catch (err) { blockedEmptySchedule = true; } + + // 3) documents 텍스트 입력 시 차단 검증 + let blockedDocs = false, docErrMsg = ''; + const p3 = m.importEstx(b); + p3.documents = {'173': '심사사유'}; + try { e.exportEstx(p3); } catch (err) { blockedDocs = true; docErrMsg = err.message; } + + // 3-1) documents 공백(" ") 입력 시도 사용자 데이터 보존을 위해 차단 검증 + let blockedSpaceDocs = false; + const p3Space = m.importEstx(b); + p3Space.documents = {'173': ' '}; + try { e.exportEstx(p3Space); } catch (err) { blockedSpaceDocs = true; } + + process.stdout.write(JSON.stringify({same1, blockedSchedule, schedErrMsg, blockedEmptySchedule, blockedDocs, docErrMsg, blockedSpaceDocs}));""" + result = json.loads( + subprocess.check_output(["node", "-e", script, str(source)], cwd=ROOT).decode("utf-8") + ) + assert result["same1"] is True + assert result["blockedSchedule"] is True + assert "예정공정표" in result["schedErrMsg"] and "JSON" in result["schedErrMsg"] + assert result["blockedEmptySchedule"] is True + assert result["blockedDocs"] is True + assert "서식 문서" in result["docErrMsg"] and "JSON" in result["docErrMsg"] + assert result["blockedSpaceDocs"] is True + assert "서식 문서" in result["docErrMsg"] and "JSON" in result["docErrMsg"] diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index 3ababc872..acecbba2a 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -141,8 +141,18 @@ def test_create_edit_apply_and_delete_quantity_sheet(): click('[data-quantity-new]');click('[data-quantity-add]'); change({quantityName:''},'Earthwork'); change({quantityItem:'Name',index:'0'},'excavation'); + change({quantityItem:'Unite',index:'0'},'m3'); change({quantityTarget:'0'},'X'); change({quantityLine:'0'},'2*3 = ⟦결과⟧0⟦결과⟧'); + click('[data-quantity-add-item]'); + const withNewItem = state.project.quantitySheets[0].items.length; + // 참조 중인 0번 항목 삭제 시도 -> 차단 및 오류 메시지 설정 확인 + click('[data-quantity-delete-item]', { quantityDeleteItem: '0' }); + const noticeBlocked = state.notice; + const blockedCount = state.project.quantitySheets[0].items.length; + // 미참조 항목인 1번 항목 삭제 시도 -> 정상 삭제 + click('[data-quantity-delete-item]', { quantityDeleteItem: '1' }); + const afterItemDeleted = state.project.quantitySheets[0].items.length; const saved=JSON.parse(JSON.stringify(state.project)); click('[data-quantity-apply]'); const applied=state.project.tables.EstMainDat[0].GN; @@ -151,11 +161,16 @@ def test_create_edit_apply_and_delete_quantity_sheet(): click('[data-quantity-delete-row]',{quantityDeleteRow:'0'}); const empty=[sheet.rows.length,sheet.items.length]; click('[data-quantity-delete-sheet]'); - return {saved,applied,computed,empty,remaining:state.project.quantitySheets.length,selection:state.quantitySheet}; + return {saved,applied,computed,empty,withNewItem,blockedCount,afterItemDeleted,noticeBlocked,remaining:state.project.quantitySheets.length,selection:state.quantitySheet}; })()""", ) + assert result["withNewItem"] == 2 + assert result["blockedCount"] == 2 + assert "연결된 산출 행을 삭제하거나" in result["noticeBlocked"] + assert result["afterItemDeleted"] == 1 assert result["saved"]["quantitySheets"][0]["Name"] == "Earthwork" assert result["saved"]["quantitySheets"][0]["items"][0]["Name"] == "excavation" + assert result["saved"]["quantitySheets"][0]["items"][0]["Unite"] == "m3" assert result["computed"]["errors"] == [] assert result["computed"]["items"][0]["GN"] == 6 assert result["applied"] == 6 diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py index 8cb1539fc..20250ec77 100644 --- a/resources/tester/test_est_review_workflow.py +++ b/resources/tester/test_est_review_workflow.py @@ -125,3 +125,86 @@ def test_fixed_review_preserves_amount_for_reason_or_quantity_only(): })()""", ) assert result == [100, 100, 20] + + +def test_review_view_filtering_and_batch_reason_application(): + result = run( + "math", + """(()=>{ + const handlers={},p=EstNative.empty(); + p.tables.EstMainDat=[ + {PkID:1,Code:'A01',Name:'Item1',GN:10,GU:100,TC:1000}, + {PkID:2,Code:'A02',Name:'Item2',GN:5,GU:200,TC:1000}, + {PkID:3,Code:'A03',Name:'Item3',GN:2,GU:300,TC:600} + ]; + p.contractReview={ + edits:{ + 'row:1':{GN:8,Reason:'수량조정'}, + 'row:2':{GU:180} + } + }; + let html=''; + const domNodes = [ + {dataset:{key:'row:1',review:'Reason'}}, + {dataset:{key:'row:2',review:'Reason'}}, + {dataset:{key:'row:3',review:'Reason'}} + ]; + globalThis.document={ + addEventListener:(name,fn)=>handlers[name]=fn, + querySelectorAll:(sel)=>domNodes + }; + globalThis.prompt=(msg)=>'일괄사유적용'; + globalThis.EstApp={ + state:{project:p,reviewFilter:'all',reviewSelection:new Set()}, + change:fn=>fn(), + render:()=>{}, + byId:()=>({innerHTML:h=>{html=h;},set innerHTML(h){html=h;}}), + esc:s=>String(s||''), + number:n=>String(n||0) + }; + EstReview.render(EstApp.state); + const allRowsCount = (html.match(/ 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 @@ 내역서
{ + try { + if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); + } catch {} +}); + +function log(message) { + const line = `[${new Date().toISOString()}] ${message}\n`; + try { + if (fs.existsSync(logFile) && fs.statSync(logFile).size > MAX_LOG_BYTES) { + const old = fs.readFileSync(logFile, "utf8"); + fs.writeFileSync(logFile, old.slice(-MAX_LOG_BYTES / 2), "utf8"); + } + fs.appendFileSync(logFile, line, "utf8"); + } catch {} +} + +log(`감시 시작 (PID: ${process.pid})`); + +let lastAntigravityState = null; +let lastHealthStatus = null; +let notifiedDone = false; +let notifiedFailure = false; + +const ORCA_BIN = "C:\\Users\\umsan\\AppData\\Local\\Programs\\orca\\resources\\bin\\orca.exe"; + +function sendOrchestrationNotification(subject, body, type = "status") { + // 민감한 환경변수 노출 방지 및 타임아웃 5초 설정 (shell: false로 공백/인자 안정성 확보) + execFile( + ORCA_BIN, + [ + "orchestration", + "send", + "--to", + `run:${TARGET_RUN}`, + "--type", + type, + "--subject", + subject, + "--body", + body, + "--json", + ], + { timeout: 5000, shell: false }, + (error, stdout, stderr) => { + if (error) { + log(`Orchestration 알림 실패: ${error.message}`); + return; + } + try { + const res = JSON.parse(stdout); + if (res.ok) { + log(`Orchestration 알림 성공: messageId=${res.result?.messageId}`); + } else { + log(`Orchestration 알림 거부: ${JSON.stringify(res.error || res)}`); + } + } catch (e) { + log(`Orchestration 응답 파싱 오류: ${stdout}`); + } + }, + ); +} + +function checkOrcaAntigravity() { + execFile( + ORCA_BIN, + ["worktree", "ps", "--json"], + { timeout: 4000, maxBuffer: 1024 * 1024, shell: false }, + (error, stdout) => { + if (error) { + if (lastAntigravityState !== `error:${error.message}`) { + lastAntigravityState = `error:${error.message}`; + log(`Orca 확인 실패: ${error.message}`); + } + return; + } + try { + const data = JSON.parse(stdout); + const codexWorktree = data.result?.worktrees?.find((w) => + /D:\/aislo-wt\/CODEX/i.test(w.path || ""), + ); + const antigravityAgent = codexWorktree?.agents?.find((a) => a.agentType === "antigravity"); + const currentState = antigravityAgent?.state || codexWorktree?.status || "unknown"; + + if (currentState !== lastAntigravityState) { + const prevState = lastAntigravityState; + lastAntigravityState = currentState; + log(`안티그래비티 작업 상태 변경: ${prevState} -> ${currentState}`); + + // 안티그래비티 완료/대기 전환 시 단발성 자동 알림 + if ((currentState === "done" || currentState === "idle") && !notifiedDone) { + notifiedDone = true; + sendOrchestrationNotification( + "안티그래비티 작업 완료 보고", + `안티그래비티 상태가 ${currentState}로 전이되었습니다. 검토를 재개할 수 있습니다.`, + "status", + ); + } else if (currentState === "working") { + notifiedDone = false; // 다시 작업 중이 되면 플래그 리셋 + } + } + } catch (parseErr) { + if (lastAntigravityState !== `parse-error:${parseErr.message}`) { + lastAntigravityState = `parse-error:${parseErr.message}`; + log(`Orca JSON 파싱 오류: ${parseErr.message}`); + } + } + }, + ); +} + +function checkServerHealth() { + const req = http.get("http://100.65.141.15:5175/api/health", { timeout: 3000 }, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + const statusStr = `HTTP ${res.statusCode}`; + if (statusStr !== lastHealthStatus) { + lastHealthStatus = statusStr; + log(`EST 5175 서버 상태 변경: ${statusStr} (${data.trim()})`); + } + if (res.statusCode === 200) { + notifiedFailure = false; + } else if (!notifiedFailure) { + notifiedFailure = true; + sendOrchestrationNotification( + "EST 5175 서버 장애 경고", + `EST 독립 서버 응답이 비정상입니다 (${statusStr}). 확인이 필요합니다.`, + "status", + ); + } + }); + }); + + req.on("error", (err) => { + const errStr = `error:${err.message}`; + if (errStr !== lastHealthStatus) { + lastHealthStatus = errStr; + log(`EST 5175 접속 실패: ${err.message}`); + } + if (!notifiedFailure) { + notifiedFailure = true; + sendOrchestrationNotification( + "EST 5175 서버 접속 불가", + `EST 독립 서버 연결에 실패했습니다 (${err.message}).`, + "status", + ); + } + }); + + req.on("timeout", () => { + req.destroy(); + const timeoutStr = "timeout"; + if (timeoutStr !== lastHealthStatus) { + lastHealthStatus = timeoutStr; + log("EST 5175 응답 시간 초과"); + } + if (!notifiedFailure) { + notifiedFailure = true; + sendOrchestrationNotification( + "EST 5175 서버 응답 시간 초과", + "EST 독립 서버 헬스 체크 응답 시간이 초과되었습니다.", + "status", + ); + } + }); +} + +function poll() { + checkOrcaAntigravity(); + checkServerHealth(); +} + +poll(); +setInterval(poll, 15000); diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 022ab14d8..8e9861ff1 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -3,7 +3,6 @@ const EstActions = (() => { clipboard = [], clipboardTable = "", restoring = false; - const uid = () => EstApp.uid(); function form(title, fields, callback) { const { esc, showDialog } = EstApp; showDialog( @@ -422,7 +421,12 @@ const EstActions = (() => { ); }, ); - else if (name === "add-quantity") + else if (name === "restore-bid") { + if (confirm("당초단가(100%)로 복원하시겠습니까?")) + EstApp.change(() => { + state.project = EstBusiness.restoreBid(project); + }); + } else if (name === "add-quantity") EstApp.change(() => { project.quantities.push({ Code: EstQuantity.newCode(project.quantities), @@ -438,7 +442,7 @@ const EstActions = (() => { EstApp.change(() => EstQuantity.applyTotals(project, totals)); } else if (name === "add-round") { const next = { - id: uid(), + id: EstApp.uid(), name: EstBusiness.newProgressName(project.progress), inputs: {}, approved: false, @@ -659,19 +663,16 @@ const EstActions = (() => { EstApp.render(); }); EstApp.byId("previous-page").addEventListener("click", () => { - EstApp.state.page--; - EstApp.render(); + EstApp.state.page--; EstApp.render(); }); EstApp.byId("next-page").addEventListener("click", () => { - EstApp.state.page++; - EstApp.render(); + EstApp.state.page++; EstApp.render(); }); document.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); EstApp.save().catch((error) => { - EstApp.state.notice = error.message; - EstApp.render(); + EstApp.state.notice = error.message; EstApp.render(); }); } }); @@ -694,5 +695,5 @@ const EstActions = (() => { EstAttachments.install(); EstStandard.install(); } - return { install, form }; + return { install, form, undo }; })(); diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index dd37b5584..92005890a 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -1,5 +1,37 @@ const EstApp = (() => { - const CACHE = "est-web-v2-working"; + function uid() { + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0, + v = c === "x" ? r : (r & 3) | 8; + return v.toString(16); + }); + } + // 탭별 인스턴스 격리 식별자: + // 1) 같은 탭 내 새로고침 시: sessionStorage 및 window.name이 보존되므로 동일 인스턴스로 복원 + // 2) 탭 복제(Duplicate tab) 시: Chromium 브라우저는 sessionStorage를 그대로 복제하지만 window.name은 빈 문자열("")로 리셋됨. + // 따라서 window.name과 sessionStorage가 불일치하면 복제된 새 탭으로 감지하고 새 tabId를 발급하여 이전 탭의 캐시를 복제한 후 분리 격리함. + const TAB_KEY = "est-tab-instance-id"; + const storage = typeof sessionStorage !== "undefined" ? sessionStorage : null; + const win = typeof window !== "undefined" ? window : { name: "" }; + let currentTabId = storage ? storage.getItem(TAB_KEY) : null; + const isDuplicateTab = currentTabId && (!win.name || win.name !== currentTabId); + const parentTabId = isDuplicateTab ? currentTabId : null; + + if (!currentTabId || isDuplicateTab) { + currentTabId = uid(); + if (storage) storage.setItem(TAB_KEY, currentTabId); + } + win.name = currentTabId; + + const CACHE = `est-web-v2-working-${currentTabId}`; + const LEGACY_CACHE = "est-web-v2-working"; const state = { project: null, view: "EstMainDat", @@ -38,6 +70,14 @@ const EstApp = (() => { attachments: "첨부파일", standard: "표준시장단가 사용 내역", "standard-change": "표준시장단가 설계변경", + "estimate-usage": "견적단가 사용 내역", + "estimate-change": "견적단가 설계변경", + "material-usage": "자재단가 사용 내역", + "material-change": "자재단가 설계변경", + "labor-usage": "노임단가 사용 내역", + "labor-change": "노임단가 설계변경", + "machine-usage": "기계경비 사용 내역", + "machine-change": "기계경비 설계변경", drawing: "도면·문서", }; const esc = (value) => @@ -47,13 +87,6 @@ const EstApp = (() => { ); const number = (value) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(Number(value) || 0); - function uid() { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6] & 15) | 64; - bytes[8] = (bytes[8] & 63) | 128; - const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; - } const byId = (id) => document.getElementById(id); async function api(url, options = {}) { const response = await fetch(url, options), @@ -61,13 +94,84 @@ const EstApp = (() => { if (!response.ok) throw Error(data.error || `요청 실패: ${response.status}`); return data; } - function cache(serialized) { + let cacheQueue = Promise.resolve(); + function openIdb() { + return new Promise((resolve) => { + if (!globalThis.indexedDB) return resolve(null); + const req = indexedDB.open("est-independent-cache", 1); + req.onupgradeneeded = () => req.result.createObjectStore("cache"); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + }); + } + let idbPromise = null; + function getIdb() { + idbPromise ||= openIdb(); + return idbPromise; + } + function waitForTx(tx) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(true); + tx.onerror = () => reject(tx.error || Error("IndexedDB 트랜잭션 실패")); + tx.onabort = () => reject(Error("IndexedDB 트랜잭션 중단")); + }); + } + async function saveToStorage(key, value) { try { - sessionStorage.setItem(CACHE, serialized ?? JSON.stringify(state.project)); + sessionStorage.setItem(key, value); + const db = await getIdb(); + if (db) { + const tx = db.transaction("cache", "readwrite"); + tx.objectStore("cache").delete(key); + await waitForTx(tx).catch(() => {}); + } + return true; } catch { - state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요."; + // sessionStorage 할당 초과(약 5~10MB) 시 IndexedDB로 자동 전환 보관 + try { + const db = await getIdb(); + if (db) { + const tx = db.transaction("cache", "readwrite"); + tx.objectStore("cache").put(value, key); + await waitForTx(tx); + sessionStorage.removeItem(key); + return true; + } + } catch (err) { + console.warn("IndexedDB 저장 실패:", err); + } + return false; } } + async function loadFromStorage(key) { + const fromSession = sessionStorage.getItem(key); + if (fromSession) return fromSession; + try { + const db = await getIdb(); + if (db) { + return new Promise((resolve) => { + const tx = db.transaction("cache", "readonly"); + const req = tx.objectStore("cache").get(key); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => resolve(null); + }); + } + } catch {} + return null; + } + function cache(serialized) { + const payload = serialized ?? JSON.stringify(state.project); + const op = cacheQueue + .catch(() => {}) + .then(async () => { + const ok = await saveToStorage(CACHE, payload); + if (!ok) { + state.notice = "임시 저장 공간을 사용할 수 없습니다. 저장 버튼으로 작업을 보관하세요."; + } + }); + cacheQueue = op; + return op; + } function snapshot() { const serialized = JSON.stringify(state.project); if (serialized.length < 10 * 1024 * 1024 || !globalThis.CompressionStream) return serialized; @@ -279,11 +383,23 @@ const EstApp = (() => { } async function initialize() { try { - const saved = sessionStorage.getItem(CACHE); + let saved = await loadFromStorage(CACHE); + if (!saved && parentTabId) { + // 복제 탭의 경우 부모 탭의 캐시 데이터를 복제하여 초기 상태로 상속 + const parentSaved = await loadFromStorage(`est-web-v2-working-${parentTabId}`); + if (parentSaved) { + saved = parentSaved; + await saveToStorage(CACHE, saved); + } + } + if (!saved) saved = await loadFromStorage(LEGACY_CACHE); setProject(saved ? JSON.parse(saved) : await api("/api/sample")); EstActions.install(); EstQuantity.install(); EstReview.install(); + EstEstimate.install(); + EstBasicPrices.install(); + EstMachine.install(); } catch (error) { byId("messages").hidden = false; byId("messages").textContent = `시작 실패: ${error.message}`; @@ -312,6 +428,8 @@ const EstApp = (() => { open, showDialog, initialize, + parentTabId, + currentTabId, }; })(); document.addEventListener("DOMContentLoaded", () => EstApp.initialize()); diff --git a/tools/est-test/native-basic-prices.js b/tools/est-test/native-basic-prices.js new file mode 100644 index 000000000..a0560f932 --- /dev/null +++ b/tools/est-test/native-basic-prices.js @@ -0,0 +1,196 @@ +const EstBasicPrices = (() => { + let page = 0; + const kinds = { + material: { + key: "material", + table: "StuffcDat", + linkIndex: 5, + unitField: "GU", + costField: "GC", + name: "자재단가", + }, + labor: { + key: "labor", + table: "LaborcDat", + linkIndex: 6, + unitField: "NU", + costField: "NC", + name: "노임단가", + }, + }; + + function config(view) { + if (view === "material" || view === "material-usage" || view === "material-change") + return kinds.material; + if (view === "labor" || view === "labor-usage" || view === "labor-change") return kinds.labor; + return null; + } + + function usage(project, kindKey = "material") { + const cfg = kinds[kindKey]; + if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`); + const sources = new Map(); + for (const row of project.tables[cfg.table] || []) { + if (!row.Code) continue; + const rows = sources.get(row.Code) || []; + rows.push(row); + sources.set(row.Code, rows); + } + return (project.tables.EstMainDat || []).flatMap((row, index) => { + if (Number(row.Level) > 0 || Number(row.LinkIndex) !== cfg.linkIndex) return []; + const matches = sources.get(row.Code) || []; + const source = + matches.length === 1 ? EstNative.price(structuredClone(matches[0]), cfg.linkIndex) : null; + const appliedPrice = + row.TU == null + ? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0) + : EstMath.num(row.TU); + const sourcePrice = source ? EstMath.num(source.TC) : null; + const canApply = + matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6; + return [ + { + index, + Code: row.Code || "", + Name: row.Name || "", + Size: row.Size || "", + Unite: row.Unite || "", + GN: EstMath.num(row.GN), + appliedPrice, + sourcePrice, + difference: source ? appliedPrice - sourcePrice : null, + TC: EstMath.num(row.TC), + status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨", + canApply, + }, + ]; + }); + } + + function changes(project, kindKey = "material") { + const cfg = kinds[kindKey]; + if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`); + const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }; + return EstBusiness.changes(project) + .filter( + (row) => + Number(row.LinkIndex) === cfg.linkIndex || + Number(row.originalLinkIndex) === cfg.linkIndex, + ) + .map((row) => ({ ...row, status: labels[row.status] || row.status })); + } + + function applyRowPrice(project, index, kindKey = "material") { + const cfg = kinds[kindKey]; + if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`); + index = Number(index); + const row = project.tables?.EstMainDat?.[index]; + if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== cfg.linkIndex) + throw Error(`${cfg.name} 내역 행을 찾을 수 없습니다.`); + const sources = (project.tables?.[cfg.table] || []).filter( + (s) => s.Code && s.Code === row.Code, + ); + if (!sources.length) throw Error(`${cfg.name} 원천 자료가 없습니다: ${row.Code}`); + if (sources.length > 1) throw Error(`${cfg.name} 원천 코드가 중복되었습니다: ${row.Code}`); + const source = EstNative.price(structuredClone(sources[0]), cfg.linkIndex); + const sourcePrice = EstMath.num(source?.TC); + if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다."); + + // SCheck 재분류가 설정되어 있는 경우 비목 보존 + const scheck = Number(row.SCheck); + if (scheck > 0) { + const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck]; + for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0; + } else { + // 자재단가는 GU, 노임단가는 NU에 단가 할당 + for (const u of EstMath.units) row[u] = u === cfg.unitField ? sourcePrice : 0; + } + + // AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산 + if (project.options?.AutoCalc !== false) { + EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit); + } else { + row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0); + } + } + + function applyAllPrices(project, kindKey = "material") { + const cfg = kinds[kindKey]; + if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`); + const list = usage(project, kindKey).filter((item) => item.canApply); + if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다."); + for (const item of list) applyRowPrice(project, item.index, kindKey); + return list.length; + } + + function render() { + const { state, esc, number, byId } = EstApp; + const cfg = config(state.view); + if (!cfg) return; + const isChange = state.view.endsWith("-change"); + const rows = isChange ? changes(state.project, cfg.key) : usage(state.project, cfg.key); + page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1)); + const visible = rows.slice(page * 100, page * 100 + 100), + value = (v) => (v == null ? "" : number(v)); + + if (isChange) { + byId("work-content").innerHTML = + `
${cfg.name} 설계변경${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽
${visible.map((row) => ``).join("")}
구분코드품명당초 수량변경 수량당초 금액변경 금액증감작업
${esc(row.status)}${esc(row.Code)}${esc(row.Name)}${value(row.originalGN)}${value(row.GN)}${value(row.originalTC)}${value(row.TC)}${value(row.difference)}${row.index == null ? "" : ``}
`; + return; + } + const canApplyCount = rows.filter((r) => r.canApply).length; + byId("work-content").innerHTML = + `
${cfg.name} 사용 내역${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽

내역에 적용된 단가와 현재 ${cfg.name} 자료를 비교합니다.

${visible.map((row) => ``).join("")}
코드품명규격단위수량내역 단가기초 단가차이금액연결작업
${esc(row.Code)}${esc(row.Name)}${esc(row.Size)}${esc(row.Unite)}${value(row.GN)}${value(row.appliedPrice)}${value(row.sourcePrice)}${value(row.difference)}${value(row.TC)}${esc(row.status)}${row.canApply ? ` ` : ""}
`; + } + + function install() { + document.addEventListener("click", (event) => { + const applyAll = event.target.closest("[data-basic-apply-all]"); + if (applyAll) { + const kindKey = applyAll.dataset.basicApplyAll; + const cfg = kinds[kindKey]; + try { + EstApp.change((p) => { + const count = applyAllPrices(p, kindKey); + EstApp.state.notice = `${cfg?.name || "기초단가"} ${count}건의 단가를 일괄 적용했습니다.`; + }); + } catch (err) { + EstApp.state.notice = err.message; + EstApp.render(); + } + return; + } + const applyRow = event.target.closest("[data-basic-apply-row]"); + if (applyRow) { + const index = Number(applyRow.dataset.basicApplyRow); + const kindKey = applyRow.dataset.kind; + try { + EstApp.change((p) => { + applyRowPrice(p, index, kindKey); + EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`; + }); + } catch (err) { + EstApp.state.notice = err.message; + EstApp.render(); + } + return; + } + const step = event.target.closest("[data-basic-page]"); + if (step) { + page += Number(step.dataset.basicPage); + render(); + return; + } + const node = event.target.closest("[data-basic-row]"); + if (!node) return; + const index = Number(node.dataset.basicRow); + if (!EstApp.state.project.tables.EstMainDat[index]) return; + EstApp.navigate("EstMainDat"); + EstApp.state.page = Math.floor(index / 100); + EstApp.state.selection = new Set([index]); + EstApp.render(); + }); + } + + return { usage, changes, render, install, applyRowPrice, applyAllPrices, kinds }; +})(); diff --git a/tools/est-test/native-block-actions.js b/tools/est-test/native-block-actions.js new file mode 100644 index 000000000..dc7f41937 --- /dev/null +++ b/tools/est-test/native-block-actions.js @@ -0,0 +1,276 @@ +// REA popMainBlockCut, popMainBlockCopy, popMainBlockPaste, popMainBlockDel 및 블럭 범위 지정 (ActionBlockOneSet, 블럭시작/끝) +// 및 popMainSetStr (산출근거 주석 지정/해제), TFRMWONGABASE popupTotal (공사비역산 자리수 맞추기) +const EstBlockActions = (() => { + let blockStart = null; + let blockTable = null; + + function setBlockStart(state) { + const rows = state.project.tables[state.view]; + if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다."); + const selected = [...state.selection]; + if (!selected.length) throw Error("블럭 시작 위치로 지정할 행을 선택하세요."); + blockStart = selected[0]; + blockTable = state.view; + state.notice = `${blockStart + 1}행이 블럭 시작점으로 설정되었습니다. (블럭 끝 행을 선택 후 '블럭 끝'을 누르세요)`; + } + + function setBlockEnd(state) { + const rows = state.project.tables[state.view]; + if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다."); + if (blockStart == null || blockTable !== state.view) { + throw Error("먼저 '블럭 시작'을 지정하세요."); + } + const selected = [...state.selection]; + if (!selected.length) throw Error("블럭 끝 위치로 지정할 행을 선택하세요."); + const blockEnd = selected[0]; + const start = Math.min(blockStart, blockEnd); + const end = Math.max(blockStart, blockEnd); + + state.selection.clear(); + for (let i = start; i <= end; i++) { + state.selection.add(i); + } + state.notice = `${start + 1}행부터 ${end + 1}행까지 총 ${end - start + 1}개 행이 블럭으로 선택되었습니다.`; + blockStart = null; + blockTable = null; + } + + function clearBlockSelection(state) { + state.selection.clear(); + blockStart = null; + blockTable = null; + state.notice = "선택이 해제되었습니다."; + } + + function selectAllRows(state) { + const rows = state.project.tables[state.view]; + if (!rows) throw Error("현재 화면에서는 전체 선택을 할 수 없습니다."); + state.selection.clear(); + for (let i = 0; i < rows.length; i++) { + state.selection.add(i); + } + state.notice = `전체 ${rows.length}개 행이 선택되었습니다.`; + } + + function deleteBlockRows(state) { + const rows = state.project.tables[state.view]; + if (!rows) throw Error("현재 화면에서는 블럭 삭제를 할 수 없습니다."); + const indexes = new Set([...state.selection]); + if (!indexes.size) throw Error("삭제할 블럭 행을 먼저 선택하세요."); + + // 호표/기계경비 그룹이 있는 경우 호표 제목 행 포함 시 전체 호표 행 포함 + if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) { + for (const group of EstNative.groups(rows).values()) { + if (indexes.has(group.start)) { + for (let i = group.start; i < group.start + group.rows.length; i++) { + indexes.add(i); + } + } + } + } + + const codes = new Set( + [...indexes] + .map((i) => rows[i]?.Code) + .filter(Boolean) + .map((c) => String(c).trim()), + ); + const kind = Number( + Object.entries(EstNative.sourceTables).find(([, table]) => table === state.view)?.[0] || + { HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[state.view], + ); + + if ( + kind && + Object.entries(state.project.tables).some(([table, list]) => + list.some((row, i) => { + if (table === state.view && indexes.has(i)) return false; + const rLink = Number(row.LinkIndex); + const rCode = row.Code ? String(row.Code).trim() : ""; + return rLink === kind && rCode && codes.has(rCode); + }), + ) + ) { + throw Error("다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."); + } + + if (!confirm(`${indexes.size}개 행을 블럭 삭제하시겠습니까?`)) return; + + EstApp.change(() => { + [...indexes].sort((a, b) => b - a).forEach((i) => rows.splice(i, 1)); + state.selection.clear(); + blockStart = null; + blockTable = null; + }); + } + + function toggleCostComment(state) { + // REA popMainSetStr: 단가산출 산출근거 행을 주석(Index = -1)으로 지정 또는 일반 산출식으로 복원 + const rows = state.project.tables.CostMainDat; + const indices = [...state.selection].sort((a, b) => a - b); + if (state.view !== "CostMainDat" || !rows) { + throw Error("단가산출 화면에서만 산출근거 행을 주석으로 지정할 수 있습니다."); + } + if (!indices.length || indices.some((i) => !rows[i])) { + throw Error("주석으로 지정하거나 해제할 산출근거 행을 선택하세요."); + } + + const plans = []; + for (const index of indices) { + const row = rows[index]; + const curIndex = Number(row.Index) || 0; + if (curIndex > 0) { + throw Error( + `행 ${index + 1}: 호표 제목 행은 주석으로 지정할 수 없습니다. 상세 산출근거 행을 선택하세요.`, + ); + } + if (curIndex === -20) { + throw Error(`행 ${index + 1}: 소계 구분선 행은 주석으로 지정할 수 없습니다.`); + } + + const curSCheck = Number(row.SCheck) || 0; + const isComment = curIndex === -1 || curSCheck === -1; + + if (isComment) { + // 이미 주석인 행 -> 복원 + let restoreIndex = 0; + if (row._origIndex !== undefined) { + restoreIndex = Number(row._origIndex); + } else if (row.Index_ !== undefined) { + restoreIndex = Number(row.Index_); + } + + let restoreSCheck = null; + if (row._origSCheck !== undefined) { + restoreSCheck = Number(row._origSCheck); + } else if (row.SCheck_ !== undefined) { + restoreSCheck = Number(row.SCheck_); + } else if ([-1, -2, -3, -5].includes(Number(row.OCheck))) { + restoreSCheck = Number(row.OCheck); + } + + plans.push({ + index, + row, + isComment: false, + restoreIndex, + restoreSCheck, + }); + } else { + // 일반 상세 행 또는 비목 행(-2, -3, -5 등) -> 주석(-1)으로 전환 + plans.push({ + index, + row, + isComment: true, + origIndex: curIndex, + origSCheck: row.SCheck !== undefined ? Number(row.SCheck) : null, + }); + } + } + + EstApp.change(() => { + for (const p of plans) { + if (p.isComment) { + p.row._origIndex = p.origIndex; + if (p.origSCheck !== null) { + p.row._origSCheck = p.origSCheck; + } + p.row.Index = -1; + p.row.SCheck = -1; + } else { + delete p.row._origIndex; + delete p.row._origSCheck; + p.row.Index = p.restoreIndex; + if (p.restoreSCheck !== null) { + p.row.SCheck = p.restoreSCheck; + } else { + delete p.row.SCheck; + } + } + } + }); + } + + function alignWongaTotalAction(state) { + // REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 + const rows = state.project.tables.Wonga; + if (!rows || !rows.length) { + throw Error("원가계산서 자료가 없습니다."); + } + const targetRow = rows.find((r) => Number(r.LinkIndex) === 55); + const curTotal = targetRow ? Number(targetRow.Cost) || 0 : 0; + + EstActions.form( + "공사비역산 자리수 맞추기", + [ + { + name: "targetCost", + label: "목표 도급공사비 (원)", + type: "number", + value: curTotal, + }, + { + name: "unitDigits", + label: "단단위 절하 기준", + value: "3", + choices: [ + ["0", "원 단위 (절하 없음)"], + ["1", "십원 단위 (10원 절하)"], + ["2", "백원 단위 (100원 절하)"], + ["3", "천원 단위 (1,000원 절하)"], + ["4", "만원 단위 (10,000원 절하)"], + ], + }, + ], + ({ targetCost, unitDigits }) => { + EstApp.change(() => { + EstWonga.alignTotal(rows, targetCost, unitDigits); + state.project.options.TotalFix = true; + }); + }, + ); + } + + function handle(name, state) { + if (name === "block-start") { + setBlockStart(state); + return true; + } + if (name === "block-end") { + setBlockEnd(state); + return true; + } + if (name === "block-clear") { + clearBlockSelection(state); + return true; + } + if (name === "select-all-rows") { + selectAllRows(state); + return true; + } + if (name === "block-del") { + deleteBlockRows(state); + return true; + } + if (name === "toggle-cost-comment") { + toggleCostComment(state); + return true; + } + if (name === "align-wonga-total") { + alignWongaTotalAction(state); + return true; + } + return false; + } + + return { + setBlockStart, + setBlockEnd, + clearBlockSelection, + selectAllRows, + deleteBlockRows, + toggleCostComment, + alignWongaTotalAction, + handle, + }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 16726ca41..2e6fcd5a8 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -104,6 +104,16 @@ const EstBusinessActions = (() => { ); return true; } + if (node.id === "resource-kind-filter") { + state.resourceKind = node.value; + EstApp.render(); + return true; + } + if (node.id === "resource-by-work") { + state.resourceByWork = Boolean(node.checked); + EstApp.render(); + return true; + } if (node.id === "progress-components" || node.id === "progress-digits") { const round = currentRound(); if (round.locked) throw Error("확정 회차입니다."); @@ -282,17 +292,35 @@ const EstBusinessActions = (() => { ]; }, false); } else if (name === "move-estimate-up" || name === "move-estimate-down") { - if (state.view !== "EstMainDat") throw Error("내역서에서만 행을 이동할 수 있습니다."); - const direction = name === "move-estimate-up" ? -1 : 1; - const indices = EstBusiness.moveEstimateRows( - structuredClone(project.tables.EstMainDat), - state.selection, - direction, - ); + const isEst = state.view === "EstMainDat", + isGroup = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view); + if (!isEst && !isGroup) + throw Error( + "내역서 또는 호표(일위대가·단가산출·기계경비) 화면에서만 행을 이동할 수 있습니다.", + ); + const direction = name === "move-estimate-up" ? -1 : 1, + tableRows = project.tables[state.view]; + const indices = isEst + ? EstBusiness.moveEstimateRows(structuredClone(tableRows), state.selection, direction) + : EstBusiness.moveGroupDetailRows( + structuredClone(tableRows), + state.group, + state.selection, + direction, + state.view, + ); if (indices.every((index) => state.selection.has(index))) return; EstApp.change(() => { state.selection = new Set( - EstBusiness.moveEstimateRows(project.tables.EstMainDat, state.selection, direction), + isEst + ? EstBusiness.moveEstimateRows(tableRows, state.selection, direction) + : EstBusiness.moveGroupDetailRows( + tableRows, + state.group, + state.selection, + direction, + state.view, + ), ); }); } else if (name === "unlock-round") { @@ -401,6 +429,176 @@ const EstBusinessActions = (() => { }, false); }, ); + } else if (name === "vat-calc") { + if (state.view !== "StuffcDat") + throw Error("자재단가 화면에서만 부가세 계산을 실행할 수 있습니다."); + const rows = project.tables.StuffcDat || []; + const indices = [...state.selection].sort((a, b) => a - b); + EstActions.form( + "자재 부가세 계산", + [ + { + name: "calcMethod", + label: "계산방법", + value: 1, + choices: [ + [0, "부가세포함(×1.1)"], + [1, "부가세제외(÷1.1)"], + ], + }, + { + name: "digetMethod", + label: "단단위 계산방식", + value: 1, + choices: [ + [0, "사사오입"], + [1, "절하"], + [2, "절상"], + ], + }, + { name: "diget", label: "단단위 자리수(0=원, 1=십원..)", type: "number", value: "0" }, + ], + (values) => { + EstApp.change(() => { + state.project = EstVat.apply(state.project, indices, { + calcMethod: Number(values.calcMethod), + digetMethod: Number(values.digetMethod), + diget: Number(values.diget), + }); + }); + }, + ); + } else if (name === "haljung-calc") { + if (state.view !== "HeadMainDat") + throw Error("일위대가 화면에서만 할증계산을 실행할 수 있습니다."); + const rows = project.tables.HeadMainDat || []; + const grps = EstNative.groups(rows); + const currentGroup = grps.get(state.group); + if (!currentGroup) throw Error("할증을 적용할 공종(묶음)을 선택하세요."); + EstActions.form( + "공종 내 할증계산", + [ + { name: "name", label: "명 칭", value: "할 증" }, + { + name: "source", + label: "불러올비목(규격)", + value: 0, + choices: [ + [0, "비목별"], + [1, "재료비의"], + [2, "노무비의"], + [3, "경비의"], + [4, "합계의"], + ], + }, + { + name: "target", + label: "적용비목", + value: 0, + choices: [ + [0, "비목별"], + [1, "재료비에"], + [2, "노무비에"], + [3, "경비에"], + ], + }, + { name: "gn", label: "적용율(%)", type: "number", value: "10" }, + ], + (values) => { + const rate = Number(values.gn); + if (!Number.isFinite(rate) || rate === 0) + throw Error("할증 적용율은 0이 아닌 유효한 숫자여야 합니다."); + EstApp.change(() => { + state.project = EstHalJung.apply(state.project, state.view, state.group, { + name: values.name, + sourceKind: Number(values.source), + targetKind: Number(values.target), + rate, + }); + }); + }, + ); + } else if (name === "unit-calc") { + if (state.view !== "HeadMainDat") + throw Error("일위대가 화면에서만 공종 단위환산 계산을 실행할 수 있습니다."); + const grps = EstNative.groups(project.tables.HeadMainDat || []); + const currentGroup = grps.get(state.group); + if (!currentGroup) throw Error("환산할 일위대가 공종(묶음)을 선택하세요."); + const title = currentGroup.rows[0]; + EstActions.form( + "공종의 단위환산 계산", + [ + { name: "name", label: "명 칭", value: title.Name || "단위환산" }, + { name: "size", label: "규 격", value: title.Size || "" }, + { name: "gn", label: "수 량(환산계수)", type: "number", value: "1" }, + { name: "unite", label: "단 위", value: title.Unite || "식" }, + ], + (values) => { + const factor = Number(values.gn); + if (!Number.isFinite(factor) || factor === 0) + throw Error("수량은 0이 아닌 유효한 숫자여야 합니다."); + EstApp.change(() => { + state.project = EstBusiness.convertGroupUnit(state.project, state.group, { + name: values.name, + size: values.size, + factor, + unit: values.unite, + }); + }); + }, + ); + } else if (name === "toggle-jc-bimog") { + EstExtra.toggleJcBimog(state); + } else if (name === "toggle-sun-gongsa") { + EstExtra.toggleSunGongsa(state); + } else if (name === "uses-data-search") { + const rows = state.project.tables[state.view] || []; + const selected = [...state.selection]; + const targetRow = selected.length ? rows[selected[0]] : null; + if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요."); + EstExtra.showUsesSearchDialog(targetRow); + } else if (name === "set-cost-line-kind") { + const rows = state.project.tables.CostMainDat, + indices = [...state.selection].sort((a, b) => a - b); + if (state.view !== "CostMainDat" || !rows) + throw Error("단가산출 화면에서만 산출근거 비목을 지정할 수 있습니다."); + if (!indices.length || indices.some((i) => !rows[i])) + throw Error("비목을 지정할 산출근거 행을 선택하세요."); + const curKind = Number(rows[indices[0]].OCheck) || 0; + EstActions.form( + "산출근거 비목 지정", + [ + { + name: "kind", + label: "지정 비목", + value: curKind, + choices: [ + [0, "일반 산출식 / 해제"], + [-1, "노무비 (-1)"], + [-2, "재료비 (-2)"], + [-3, "경비 (-3)"], + [-5, "소계 (-5)"], + ], + }, + ], + ({ kind }) => { + kind = Number(kind); + EstApp.change(() => { + for (const idx of indices) { + const r = rows[idx]; + if (kind === 0) { + delete r.OCheck; + delete r.SCheck; + } else { + r.OCheck = kind; + r.SCheck = kind; + } + } + }); + }, + ); + } else if (EstBlockActions.handle(name, state)) { + EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { purpose = name; documentKey = state.documentKey; diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index bc1e38dd2..de3d4aacd 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -27,12 +27,12 @@ const EstBusiness = (() => { } function moveEstimateRows(rows, selection, direction) { const indices = [...selection].sort((a, b) => a - b); - if (!indices.length) throw Error("이동할 내역 행을 선택하세요."); + if (!indices.length) throw Error("이동할 행을 선택하세요."); if ( ![1, -1].includes(direction) || indices.some((value, index) => value !== indices[0] + index) ) - throw Error("연속된 내역 행을 선택하세요."); + throw Error("연속된 행을 선택하세요."); const first = indices[0], last = indices.at(-1), neighbor = direction < 0 ? first - 1 : last + 1; @@ -54,6 +54,204 @@ const EstBusiness = (() => { rows.splice(first + direction, 0, ...group); return indices.map((index) => index + direction); } + function validateCostOrderIntegrity(beforeGroupRows, afterGroupRows) { + const beforeCalc = EstText.calculate(structuredClone(beforeGroupRows)); + const afterCalc = EstText.calculate(structuredClone(afterGroupRows)); + if (afterCalc.errors && afterCalc.errors.length) { + throw Error(`산출근거 참조 오류로 이동할 수 없습니다: ${afterCalc.errors[0]}`); + } + // 소계(LinkIndex === 61) 및 비율(LinkIndex === 58) 또는 전체 호표 합계(첫 행 TC)의 수치/참조가 달라지면 차단 + if (Number(afterCalc.rows[0]?.TC || 0) !== Number(beforeCalc.rows[0]?.TC || 0)) { + throw Error("호표 최종 금액이 변경되어 순서를 보존할 수 없는 이동입니다."); + } + const beforeMap = new Map(); + beforeCalc.rows.forEach((r, idx) => beforeMap.set(rowKey(r, idx), r)); + for (let i = 0; i < afterCalc.rows.length; i++) { + const aRow = afterCalc.rows[i]; + const key = rowKey(aRow, i); + const bRow = beforeMap.get(key); + if (!bRow) continue; + const kind = num(bRow.LinkIndex); + if ([58, 61].includes(kind)) { + if (Number(aRow.TC || 0) !== Number(bRow.TC || 0)) { + throw Error("구간 소계 또는 비율 결과가 달라지는 안전하지 않은 이동입니다."); + } + } + // 앞줄 참조(\x0c)를 사용하는 행의 계산 수식 문자열(LineData)이 보존되는지 검증 + if (String(bRow.LineData || "").includes("\x0c")) { + if (String(aRow.LineData || "") !== String(bRow.LineData || "")) { + throw Error("앞줄 참조 대상 또는 수치가 달라지는 안전하지 않은 이동입니다."); + } + } + } + } + function moveGroupDetailRows(rows, groupCode, selection, direction, table = "") { + const indices = [...selection].sort((a, b) => a - b); + if (!indices.length) throw Error("이동할 호표 상세 행을 선택하세요."); + if ( + ![1, -1].includes(direction) || + indices.some((value, index) => value !== indices[0] + index) + ) + throw Error("연속된 행을 선택하세요."); + const grps = EstNative.groups(rows), + targetGroup = grps.get(groupCode); + if (!targetGroup) throw Error("호표를 찾을 수 없습니다."); + const groupStart = targetGroup.start, + groupLen = targetGroup.rows.length, + groupEnd = groupStart + groupLen - 1; + for (const idx of indices) { + if (idx <= groupStart || idx > groupEnd) + throw Error("호표의 상세 행만 이동할 수 있습니다(제목 행 제외)."); + } + const first = indices[0], + last = indices.at(-1), + neighbor = direction < 0 ? first - 1 : last + 1; + if (neighbor <= groupStart || neighbor > groupEnd) return indices; + const affected = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1); + if (affected.some((row) => [54, 55, 58, 59, 61, 62].includes(num(row.LinkIndex)))) + throw Error("합계·소계·구간비율 행은 직접 이동하거나 건너뛸 수 없습니다."); + if (affected.some((row) => String(row.LineData || "").includes("\x1a"))) + throw Error("합계 수식표지(\\x1a)가 포함된 행은 이동하거나 건너뛸 수 없습니다."); + const counts = new Map(); + rows.forEach((row, index) => { + const key = rowKey(row, index); + counts.set(key, (counts.get(key) || 0) + 1); + }); + for (let index = Math.min(first, neighbor); index <= Math.max(last, neighbor); index++) { + const row = rows[index]; + if ((row.PkID == null && !row.Code) || counts.get(rowKey(row, index)) !== 1) + throw Error("행 식별자가 없거나 중복되어 연결을 보존할 수 없습니다."); + } + const isCost = table === "CostMainDat" || rows.some((r) => r.LineData !== undefined); + const affectedM20Indices = []; + if (isCost) { + for (let i = Math.min(first, neighbor); i <= Math.max(last, neighbor); i++) { + if (num(rows[i].Index) === -20) affectedM20Indices.push(i); + } + } + // 선택 범위 중간에 -20이 포함되어 단일 경계 스왑으로 보장할 수 없는 경우 안전 차단 + if (affectedM20Indices.length > 1) { + throw Error("복수 구간 경계가 포함된 범위는 한 번에 이동할 수 없습니다."); + } + if ( + affectedM20Indices.length === 1 && + indices.length > 1 && + affectedM20Indices[0] !== first && + affectedM20Indices[0] !== last + ) { + throw Error("선택 범위 중간에 구간 경계가 포함되어 있어 이동할 수 없습니다."); + } + + const affectedHasM20 = affectedM20Indices.length === 1; + const swapTargetIndex = affectedHasM20 ? Math.min(first, neighbor) : null; + const swapSourceIndex = affectedHasM20 ? Math.max(last, neighbor) : null; + + if (isCost) { + // 앞줄 참조(\x0c)를 가진 행이 이동에 포함되거나 건너뛰어지는 경우 참조 대상 보존 검사 + const affectedRows = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1); + const hasRefRow = affectedRows.some((r) => String(r.LineData || "").includes("\x0c")); + if (hasRefRow && indices.length > 1) { + throw Error("앞줄 참조가 포함된 복수 행은 안전한 구분을 위해 단일 행으로 이동하세요."); + } + + // 앞줄 참조(\x0c) 또는 변수 참조(\x11)가 뒤따르는 구간에서 대상 행의 정체성 및 순서 무결성 보존 검사 + // 단, LinkIndex 61(소계)은 구간 내 모든 결과를 합산하므로 순서 교환 자체로 참조 대상이 왜곡되지 않음. + // 반면 LinkIndex 58(특정 비목 직전 참조)이나 일반 행의 \x0c는 특정 선행 결과를 선택 참조하므로, + // 선행 후보가 2개 이상일 때 그 후보들 사이의 순서 변경은 참조 대상 왜곡 방지를 위해 차단함. + const groupDetailRows = targetGroup.rows; + for (let i = 0; i < groupDetailRows.length; i++) { + const row = groupDetailRows[i]; + const lineText = String(row.LineData || ""); + if (num(row.LinkIndex) !== 61 && lineText.includes("\x0c")) { + const isLink58 = num(row.LinkIndex) === 58; + let sourceKind = isLink58 ? num(row.OCheck) : null; + if (isLink58) { + const cleanText = lineText.replace(/[\x00-\x1f]/g, "").trim(); + if (/노무|인건|품/.test(cleanText)) sourceKind = -1; + else if (/재료|자재/.test(cleanText)) sourceKind = -2; + else if (/경비/.test(cleanText)) sourceKind = -3; + } + const candidateIndices = []; + for (let j = i - 1; j >= 0; j--) { + const prev = groupDetailRows[j]; + const pKind = num(prev.SCheck); + if (isLink58) { + if ( + (!sourceKind && pKind < 0 && pKind > -4) || + (sourceKind && pKind === sourceKind) + ) { + if (String(prev.LineData || "").match(/([\x12\x0c])([-\d.,]+)\1/)) + candidateIndices.push(j); + } + } else { + // 일반 행의 \x0c + if (String(prev.LineData || "").match(/([\x03\x12\x0c])([-\d.,]+)\1/)) + candidateIndices.push(j); + } + if (num(prev.Level) > 0 || num(prev.Index) > 0) break; + } + if (candidateIndices.length > 1) { + // 이 후보들 중 하나라도 이번 이동(affected)에 포함되어 순서가 바뀌는 경우 차단 + const affectedGroupIndices = affected.map((r) => rows.indexOf(r) - groupStart); + const candidateAffected = candidateIndices.filter((cIdx) => + affectedGroupIndices.includes(cIdx), + ); + if (candidateAffected.length > 0) { + throw Error( + "직전 결과를 참조하는 행이 뒤따라 참조 대상 구분이 불명확하므로 이동할 수 없습니다.", + ); + } + } + } + } + + // 변수 재정의(동일 변수명에 대한 중복 할당)가 존재하는 경우 변수 참조 순서 왜곡 방지 + const varAssigned = new Map(); + groupDetailRows.forEach((r, idx) => { + const text = String(r.LineData || ""); + for (const m of text.matchAll(/(?:^|[\s,;])([A-Za-z][A-Za-z0-9]*)\s*=/g)) { + const vName = m[1]; + if (!varAssigned.has(vName)) varAssigned.set(vName, []); + varAssigned.get(vName).push(idx); + } + }); + for (const [vName, occurrences] of varAssigned.entries()) { + if (occurrences.length > 1) { + const affectedIndices = occurrences.filter((idx) => { + const globalIdx = groupStart + idx; + return globalIdx >= Math.min(first, neighbor) && globalIdx <= Math.max(last, neighbor); + }); + if (affectedIndices.length > 0) { + throw Error( + `동일 변수(${vName})를 재정의하는 구간에서는 참조 대상 보존을 위해 이동할 수 없습니다.`, + ); + } + } + } + + const simGroup = structuredClone(targetGroup.rows); + const relFirst = first - groupStart; + const movedSim = simGroup.splice(relFirst, indices.length); + simGroup.splice(relFirst + direction, 0, ...movedSim); + if (affectedHasM20) { + // 이동에 참여한 두 교환 위치 사이에서만 -20 표지를 정확히 교환(다른 구간 표지 불변 보존) + const relTarget = swapTargetIndex - groupStart; + const relSource = swapSourceIndex - groupStart; + const targetVal = simGroup[relTarget].Index; + simGroup[relTarget].Index = simGroup[relSource].Index; + simGroup[relSource].Index = targetVal; + } + validateCostOrderIntegrity(targetGroup.rows, simGroup); + } + const moved = rows.splice(first, indices.length); + rows.splice(first + direction, 0, ...moved); + if (affectedHasM20) { + const targetVal = rows[swapTargetIndex].Index; + rows[swapTargetIndex].Index = rows[swapSourceIndex].Index; + rows[swapSourceIndex].Index = targetVal; + } + return indices.map((index) => index + direction); + } function baseline(project) { const working = { ...project }; delete working.baseline; @@ -219,6 +417,10 @@ const EstBusiness = (() => { } function bid(project, rates) { const next = copy(project); + if (project.options && "SourceRefresh" in project.options) { + next.options._preBidSourceRefresh ??= project.options.SourceRefresh; + } + next.options = { ...next.options, SourceRefresh: false }; for (const key of ["HeadMainDat", "EstMainDat"]) for (const row of next.tables[key]) { if ( @@ -227,15 +429,73 @@ const EstBusiness = (() => { [51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(num(row.LinkIndex)) ) continue; + if (!row.PreBidRates) { + row.PreBidRates = {}; + units.forEach((field) => (row.PreBidRates[field] = num(row[field]))); + } units.forEach((field, i) => { - row[`O${field}`] ??= num(row[field]); + row[`O${field}`] ??= num(row.PreBidRates[field] ?? row[field]); row[field] = floating((num(row[`O${field}`]) * num(rates[i] ?? rates[0])) / 100); }); row.TU = units.reduce((n, k) => n + num(row[k]), 0); row.ApplyNacChal = 1; } - next.options.SourceRefresh = false; - return EstNative.calculate(next); + return next.options?.AutoCalc === false ? next : EstNative.calculate(next); + } + function restoreBid(project) { + const next = copy(project); + const targetSourceRefresh = + next.options && "_preBidSourceRefresh" in next.options + ? next.options._preBidSourceRefresh + : next.options?.SourceRefresh; + if (next.options) delete next.options._preBidSourceRefresh; + next.options = { ...next.options, SourceRefresh: false }; + for (const key of ["HeadMainDat", "EstMainDat"]) + for (const row of next.tables[key]) { + if (!row.ApplyNacChal && !row.PreBidRates) continue; + units.forEach((field) => { + if (row.PreBidRates && row.PreBidRates[field] != null) { + row[field] = num(row.PreBidRates[field]); + } else if (row[`O${field}`] != null) { + row[field] = num(row[`O${field}`]); + } + }); + delete row.PreBidRates; + row.TU = units.reduce((n, k) => n + num(row[k]), 0); + delete row.ApplyNacChal; + } + const calculated = next.options?.AutoCalc === false ? next : EstNative.calculate(next); + if (targetSourceRefresh !== undefined) { + calculated.options = { ...calculated.options, SourceRefresh: targetSourceRefresh }; + } + return calculated; + } + function convertGroupUnit( + project, + groupCode, + { name = "단위환산", size = "", factor = 1, unit = "식" } = {}, + ) { + const next = copy(project); + const grps = EstNative.groups(next.tables.HeadMainDat || []); + const group = grps.get(groupCode); + if (!group) throw Error("일위대가 공종을 찾을 수 없습니다."); + const numFactor = num(factor); + if (!Number.isFinite(numFactor) || numFactor === 0) + throw Error("환산 수량은 0이 아닌 유효한 숫자여야 합니다."); + const title = group.rows[0]; + title.Name = name.trim() || title.Name; + if (size && size.trim() !== "") title.Size = size.trim(); + if (unit && unit.trim() !== "") title.Unite = unit.trim(); + for (let i = 1; i < group.rows.length; i++) { + const row = group.rows[i]; + const link = num(row.LinkIndex); + if ([51, 52, 58, 62, 68].includes(link)) continue; + if ([1, 3].includes(num(row.CalcIndex))) continue; + if (row.GN != null) { + row.GN = num(row.GN) * numFactor; + } + } + return next.options?.AutoCalc === false ? next : EstNative.calculate(next); } function aligned(project, incoming) { const a = project.tables.EstMainDat, @@ -418,6 +678,8 @@ const EstBusiness = (() => { changes, costChanges, bid, + restoreBid, + convertGroupUnit, original, progress, escalation, @@ -427,6 +689,7 @@ const EstBusiness = (() => { rowKey, progressExclusions, moveEstimateRows, + moveGroupDetailRows, importContract, importBid, annualCopy, diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index 3e3b3690f..53faf9f14 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -126,7 +126,16 @@ const EstNative = (() => { if (!result) return row; const merged = { ...row, ...result }; // Main-row identities belong to a different native table/index. - for (const key of ["PkID", "Index", "RecNo", "Flag"]) { + // Native title records must preserve their own identities and blob/memo payloads. + for (const key of [ + "PkID", + "Index", + "RecNo", + "Flag", + "DifineMemo", + "SpaceMemo", + "EscData", + ]) { if (Object.hasOwn(row, key)) merged[key] = row[key]; else delete merged[key]; } diff --git a/tools/est-test/native-estimate.js b/tools/est-test/native-estimate.js new file mode 100644 index 000000000..6a300b40d --- /dev/null +++ b/tools/est-test/native-estimate.js @@ -0,0 +1,147 @@ +const EstEstimate = (() => { + let page = 0; + function usage(project) { + const sources = new Map(); + for (const row of project.tables.EstiMateDat || []) { + if (!row.Code) continue; + const rows = sources.get(row.Code) || []; + rows.push(row); + sources.set(row.Code, rows); + } + return (project.tables.EstMainDat || []).flatMap((row, index) => { + if (Number(row.Level) > 0 || Number(row.LinkIndex) !== 101) return []; + const matches = sources.get(row.Code) || []; + const source = + matches.length === 1 ? EstNative.price(structuredClone(matches[0]), 101) : null; + const appliedPrice = + row.TU == null + ? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0) + : EstMath.num(row.TU); + const sourcePrice = source ? EstMath.num(source.TC) : null; + const canApply = + matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6; + return [ + { + index, + Code: row.Code || "", + Name: row.Name || "", + Size: row.Size || "", + Unite: row.Unite || "", + GN: EstMath.num(row.GN), + appliedPrice, + sourcePrice, + difference: source ? appliedPrice - sourcePrice : null, + TC: EstMath.num(row.TC), + status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨", + canApply, + }, + ]; + }); + } + function changes(project) { + const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }; + return EstBusiness.changes(project) + .filter((row) => Number(row.LinkIndex) === 101 || Number(row.originalLinkIndex) === 101) + .map((row) => ({ ...row, status: labels[row.status] || row.status })); + } + function applyRowPrice(project, index) { + index = Number(index); + const row = project.tables?.EstMainDat?.[index]; + if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== 101) + throw Error("견적단가 내역 행을 찾을 수 없습니다."); + const sources = (project.tables?.EstiMateDat || []).filter( + (s) => s.Code && s.Code === row.Code, + ); + if (!sources.length) throw Error(`견적단가 원천 자료가 없습니다: ${row.Code}`); + if (sources.length > 1) throw Error(`견적단가 원천 코드가 중복되었습니다: ${row.Code}`); + const source = EstNative.price(structuredClone(sources[0]), 101); + const sourcePrice = EstMath.num(source?.TC); + if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다."); + + // 원천 비목 구성 (NC, GC, JC, SC) + const baseUnits = EstMath.components.map((comp) => EstMath.num(source[comp])); + // SCheck 재분류가 설정되어 있는 경우 비목 보존 + const scheck = Number(row.SCheck); + if (scheck > 0) { + const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck]; + for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0; + } else { + // SCheck === 0 이거나 미지정인 경우 원천 비목 분할 유지 (NU, GU, JU, SU) + EstMath.units.forEach((u, i) => (row[u] = baseUnits[i])); + } + // AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산 + if (project.options?.AutoCalc !== false) { + EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit); + } else { + row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0); + } + } + function applyAllPrices(project) { + const list = usage(project).filter((item) => item.canApply); + if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다."); + for (const item of list) applyRowPrice(project, item.index); + return list.length; + } + function render() { + const { state, esc, number, byId } = EstApp, + isChange = state.view === "estimate-change", + rows = isChange ? changes(state.project) : usage(state.project); + page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1)); + const visible = rows.slice(page * 100, page * 100 + 100), + value = (v) => (v == null ? "" : number(v)); + if (isChange) { + byId("work-content").innerHTML = + `
견적단가 설계변경${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽
${visible.map((row) => ``).join("")}
구분코드품명당초 수량변경 수량당초 금액변경 금액증감작업
${esc(row.status)}${esc(row.Code)}${esc(row.Name)}${value(row.originalGN)}${value(row.GN)}${value(row.originalTC)}${value(row.TC)}${value(row.difference)}${row.index == null ? "" : ``}
`; + return; + } + const canApplyCount = rows.filter((r) => r.canApply).length; + byId("work-content").innerHTML = + `
견적단가 사용 내역${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽

내역에 적용된 단가와 현재 견적단가 자료를 비교합니다.

${visible.map((row) => ``).join("")}
코드품명규격단위수량내역 단가기초 단가차이금액연결작업
${esc(row.Code)}${esc(row.Name)}${esc(row.Size)}${esc(row.Unite)}${value(row.GN)}${value(row.appliedPrice)}${value(row.sourcePrice)}${value(row.difference)}${value(row.TC)}${esc(row.status)}${row.canApply ? ` ` : ""}
`; + } + function install() { + document.addEventListener("click", (event) => { + const applyAll = event.target.closest("[data-estimate-apply-all]"); + if (applyAll) { + try { + EstApp.change((p) => { + const count = applyAllPrices(p); + EstApp.state.notice = `견적단가 ${count}건의 기초단가를 일괄 적용했습니다.`; + }); + } catch (err) { + EstApp.state.notice = err.message; + EstApp.render(); + } + return; + } + const applyRow = event.target.closest("[data-estimate-apply-row]"); + if (applyRow) { + const index = Number(applyRow.dataset.estimateApplyRow); + try { + EstApp.change((p) => { + applyRowPrice(p, index); + EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`; + }); + } catch (err) { + EstApp.state.notice = err.message; + EstApp.render(); + } + return; + } + const step = event.target.closest("[data-estimate-page]"); + if (step) { + page += Number(step.dataset.estimatePage); + render(); + return; + } + const node = event.target.closest("[data-estimate-row]"); + if (!node) return; + const index = Number(node.dataset.estimateRow); + if (!EstApp.state.project.tables.EstMainDat[index]) return; + EstApp.navigate("EstMainDat"); + EstApp.state.page = Math.floor(index / 100); + EstApp.state.selection = new Set([index]); + EstApp.render(); + }); + } + return { usage, changes, render, install, applyRowPrice, applyAllPrices }; +})(); diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index ceb63677d..89338a408 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -32,6 +32,18 @@ const EstExtra = (() => { EstStandard.render(); return; } + if (["estimate-usage", "estimate-change"].includes(state.view)) { + EstEstimate.render(); + return; + } + if (["material-usage", "material-change", "labor-usage", "labor-change"].includes(state.view)) { + EstBasicPrices.render(); + return; + } + if (["machine-usage", "machine-change"].includes(state.view)) { + EstMachine.render(); + return; + } const { project, view } = state, { esc, number, byId } = EstApp; let html = ""; @@ -77,7 +89,8 @@ const EstExtra = (() => { button("capture-baseline", "현재 내역을 당초로 확정") + button("load-contract", "당초·계약 내역 불러오기") + button("recalculate-original", "당초 단가 재계산") + - button("bid-rates", "낙찰률 적용"), + button("bid-rates", "낙찰률 적용") + + button("restore-bid", "당초단가(100%) 복원"), table( ["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"], changes @@ -328,22 +341,57 @@ const EstExtra = (() => { `수식 예: [기준]*0.5, [기준]-[1차]. 같은 수식을 수량과 각 성분 금액에 따로 적용하고 금액은 원 미만 절사합니다. ${esc(output.errors.join(" / "))}`, ); } else if (view === "resources") { - const output = EstResources.calculate(project); + state.resourceKind = state.resourceKind || "all"; + state.resourceByWork = state.resourceByWork ?? false; + const byWork = Boolean(state.resourceByWork); + const output = EstResources.calculate(project, byWork); + const filtered = output.rows.filter( + (r) => state.resourceKind === "all" || String(r.kind) === String(state.resourceKind), + ); + const kinds = [ + ["all", "전체"], + ["6", "노임"], + ["5", "자재"], + ["105", "기계"], + ["21", "표준"], + ["101", "견적"], + ]; + const kindSelect = ``; + const byWorkCheck = ``; + const headers = byWork + ? [ + "공종코드", + "공종명", + "종류", + "코드", + "품명", + "규격", + "단위", + "소요량 / 환산시간", + "금액", + ] + : ["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"]; + const rows = filtered.map((row) => { + const baseCells = [ + esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]), + esc(row.Code), + esc(row.Name), + esc(row.Size), + esc(row.Unite), + number(row.quantity), + number(row.amount), + ]; + return byWork ? [esc(row.WorkCode), esc(row.WorkName), ...baseCells] : baseCells; + }); html = box( "동원인원·자재·장비", - "", - table( - ["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"], - output.rows.map((row) => [ - esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]), - esc(row.Code), - esc(row.Name), - esc(row.Size), - esc(row.Unite), - number(row.quantity), - number(row.amount), - ]), - ), + `${kindSelect} ${byWorkCheck} (총 ${filtered.length}건)`, + table(headers, rows), esc(output.errors.join(" / ")), ); } else if (view === "drawing") { @@ -369,5 +417,281 @@ const EstExtra = (() => { } byId("work-content").innerHTML = html; } - return { render }; + + function findDataUsages(project, target) { + if (!project || !target) return []; + const code = target.Code ? String(target.Code).trim() : ""; + const name = target.Name ? EstNative.normalize(target.Name) : ""; + const size = target.Size ? EstNative.normalize(target.Size) : ""; + if (!code && !name) return []; + + const results = []; + const tables = [ + { id: "EstMainDat", label: "내역서" }, + { id: "HeadMainDat", label: "일위대가" }, + { id: "CostMainDat", label: "단가산출" }, + { id: "MachMainDat", label: "기계경비" }, + ]; + + for (const info of tables) { + const rows = project.tables[info.id] || []; + const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(info.id); + let curGroup = ""; + + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + if (grouped && Number(r.Index) > 0 && r.Code) curGroup = r.Code; + + // 대상 자기 자신의 대표 행은 검색 결과에서 제외 + if (r === target) continue; + + let matched = false; + if (code && r.Code === code) { + matched = true; + } else if (!code && name) { + const rName = r.Name ? EstNative.normalize(r.Name) : ""; + const rSize = r.Size ? EstNative.normalize(r.Size) : ""; + if (rName === name && (!size || rSize === size)) { + matched = true; + } + } + + if (matched) { + const rowName = r.Name || (r.LineData ? EstText.clean(r.LineData) : ""); + results.push({ + table: info.id, + tableLabel: info.label, + index: i, + group: curGroup, + code: r.Code || "", + name: rowName, + size: r.Size || "", + unite: r.Unite || "", + quantity: r.GN != null ? Number(r.GN) : null, + price: r.TC != null ? Number(r.TC) : null, + }); + } + } + } + return results; + } + + function showUsesSearchDialog(targetRow) { + const { state, esc, number, showDialog, byId } = EstApp; + if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요."); + + const rowName = targetRow.Name || (targetRow.LineData ? EstText.clean(targetRow.LineData) : ""); + const results = findDataUsages(state.project, targetRow); + const title = `데이터 사용위치 검색: ${targetRow.Code ? `[${targetRow.Code}] ` : ""}${rowName || "선택 항목"}`; + + let bodyHtml = ""; + if (!results.length) { + bodyHtml = + '

내역서, 일위대가, 단가산출, 기계경비에서 사용된 위치를 찾지 못했습니다.

'; + } else { + bodyHtml = ` +
+ + + + + + + + ${results + .map( + (res) => ` + + + + + + + + + + + `, + ) + .join("")} + +
구분코드품명 / 산출근거규격단위수량금액이동
${esc(res.tableLabel)}${esc(res.code)}${esc(res.name)}${esc(res.size)}${esc(res.unite)}${res.quantity != null ? number(res.quantity) : "-"}${res.price != null ? number(res.price) : "-"} + +
+
+
총 ${results.length}곳에서 사용 중입니다. [이동] 버튼을 누르면 해당 화면과 행으로 이동합니다.
+ `; + } + + showDialog(title, bodyHtml); + + const dialogContent = byId("dialog-content"); + const clickHandler = (e) => { + const btn = e.target.closest('[data-action="goto-use-location"]'); + if (!btn) return; + const targetTable = btn.dataset.table; + const targetIndex = Number(btn.dataset.index); + const targetGroup = btn.dataset.group || ""; + + byId("dialog").close(); + dialogContent.removeEventListener("click", clickHandler); + + EstApp.navigate(targetTable, targetGroup); + state.page = Math.floor(targetIndex / 100); + state.selection = new Set([targetIndex]); + EstApp.render(); + }; + dialogContent.addEventListener("click", clickHandler); + } + + function install() { + // 필요한 경우 이벤트 리스너 등록 + } + + function toggleJcBimog(state) { + // REA ActionSetJCBimog (0xf17c10 / 0x10dba14): 경비성단가 지정/해제 -> SCheck 3 토글 + const rows = state.project.tables[state.view], + indices = [...state.selection].sort((a, b) => a - b); + if (!rows || !["EstMainDat", "HeadMainDat"].includes(state.view)) + throw Error("내역서 또는 일위대가 화면에서만 경비성단가를 지정/해제할 수 있습니다."); + if (!indices.length || indices.some((index) => !rows[index])) + throw Error("경비성단가를 지정/해제할 행을 선택하세요."); + + // 원자적 검증: 한 행이라도 복원 정보가 없거나 거부되면 앞선 행도 변경되지 않도록 사전 검사 + const plans = []; + for (const index of indices) { + const row = rows[index]; + if (Number(row.SCheck) === 3) { + // 경비성 해제 -> 명시적 0/1/2 이력(_prevSCheck) 확인 + let restoreVal = + row._prevSCheck !== undefined && [0, 1, 2].includes(Number(row._prevSCheck)) + ? Number(row._prevSCheck) + : null; + if ( + restoreVal == null && + row.SCheck_ !== undefined && + [0, 1, 2].includes(Number(row.SCheck_)) + ) { + restoreVal = Number(row.SCheck_); + } + if (restoreVal == null) { + throw Error( + `행 ${index + 1}: 이전 비목 이력(0:비목별, 1:재료, 2:노무)이 없는 기존 경비성(3) 행은 임의 변환할 수 없습니다.`, + ); + } + plans.push({ index, row, isToggleOn: false, restoreVal }); + } else { + // 경비성 지정 -> 현재 SCheck(0, 1, 2)를 이력으로 보존 + const cur = Number(row.SCheck) || 0; + plans.push({ index, row, isToggleOn: true, cur }); + } + } + + // 전수 검증 통과 후 일괄 적용 + EstApp.change(() => { + for (const p of plans) { + if (p.isToggleOn) { + p.row._prevSCheck = p.cur; + EstBusinessActions.setEstimateClassification( + state.project, + p.row, + p.index, + 3, + state.view, + ); + } else { + delete p.row._prevSCheck; + EstBusinessActions.setEstimateClassification( + state.project, + p.row, + p.index, + p.restoreVal, + state.view, + ); + } + } + }); + } + + function toggleSunGongsa(state) { + // REA popMainSetTotalSumLine: 순직공비 계산끝(순공사비) 지정/해제 -> LinkIndex 54 토글 + const rows = state.project.tables.EstMainDat, + indices = [...state.selection].sort((a, b) => a - b); + if (state.view !== "EstMainDat" || !rows) + throw Error("내역서 화면에서만 순공사비 경계 행을 지정/해제할 수 있습니다."); + if (indices.length !== 1 || !rows[indices[0]]) + throw Error("순공사비 경계 행으로 지정하거나 해제할 행 1개를 선택하세요."); + const index = indices[0], + row = rows[index]; + + // 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5,6,21,101~105) 또는 + // 수량이 있는 행, 코드가 있는 상세 행은 순공사비 경계선으로 지정 불가 (원천/연결 파괴 차단) + const curLink = Number(row.LinkIndex) || 0; + if (curLink !== 54) { + if ( + row.Code || + Number(row.GN) > 0 || + [5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101, 103, 104, 105].includes(curLink) + ) { + throw Error( + "단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요.", + ); + } + } + + EstApp.change(() => { + if (curLink === 54) { + // 기존 54 해제 -> 보존된 원래 LinkIndex 또는 원래 유형이 확인되는 경우에만 복원 + let origLink = row._origLinkIndex !== undefined ? Number(row._origLinkIndex) : null; + if (origLink == null && row.LinkIndex_ !== undefined && Number(row.LinkIndex_) !== 54) { + origLink = Number(row.LinkIndex_); + } + if (origLink == null) { + // 복원 정보가 없는 기존 원본 54 행: 원래 유형을 알 수 없으므로 임의 0 변환 차단 + throw Error( + "기존 파일에 저장된 원본 순공사비(54) 행이거나 원래 유형을 확인할 수 없어 임의 0으로 변환할 수 없습니다.", + ); + } + row.LinkIndex = origLink; + delete row._origLinkIndex; + if (row._origName !== undefined) { + row.Name = row._origName; + delete row._origName; + } else if (row.Name === "순공사비") { + row.Name = "공종"; + } + } else { + // 기존 다른 54 행이 있으면 원래 상태로 안전 복원 + for (const r of rows) { + if (Number(r.LinkIndex) === 54) { + let origL = r._origLinkIndex !== undefined ? Number(r._origLinkIndex) : null; + if (origL == null && r.LinkIndex_ !== undefined && Number(r.LinkIndex_) !== 54) { + origL = Number(r.LinkIndex_); + } + if (origL == null) { + throw Error( + "기존 원본 순공사비(54) 행의 원래 유형을 알 수 없어 다른 행으로 대체할 수 없습니다.", + ); + } + r.LinkIndex = origL; + delete r._origLinkIndex; + if (r._origName !== undefined) { + r.Name = r._origName; + delete r._origName; + } else if (r.Name === "순공사비") { + r.Name = "공종"; + } + } + } + row._origLinkIndex = curLink; + row._origName = row.Name ?? ""; + row.LinkIndex = 54; + if (!row.Name || row.Name === "공종") row.Name = "순공사비"; + } + }); + } + + return { render, findDataUsages, showUsesSearchDialog, install, toggleJcBimog, toggleSunGongsa }; })(); diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index 94da6536b..559b5a666 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -6,6 +6,18 @@ const EstForms = (() => { columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"], rows: EstStandard.changes(project), }; + if (id === "estimate-change") + return { + columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"], + rows: EstEstimate.changes(project), + }; + if (id === "material-change" || id === "labor-change") { + const kindKey = id.startsWith("material") ? "material" : "labor"; + return { + columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"], + rows: EstBasicPrices.changes(project, kindKey), + }; + } if (id === "standard-usage") return { columns: [ @@ -22,6 +34,61 @@ const EstForms = (() => { ], rows: EstStandard.usage(project), }; + if (id === "estimate-usage") + return { + columns: [ + "Code", + "Name", + "Size", + "Unite", + "GN", + "appliedPrice", + "sourcePrice", + "difference", + "TC", + "status", + ], + rows: EstEstimate.usage(project), + }; + if (id === "material-usage" || id === "labor-usage") { + const kindKey = id.startsWith("material") ? "material" : "labor"; + return { + columns: [ + "Code", + "Name", + "Size", + "Unite", + "GN", + "appliedPrice", + "sourcePrice", + "difference", + "TC", + "status", + ], + rows: EstBasicPrices.usage(project, kindKey), + }; + } + if (id === "machine-usage") + return { + columns: [ + "Code", + "Name", + "Size", + "Unite", + "appliedPrice", + "originalPrice", + "difference", + "NC", + "GC", + "JC", + ], + rows: EstMachine.usage(project), + }; + if (id === "machine-change") + return { + columns: ["status", "Code", "Name", "Size", "Unite", "originalTC", "TC", "difference"], + rows: EstMachine.changes(project), + }; if (id === "change-costs") return { columns: ["Gubun", "Name", "originalCost", "changedCost", "difference"], @@ -169,14 +236,36 @@ const EstForms = (() => { 22: "Wonga", }[id]; if (table) { - const columns = - table === "Wonga" - ? ["Gubun", "Name", "Youl", "VSusic", "Cost"] - : table === "CostMainDat" - ? ["Code", "LineData"] - : table === "HeadMainDat" - ? [...common, "GN", "NU", "GU", "JU", "SU", "TC"] - : [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"]; + let columns; + if (table === "Wonga") { + columns = ["Gubun", "Name", "Youl", "VSusic", "Cost"]; + } else if (table === "CostMainDat") { + columns = ["Code", "LineData"]; + } else if (table === "HeadMainDat") { + const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0); + if (id === 115 && gongMethod === 0) { + // 공람용: 수량만 인쇄 + columns = [...common, "GN"]; + } else if (id === 115 && gongMethod === 1) { + // 공람용: 단가만 인쇄 + columns = [...common, "NU", "GU", "JU", "SU", "TC"]; + } else { + // 기본(수량, 단가 인쇄) + columns = [...common, "GN", "NU", "GU", "JU", "SU", "TC"]; + } + } else { + const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0); + if (id === 117 && gongMethod === 0) { + // 공람용: 수량만 인쇄 + columns = [...common, "GN"]; + } else if (id === 117 && gongMethod === 1) { + // 공람용: 단가/금액만 인쇄 + columns = [...common, "TU", "TC", "NC", "GC", "JC", "SC"]; + } else { + // 기본(수량, 단가/금액 인쇄) + columns = [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"]; + } + } return { table, columns, rows: project.tables[table] || [] }; } const totals = { @@ -207,7 +296,15 @@ const EstForms = (() => { .reduce((n, r) => n + EstMath.num(r[key]), 0); return row; }); - return { table: totals, columns: [...common, "NC", "GC", "JC", "SC", "TC"], rows }; + const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0); + let columns; + if ([116, 118].includes(id) && gongMethod === 0) { + // 공람용 총괄표: 금액 숨김 + columns = [...common]; + } else { + columns = [...common, "NC", "GC", "JC", "SC", "TC"]; + } + return { table: totals, columns, rows }; } if ([107, 108].includes(id)) return { @@ -334,7 +431,13 @@ const EstForms = (() => { }; const label = (key) => labels[key] || EstModel.labels[key] || key; function grid(output) { - const { esc } = EstApp, + const esc = (v) => + typeof EstApp !== "undefined" + ? EstApp.esc(v) + : String(v ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"), number = (v) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(v); return `${output.columns.map((key) => ``).join("")}${output.rows.map((row) => `${output.columns.map((key) => ``).join("")}`).join("")}
${esc(labels[key] || EstModel.labels[key] || key)}
${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))}
`; } diff --git a/tools/est-test/native-haljung.js b/tools/est-test/native-haljung.js new file mode 100644 index 000000000..af208c7f5 --- /dev/null +++ b/tools/est-test/native-haljung.js @@ -0,0 +1,93 @@ +// REA TFRMHALJUNGBIUL (0xdb4b14, 0xdb4d74, 0xdb4e70, 0xf5b7fc) +// 일위대가 및 단가산출 공종(묶음) 내 할증계산 행 추가/적용 기능 +const EstHalJung = (() => { + const { num, components, units, floating } = EstMath; + + // REA cxRadioSize (불러올비목): 0=비목별, 1=재료비의, 2=노무비의, 3=경비의, 4=합계의 + const sizeKinds = { + 0: { text: "비목별", ocheck: 0 }, + 1: { text: "재료비의", ocheck: 1 }, + 2: { text: "노무비의", ocheck: 2 }, + 3: { text: "경비의", ocheck: 3 }, + 4: { text: "합계의", ocheck: 4 }, + }; + + // REA cxRadioBimog (적용비목): 0=비목별, 1=재료비에, 2=노무비에, 3=경비에 + const bimogKinds = { + 0: { text: "비목별", scheck: 0 }, + 1: { text: "재료비에", scheck: 1 }, + 2: { text: "노무비에", scheck: 2 }, + 3: { text: "경비에", scheck: 3 }, + }; + + function apply( + project, + tableKey, + groupCode, + { name = "할 증", size = "비목별", rate = 0, sourceKind = 0, targetKind = 0 } = {}, + ) { + if (tableKey !== "HeadMainDat") { + throw Error("일위대가 화면에서만 할증계산을 적용할 수 있습니다."); + } + const rateNum = num(rate); + if (!Number.isFinite(rateNum) || rateNum === 0) { + throw Error("할증 적용율(%)은 0이 아닌 유효한 숫자여야 합니다."); + } + const next = structuredClone(project); + const rows = next.tables[tableKey] || []; + const grps = EstNative.groups(rows); + const group = grps.get(groupCode); + if (!group) throw Error("공종(묶음)을 찾을 수 없습니다."); + + const sInfo = sizeKinds[sourceKind] || sizeKinds[0]; + const bInfo = bimogKinds[targetKind] || bimogKinds[0]; + const finalSize = size && size !== "비목별" ? size.trim() : sInfo.text; + + // REA 의도: 불러올비목이 특정 비목(1=재료비, 2=노무비, 3=경비)이고 적용비목이 비목별(targetKind=0)인 경우, + // 원천 비목에 그대로 가산되도록 scheck를 원천 비목으로 맞춤. + // (targetKind가 0이고 sourceKind가 0이면 비목별 행별로 각각 분배되므로 scheck=0 유지) + const effectiveSCheck = + targetKind === 0 && sourceKind >= 1 && sourceKind <= 3 ? sInfo.ocheck : bInfo.scheck; + + // REA LinkIndex 58 (비율) 행 생성 + const haljungRow = { + Index: 0, + Level: 0, + LinkIndex: 58, + Name: name.trim() || "할 증", + Size: finalSize, + Unite: "%", + GN: rateNum, + SCheck: effectiveSCheck, + OCheck: sInfo.ocheck, + TU: 0, + TC: 0, + NC: 0, + GC: 0, + JC: 0, + SC: 0, + }; + + // 묶음 내 삽입 위치 찾기: + // 소계(LinkIndex=51, 68)가 있으면 소계 바로 앞에, 없으면 묶음의 마지막 행 뒤에 삽입 + const groupEnd = group.start + group.rows.length; + let insertIndex = groupEnd; + for (let i = group.start + 1; i < groupEnd; i++) { + const link = num(rows[i].LinkIndex); + if ([51, 68].includes(link)) { + insertIndex = i; + break; + } + } + + rows.splice(insertIndex, 0, haljungRow); + + return next.options?.AutoCalc === false ? next : EstNative.calculate(next); + } + + return { + sizeKinds, + bimogKinds, + apply, + }; +})(); diff --git a/tools/est-test/native-issues.js b/tools/est-test/native-issues.js index 075464f83..fd2e89dcf 100644 --- a/tools/est-test/native-issues.js +++ b/tools/est-test/native-issues.js @@ -1,5 +1,7 @@ const EstIssues = (() => { - let page = 0; + let page = 0, + tableFilter = "all", + typeFilter = "all"; const grouped = new Set(["HeadMainDat", "CostMainDat", "MachMainDat"]); const kinds = { 5: "StuffcDat", @@ -26,10 +28,26 @@ const EstIssues = (() => { if (list.length > 1) for (const { index } of list) issues.push({ table, index, code, message: "코드 중복" }); } - for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat"]) { + for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat", "CostMainDat"]) { (project.tables[table] || []).forEach((row, index) => { + const link = Number(row.LinkIndex); + if ( + Number(row.Level || 0) === 0 && + Number(row.Index || 0) === 0 && + [52, 57, 58, 62, 67].includes(link) && + row.Unite && + !["%", "%"].includes(String(row.Unite).trim()) + ) { + issues.push({ + table, + index, + code: row.Code, + message: "단위 백분율(%) 불일치", + rowUnite: row.Unite, + }); + } if (Number(row.Level) > 0 || Number(row.Index) > 0 || !row.Code) return; - const codes = sources.get(Number(row.LinkIndex)); + const codes = sources.get(link); if (!codes) return; const matches = codes.get(String(row.Code)) || []; if (!matches.length) @@ -37,7 +55,7 @@ const EstIssues = (() => { else if (matches.length > 1) issues.push({ table, index, code: row.Code, message: "연결 코드 중복" }); else - for (const field of ["Name", "Size"]) { + for (const field of ["Name", "Size", "Unite"]) { const a = row[field], b = matches[0].row[field]; if (a && b && EstNative.normalize(a) !== EstNative.normalize(b)) @@ -45,13 +63,95 @@ const EstIssues = (() => { table, index, code: row.Code, - message: field === "Name" ? "연결 품명 차이" : "연결 규격 차이", + message: + field === "Name" + ? "연결 품명 차이" + : field === "Size" + ? "연결 규격 차이" + : "연결 단위 차이", + sourceName: matches[0].row.Name, + sourceSize: matches[0].row.Size, + sourceUnite: matches[0].row.Unite, }); } }); } + // 수량산출서 및 직접 수식표 항목의 내역 연결(TargetKey 또는 TargetCode/LinkCode) 검사: + // 실제 totals() 연결 규칙과 일치하도록: + // 1) item.TargetKey 가 있으면 row.PkID != null && `id:${row.PkID}` === item.TargetKey 우선 매칭 + // 2) 없으면 code = item.TargetCode ?? item.LinkCode 로 row.Code === code 매칭 + // 3) level > 0 (공종/비고행) 제외 + const sourcesToCheck = []; + (project.quantitySheets || []).forEach((sheet) => { + (sheet.items || []).forEach((item, itemIndex) => { + sourcesToCheck.push({ item, sheetCode: sheet.Code, itemIndex }); + }); + }); + (project.quantities || []).forEach((item, itemIndex) => { + sourcesToCheck.push({ item, sheetCode: "manual", itemIndex }); + }); + + const estRows = (project.tables.EstMainDat || []).map((row, index) => ({ row, index })); + sourcesToCheck.forEach(({ item, sheetCode, itemIndex }) => { + const code = item.TargetCode ?? item.LinkCode; + if (!code && !item.TargetKey) { + issues.push({ + table: "quantity", + sheetCode, + index: itemIndex, + code: item.Code, + message: "수량 내역 미연결", + unlinked: true, + item, + }); + return; + } + const targets = estRows.filter( + ({ row }) => + !Number(row.Level) && + (item.TargetKey + ? row.PkID != null && `id:${row.PkID}` === item.TargetKey + : row.Code === code), + ); + if (!targets.length) { + issues.push({ + table: "quantity", + sheetCode, + index: itemIndex, + code: item.Code, + message: `수량 대상 내역 없음: ${item.TargetKey || code}`, + unlinked: true, + item, + }); + } else if (targets.length > 1) { + issues.push({ + table: "quantity", + sheetCode, + index: itemIndex, + code: item.Code, + message: `수량 대상 내역 중복: ${item.TargetKey || code}`, + }); + } + }); const costs = project.tables.CostMainDat || [], costGroups = EstNative.groups(costs); + if (typeof EstWonga !== "undefined" && typeof EstWonga.scan === "function") { + const direct = 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, + }; + const wongaIssues = EstWonga.scan(project.tables?.Wonga, project.options || {}, bases); + issues.push(...wongaIssues); + } for (const message of project.calculation?.errors || []) { const text = String(message), match = text.match(/^(.+):(\d+) /), @@ -69,15 +169,108 @@ const EstIssues = (() => { } return issues; } + function targetSelect(project, item, sheetCode, itemIndex) { + const { esc } = EstApp; + const rows = (project.tables?.EstMainDat || []).filter( + (row) => row.PkID != null && !Number(row.Level), + ); + return ``; + } function render() { const { state, esc, byId, titles } = EstApp, - issues = scan(state.project); - page = Math.max(0, Math.min(page, Math.ceil(issues.length / 100) - 1)); - const rows = issues.slice(page * 100, page * 100 + 100); + allIssues = scan(state.project); + const tables = [...new Set(allIssues.map((i) => i.table || ""))]; + const messages = [...new Set(allIssues.map((i) => i.message))]; + const filtered = allIssues.filter((i) => { + if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false; + if (typeFilter !== "all" && i.message !== typeFilter) return false; + return true; + }); + page = Math.max(0, Math.min(page, Math.ceil(filtered.length / 100) - 1)); + const rows = filtered.slice(page * 100, page * 100 + 100); + const tableSelect = ``; + const typeSelect = ``; byId("work-content").innerHTML = - `
자료 오류 검색${issues.length}건 · ${page + 1}/${Math.max(1, Math.ceil(issues.length / 100))}쪽

연결 품명·규격 차이는 확인이 필요한 항목으로 표시합니다.

${rows.map((item) => ``).join("")}
자료행코드내용이동
${esc(titles[item.table] || item.table || "전체 계산")}${item.index >= 0 ? item.index + 1 : ""}${esc(item.code)}${esc(item.message)}${item.table ? `` : ""}
`; + `
자료 오류 검색${tableSelect} ${typeSelect}검색 ${filtered.length}건 (전체 ${allIssues.length}건) · ${page + 1}/${Math.max(1, Math.ceil(filtered.length / 100))}쪽${ + filtered.some( + (i) => i.sourceName && ["연결 품명 차이", "연결 규격 차이"].includes(i.message), + ) + ? ' ' + : "" + }

연결 품명·규격 차이는 확인 후 개별 또는 일괄로 반영할 수 있으며, 단위 차이는 수량·단가 환산 보호를 위해 개별 확인 시에만 반영됩니다.

${rows + .map( + (item) => + ``, + ) + .join("")}
자료행코드내용원천 실제 명칭이동 및 조작
${esc(titles[item.table] || item.table || "전체 계산")}${item.index >= 0 ? item.index + 1 : ""}${esc(item.code)}${esc(item.message)}${ + item.sourceName + ? `${esc(item.sourceName)}${item.sourceSize ? " · " + esc(item.sourceSize) : ""}${item.sourceUnite ? " · " + esc(item.sourceUnite) : ""}` + : "" + }${ + item.table === "quantity" + ? `${ + item.unlinked + ? " " + targetSelect(state.project, item.item, item.sheetCode, item.index) + : "" + }` + : item.table + ? `${ + item.sourceName && + ["연결 품명 차이", "연결 규격 차이", "연결 단위 차이"].includes(item.message) + ? ` ` + : "" + }` + : "" + }
`; } function install() { + document.addEventListener("change", (event) => { + if (event.target.id === "issue-table-filter") { + tableFilter = event.target.value; + page = 0; + render(); + return; + } + if (event.target.id === "issue-type-filter") { + typeFilter = event.target.value; + page = 0; + render(); + return; + } + const select = event.target.closest("[data-issue-target-select]"); + if (!select) return; + const { state } = EstApp, + sheetCode = select.dataset.issueTargetSelect, + itemIndex = Number(select.dataset.issueItemIndex); + const item = + sheetCode === "manual" + ? state.project.quantities?.[itemIndex] + : state.project.quantitySheets?.find((s) => s.Code === sheetCode)?.items?.[itemIndex]; + if (!item) return; + const row = (state.project.tables.EstMainDat || []).find( + (r) => r.PkID != null && `id:${r.PkID}` === select.value && !Number(r.Level), + ); + EstApp.change(() => { + item.TargetKey = row ? select.value : ""; + item.TargetCode = row?.Code || ""; + }, false); + }); document.addEventListener("click", (event) => { const step = event.target.closest("[data-issue-page]"); if (step) { @@ -85,6 +278,72 @@ const EstIssues = (() => { render(); return; } + const qNode = event.target.closest("[data-issue-quantity]"); + if (qNode) { + const sheetCode = qNode.dataset.issueQuantity; + EstApp.state.view = "quantity"; + EstApp.state.quantitySheet = sheetCode || "manual"; + EstApp.navigate("quantity"); + return; + } + const syncAllBtn = event.target.closest("[data-issue-sync-all]"); + if (syncAllBtn) { + const { state } = EstApp; + // 현재 필터링 조건에 일치하는 품명·규격 불일치 항목만 일괄 반영 대상 + const currentFiltered = scan(state.project).filter((i) => { + if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false; + if (typeFilter !== "all" && i.message !== typeFilter) return false; + return i.sourceName && (i.message === "연결 품명 차이" || i.message === "연결 규격 차이"); + }); + if (!currentFiltered.length) return; + EstApp.change(() => { + for (const issue of currentFiltered) { + const row = state.project.tables[issue.table]?.[issue.index]; + if (!row) continue; + if (issue.message === "연결 품명 차이" && issue.sourceName) { + row.Name = issue.sourceName; + } else if (issue.message === "연결 규격 차이") { + if (issue.sourceSize && issue.sourceSize.trim() !== "") { + row.Size = issue.sourceSize; + } + } + } + }); + render(); + return; + } + const syncRowBtn = event.target.closest("[data-issue-sync-row]"); + if (syncRowBtn) { + const { state } = EstApp; + const targetTable = syncRowBtn.dataset.issueSyncRow, + targetIndex = Number(syncRowBtn.dataset.issueSyncIndex), + targetMsg = syncRowBtn.dataset.issueSyncMsg; + const issue = scan(state.project).find( + (i) => + i.table === targetTable && + i.index === targetIndex && + (!targetMsg || i.message === targetMsg) && + i.sourceName, + ); + if (!issue) return; + EstApp.change(() => { + const row = state.project.tables[targetTable]?.[targetIndex]; + if (!row) return; + if (issue.message === "연결 품명 차이" && issue.sourceName) { + row.Name = issue.sourceName; + } else if (issue.message === "연결 규격 차이") { + if (issue.sourceSize && issue.sourceSize.trim() !== "") { + row.Size = issue.sourceSize; + } + } else if (issue.message === "연결 단위 차이") { + if (issue.sourceUnite !== undefined) { + row.Unite = issue.sourceUnite; + } + } + }); + render(); + return; + } const node = event.target.closest("[data-issue-table]"); if (!node) return; const table = node.dataset.issueTable, diff --git a/tools/est-test/native-links.cjs b/tools/est-test/native-links.cjs index 52e011d30..bd07c3a5e 100644 --- a/tools/est-test/native-links.cjs +++ b/tools/est-test/native-links.cjs @@ -2,9 +2,14 @@ function existingTargets(tables, sheets, warnings) { const candidates = new Map(); const targetCounts = new Map(); + const pkidCounts = new Map(); for (const row of tables.EstMainDat || []) - if (row.Code && !Number(row.Level)) + if (row.Code && !Number(row.Level)) { targetCounts.set(row.Code, (targetCounts.get(row.Code) || 0) + 1); + if (row.PkID != null) { + pkidCounts.set(row.PkID, (pkidCounts.get(row.PkID) || 0) + 1); + } + } for (const row of tables.EstMainDat || []) { if (Number(row.Level) || !row.MulCode?.binary || !row.MullangIndex || row.ExcelSheetIndex) continue; @@ -29,7 +34,8 @@ function existingTargets(tables, sheets, warnings) { warnings.add("수량 연결: 문서·항목 코드 또는 내역 코드가 없습니다."); continue; } - if (targetCounts.get(row.Code) !== 1) { + const hasUniquePkid = row.PkID != null && pkidCounts.get(row.PkID) === 1; + if (targetCounts.get(row.Code) !== 1 && !hasUniquePkid) { warnings.add(`수량 연결: 내역 코드 ${row.Code}가 중복되어 자동 선택하지 않았습니다.`); continue; } @@ -39,12 +45,20 @@ function existingTargets(tables, sheets, warnings) { warnings.add(`수량 연결: ${parts[0]}/${parts[1]} 항목을 찾을 수 없습니다.`); continue; } - if (!candidates.has(item)) candidates.set(item, new Set()); - candidates.get(item).add(row.Code); + if (!candidates.has(item)) candidates.set(item, new Map()); + const key = hasUniquePkid ? `id:${row.PkID}` : `code:${row.Code}`; + candidates.get(item).set(key, { code: row.Code, pkId: hasUniquePkid ? row.PkID : null }); } - for (const [item, codes] of candidates) - if (codes.size === 1) item.TargetCode = [...codes][0]; - else + for (const [item, entries] of candidates) { + if (entries.size === 1) { + const entry = [...entries.values()][0]; + item.TargetCode = entry.code; + if (entry.pkId != null) { + item.TargetKey = `id:${entry.pkId}`; + } + } else { warnings.add(`수량 연결: ${item.Code}에 여러 내역 코드가 연결되어 자동 선택하지 않았습니다.`); + } + } } module.exports = { existingTargets }; diff --git a/tools/est-test/native-machine.js b/tools/est-test/native-machine.js index 1c061cd97..b13752a6b 100644 --- a/tools/est-test/native-machine.js +++ b/tools/est-test/native-machine.js @@ -129,5 +129,108 @@ const EstMachine = (() => { finish(); return { rows: output, titles, errors }; } - return { calculate }; + + let page = 0; + + function usage(project) { + const titles = project.tables?.MachTitleDat || []; + const grps = EstNative.groups(project.tables?.MachMainDat || []); + return titles.map((title, index) => { + const code = title.Code || ""; + const group = grps.get(code); + const appliedPrice = EstMath.num(title.TC); + const hasOriginal = title.TC_ != null; + const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null; + const difference = hasOriginal ? appliedPrice - originalPrice : null; + return { + index, + Code: code, + Name: title.Name || "", + Size: title.Size || "", + Unite: title.Unite || "", + appliedPrice, + originalPrice, + difference, + NC: EstMath.num(title.NC), + GC: EstMath.num(title.GC), + JC: EstMath.num(title.JC), + detailCount: group ? group.rows.length : 0, + status: !hasOriginal ? "신규(당초없음)" : Math.abs(difference) > 1e-6 ? "당초차이" : "일치", + canApply: false, // 당초 계약값은 비교용이며 현재 단가 덮어쓰기 적용을 지원하지 않음 + }; + }); + } + + function changes(project) { + const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }; + const titles = project.tables?.MachTitleDat || []; + return titles.map((title, index) => { + const appliedPrice = EstMath.num(title.TC); + const hasOriginal = title.TC_ != null; + const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null; + const difference = hasOriginal ? appliedPrice - originalPrice : null; + const statusKey = !hasOriginal + ? "new" + : Math.abs(difference) > 1e-6 + ? "changed" + : "unchanged"; + return { + index, + Code: title.Code || "", + Name: title.Name || "", + Size: title.Size || "", + Unite: title.Unite || "", + originalTC: originalPrice, + TC: appliedPrice, + difference, + status: labels[statusKey] || statusKey, + }; + }); + } + + function applyRowPrice() { + throw Error( + "기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.", + ); + } + + function applyAllPrices() { + throw Error( + "기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.", + ); + } + + function render() { + const { state, esc, number, byId } = EstApp, + isChange = state.view === "machine-change", + rows = isChange ? changes(state.project) : usage(state.project); + page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1)); + const visible = rows.slice(page * 100, page * 100 + 100), + value = (v) => (v == null ? "" : number(v)); + if (isChange) { + byId("work-content").innerHTML = + `
기계경비 설계변경${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽
${visible.map((row) => ``).join("")}
구분코드품명규격단위당초 금액변경 금액증감작업
${esc(row.status)}${esc(row.Code)}${esc(row.Name)}${esc(row.Size)}${esc(row.Unite)}${value(row.originalTC)}${value(row.TC)}${value(row.difference)}
`; + return; + } + byId("work-content").innerHTML = + `
기계경비 사용 내역${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽

기계경비 호표의 산출 단가와 당초 계약 단가를 비교합니다. (기계 호표 단가는 MachMainDat 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않습니다.)

${visible.map((row) => ``).join("")}
코드품명규격단위적용 단가당초 단가차이상태노무비재료비경비작업
${esc(row.Code)}${esc(row.Name)}${esc(row.Size)}${esc(row.Unite)}${value(row.appliedPrice)}${value(row.originalPrice)}${value(row.difference)}${esc(row.status)}${value(row.NC)}${value(row.GC)}${value(row.JC)}
`; + } + + function install() { + document.addEventListener("click", (event) => { + const step = event.target.closest("[data-machine-page]"); + if (step) { + page += Number(step.dataset.machinePage); + render(); + return; + } + const node = event.target.closest("[data-machine-group]"); + if (!node) return; + const code = node.dataset.machineGroup; + if (!code) return; + EstApp.navigate("MachMainDat", code); + }); + } + + return { calculate, usage, changes, applyRowPrice, applyAllPrices, render, install }; })(); diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js index f2831b502..e303e707a 100644 --- a/tools/est-test/native-model.js +++ b/tools/est-test/native-model.js @@ -378,6 +378,14 @@ const EstModel = (() => { "change-costs", "standard-usage", "standard-change", + "estimate-usage", + "estimate-change", + "material-usage", + "material-change", + "labor-usage", + "labor-change", + "machine-usage", + "machine-change", ]); for (const [key, value] of Object.entries(project.documents)) if ( diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 1fce26fb1..a3b8ef5de 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -307,7 +307,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${number(sheet.items[index].GN)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } function install() { document.addEventListener("change", (event) => { @@ -339,7 +339,7 @@ const EstQuantity = (() => { if (!sheet) return; if (node.dataset.quantityName !== undefined) EstApp.change(() => (sheet.Name = node.value), false); - if (["Name", "Size"].includes(node.dataset.quantityItem)) + if (["Name", "Size", "Unite"].includes(node.dataset.quantityItem)) EstApp.change( () => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value), false, @@ -359,170 +359,206 @@ const EstQuantity = (() => { }); document.addEventListener("click", (event) => { const { state } = EstApp; - if (event.target.closest("[data-quantity-excel]")) { - excelDialog(); - return; - } - if (event.target.closest("[data-quantity-excel-links]")) { - excelLinksDialog(); - return; - } - const removeLink = event.target.closest("[data-quantity-excel-remove]"); - if (removeLink) { - EstApp.change( - () => - state.project.excelQuantityLinks.splice( - Number(removeLink.dataset.quantityExcelRemove), - 1, - ), - false, - ); - document.getElementById("dialog").close(); - excelLinksDialog(); - return; - } - if (event.target.closest("[data-quantity-excel-refresh]")) { - const project = state.project, - revision = state.revision, - input = document.getElementById("quantity-excel-refresh-file"), - file = input.files[0]; - (async () => { - if (!file) throw Error("Excel 파일을 선택하세요."); - const files = await EstApp.api("/api/import-xlsx", { - method: "POST", - body: await file.arrayBuffer(), - }); - if ( - state.project !== project || - state.revision !== revision || - document.getElementById("quantity-excel-refresh-file") !== input || - input.files[0] !== file - ) - throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); - const updates = excelUpdates(project, file.name, files); - EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value))); + try { + if (event.target.closest("[data-quantity-excel]")) { + excelDialog(); + return; + } + if (event.target.closest("[data-quantity-excel-links]")) { + excelLinksDialog(); + return; + } + const removeLink = event.target.closest("[data-quantity-excel-remove]"); + if (removeLink) { + EstApp.change( + () => + state.project.excelQuantityLinks.splice( + Number(removeLink.dataset.quantityExcelRemove), + 1, + ), + false, + ); document.getElementById("dialog").close(); - })().catch((error) => { - const output = document.getElementById("quantity-excel-links-error"); - if (output) output.textContent = error.message; - }); - return; - } - if (event.target.closest("[data-quantity-excel-apply]")) { - const project = state.project, - revision = state.revision, - fileInput = document.getElementById("quantity-excel-file"), - file = fileInput.files[0], - name = document.getElementById("quantity-excel-sheet").value.trim(), - address = document.getElementById("quantity-excel-cell").value, - selected = document.getElementById("quantity-excel-target").value, - row = selected === "" ? null : project.tables.EstMainDat[Number(selected)]; - (async () => { - if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요."); - if ( - !Number.isSafeInteger(row.PkID) || - project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1 - ) - throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다."); - const files = await EstApp.api("/api/import-xlsx", { + excelLinksDialog(); + return; + } + if (event.target.closest("[data-quantity-excel-refresh]")) { + const project = state.project, + revision = state.revision, + input = document.getElementById("quantity-excel-refresh-file"), + file = input.files[0]; + (async () => { + if (!file) throw Error("Excel 파일을 선택하세요."); + const files = await EstApp.api("/api/import-xlsx", { method: "POST", body: await file.arrayBuffer(), - }), - value = excelCell(files, name, address); - if ( - state.project !== project || - state.revision !== revision || - document.getElementById("quantity-excel-file") !== fileInput || - fileInput.files[0] !== file || - document.getElementById("quantity-excel-sheet")?.value.trim() !== name || - document.getElementById("quantity-excel-cell")?.value !== address || - document.getElementById("quantity-excel-target")?.value !== selected || - project.tables.EstMainDat[Number(selected)] !== row - ) - throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); - EstApp.change(() => { - row.GN = value; - project.excelQuantityLinks ||= []; - project.excelQuantityLinks = project.excelQuantityLinks.filter( - (link) => link.target !== row.PkID, - ); - project.excelQuantityLinks.push({ - target: row.PkID, - file: file.name, - sheet: name, - cell: address.trim().toUpperCase(), }); + if ( + state.project !== project || + state.revision !== revision || + document.getElementById("quantity-excel-refresh-file") !== input || + input.files[0] !== file + ) + throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); + const updates = excelUpdates(project, file.name, files); + EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value))); + document.getElementById("dialog").close(); + })().catch((error) => { + const output = document.getElementById("quantity-excel-links-error"); + if (output) output.textContent = error.message; }); - document.getElementById("dialog").close(); - })().catch((error) => { - const output = document.getElementById("quantity-excel-error"); - if (output) output.textContent = error.message; - }); - return; - } - if (event.target.closest("[data-quantity-new]")) { - EstApp.change(() => { - const code = `Q${EstApp.uid()}`; - state.project.quantitySheets ||= []; - state.project.quantitySheets.push({ - Code: code, - Name: "새 수량산출서", - rows: [], - items: [], - options: { digits: 2, roundMode: 2 }, + return; + } + if (event.target.closest("[data-quantity-excel-apply]")) { + const project = state.project, + revision = state.revision, + fileInput = document.getElementById("quantity-excel-file"), + file = fileInput.files[0], + name = document.getElementById("quantity-excel-sheet").value.trim(), + address = document.getElementById("quantity-excel-cell").value, + selected = document.getElementById("quantity-excel-target").value, + row = selected === "" ? null : project.tables.EstMainDat[Number(selected)]; + (async () => { + if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요."); + if ( + !Number.isSafeInteger(row.PkID) || + project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1 + ) + throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다."); + const files = await EstApp.api("/api/import-xlsx", { + method: "POST", + body: await file.arrayBuffer(), + }), + value = excelCell(files, name, address); + if ( + state.project !== project || + state.revision !== revision || + document.getElementById("quantity-excel-file") !== fileInput || + fileInput.files[0] !== file || + document.getElementById("quantity-excel-sheet")?.value.trim() !== name || + document.getElementById("quantity-excel-cell")?.value !== address || + document.getElementById("quantity-excel-target")?.value !== selected || + project.tables.EstMainDat[Number(selected)] !== row + ) + throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); + EstApp.change(() => { + row.GN = value; + project.excelQuantityLinks ||= []; + project.excelQuantityLinks = project.excelQuantityLinks.filter( + (link) => link.target !== row.PkID, + ); + project.excelQuantityLinks.push({ + target: row.PkID, + file: file.name, + sheet: name, + cell: address.trim().toUpperCase(), + }); + }); + document.getElementById("dialog").close(); + })().catch((error) => { + const output = document.getElementById("quantity-excel-error"); + if (output) output.textContent = error.message; }); - state.quantitySheet = code; - }, false); - return; - } - const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); - const addRow = event.target.closest("[data-quantity-add]"); - if (addRow && sheet) { - EstApp.change(() => { - const code = `r${EstApp.uid()}`; - const at = - addRow.dataset.index === undefined - ? sheet.rows.length - : Number(addRow.dataset.index) + 1; - sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] }); - sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" }); - }, false); - return; - } - const moveRow = event.target.closest("[data-quantity-move]"); - if (moveRow && sheet) { - const index = Number(moveRow.dataset.index), - next = index + Number(moveRow.dataset.quantityMove); - if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length) + return; + } + if (event.target.closest("[data-quantity-new]")) { EstApp.change(() => { - [sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]]; + const code = `Q${EstApp.uid()}`; + state.project.quantitySheets ||= []; + state.project.quantitySheets.push({ + Code: code, + Name: "새 수량산출서", + rows: [], + items: [], + options: { digits: 2, roundMode: 2 }, + }); + state.quantitySheet = code; }, false); - return; - } - const deleteRow = event.target.closest("[data-quantity-delete-row]"); - if (deleteRow && sheet) { - if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?")) + return; + } + const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + const addItem = event.target.closest("[data-quantity-add-item]"); + if (addItem && sheet) { EstApp.change(() => { - const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1); - const remaining = new Set(sheet.rows.flatMap((row) => row.markers || [])); - sheet.items = sheet.items.filter( - (item) => !removed.markers.includes(item.Code) || remaining.has(item.Code), + const codes = new Set(sheet.items.map((it) => it.Code)); + let index = 0; + while (codes.has(`r${String(index).padStart(6, "0")}`)) index++; + const code = `r${String(index).padStart(6, "0")}`; + sheet.items.push({ + Code: code, + Name: "새 수량항목", + Size: "", + Unite: "", + GN: 0, + TargetCode: "", + }); + }, false); + return; + } + const deleteItem = event.target.closest("[data-quantity-delete-item]"); + if (deleteItem && sheet) { + const itemIdx = Number(deleteItem.dataset.quantityDeleteItem); + const targetItem = sheet.items[itemIdx]; + if (!targetItem) return; + const code = targetItem.Code; + const referringRows = sheet.rows.filter((row) => (row.markers || []).includes(code)); + if (referringRows.length > 0) { + throw Error( + `산출 행에서 참조 중인 수량 항목(${targetItem.Name || code})은 직접 삭제할 수 없습니다. 연결된 산출 행을 삭제하거나, 미참조 항목만 삭제할 수 있습니다.`, ); - }, false); - return; - } - if (event.target.closest("[data-quantity-delete-sheet]") && sheet) { - if (confirm("이 수량산출서를 삭제하시겠습니까?")) + } + if (confirm("이 수량 항목을 삭제하시겠습니까?")) + EstApp.change(() => { + sheet.items.splice(itemIdx, 1); + }, false); + return; + } + const addRow = event.target.closest("[data-quantity-add]"); + if (addRow && sheet) { EstApp.change(() => { - state.project.quantitySheets = state.project.quantitySheets.filter( - (item) => item !== sheet, - ); - state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual"; + const code = `r${EstApp.uid()}`; + const at = + addRow.dataset.index === undefined + ? sheet.rows.length + : Number(addRow.dataset.index) + 1; + sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] }); + sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" }); }, false); - return; - } - if (!event.target.closest("[data-quantity-apply]")) return; - try { + return; + } + const moveRow = event.target.closest("[data-quantity-move]"); + if (moveRow && sheet) { + const index = Number(moveRow.dataset.index), + next = index + Number(moveRow.dataset.quantityMove); + if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length) + EstApp.change(() => { + [sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]]; + }, false); + return; + } + const deleteRow = event.target.closest("[data-quantity-delete-row]"); + if (deleteRow && sheet) { + if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?")) + EstApp.change(() => { + const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1); + const remaining = new Set(sheet.rows.flatMap((row) => row.markers || [])); + sheet.items = sheet.items.filter( + (item) => !removed.markers.includes(item.Code) || remaining.has(item.Code), + ); + }, false); + return; + } + if (event.target.closest("[data-quantity-delete-sheet]") && sheet) { + if (confirm("이 수량산출서를 삭제하시겠습니까?")) + EstApp.change(() => { + state.project.quantitySheets = state.project.quantitySheets.filter( + (item) => item !== sheet, + ); + state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual"; + }, false); + return; + } + if (!event.target.closest("[data-quantity-apply]")) return; const quantities = totals(state.project, state.quantitySheet); EstApp.change(() => applyTotals(state.project, quantities)); } catch (error) { diff --git a/tools/est-test/native-report.js b/tools/est-test/native-report.js index 1bf557f78..3d5af7630 100644 --- a/tools/est-test/native-report.js +++ b/tools/est-test/native-report.js @@ -13,6 +13,9 @@ const EstReport = (() => { startPage: 1, rowsPerPage: 30, notes: "", + PrintGongMethod: settings.PrintGongMethod ?? 0, + PrintApplyField: settings.PrintApplyField ?? 0, + PrintSumBottom: settings.PrintSumBottom ?? 0, ...settings, marginTop: settings.marginTop ?? settings.margin ?? 12, marginBottom: settings.marginBottom ?? settings.margin ?? 12, @@ -149,6 +152,14 @@ const EstReport = (() => { { Index: "change-costs", Name: "설계변경 원가 증감표", Name2: "설계변경 원가 증감표" }, { Index: "standard-usage", Name: "표준시장단가 사용 내역", Name2: "표준시장단가 사용 내역" }, { Index: "standard-change", Name: "표준시장단가 설계변경", Name2: "표준시장단가 설계변경" }, + { Index: "estimate-usage", Name: "견적단가 사용 내역", Name2: "견적단가 사용 내역" }, + { Index: "estimate-change", Name: "견적단가 설계변경", Name2: "견적단가 설계변경" }, + { Index: "material-usage", Name: "자재단가 사용 내역", Name2: "자재단가 사용 내역" }, + { Index: "material-change", Name: "자재단가 설계변경", Name2: "자재단가 설계변경" }, + { Index: "labor-usage", Name: "노임단가 사용 내역", Name2: "노임단가 사용 내역" }, + { Index: "labor-change", Name: "노임단가 설계변경", Name2: "노임단가 설계변경" }, + { Index: "machine-usage", Name: "기계경비 사용 내역", Name2: "기계경비 사용 내역" }, + { Index: "machine-change", Name: "기계경비 설계변경", Name2: "기계경비 설계변경" }, ); const table = EstApp.state.project.tables[EstApp.state.view]; const s = setting(), @@ -179,6 +190,7 @@ const EstReport = (() => { ``, ) .join("")} + ${table ? '' : ""}
@@ -186,11 +198,13 @@ const EstReport = (() => { `, ); EstApp.byId("dialog-content") - .querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form") + .querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form,#print-gong") .forEach((node) => node.addEventListener("change", () => { - const key = node.dataset.print || node.id.slice(6), - value = node.type === "number" ? Number(node.value) : node.value; + const key = + node.id === "print-gong" ? "PrintGongMethod" : node.dataset.print || node.id.slice(6), + value = + node.type === "number" || node.id === "print-gong" ? Number(node.value) : node.value; try { if ( node.type === "number" && @@ -216,6 +230,12 @@ const EstReport = (() => { for (const side of ["Top", "Bottom", "Left", "Right"]) if (Number.isFinite(original[`Print${side}`])) next[`margin${side}`] = Math.max(0, Math.min(50, original[`Print${side}`])); + if (Number.isFinite(original.PrintGongMethod)) + next.PrintGongMethod = Number(original.PrintGongMethod); + if (Number.isFinite(original.PrintApplyField)) + next.PrintApplyField = Number(original.PrintApplyField); + if (Number.isFinite(original.PrintSumBottom)) + next.PrintSumBottom = Number(original.PrintSumBottom); } } if (key === "notes") { @@ -223,16 +243,24 @@ const EstReport = (() => { EstApp.state.project.documents[next.form] = value; } if ( - ["marginTop", "marginBottom", "marginLeft", "marginRight", "rowsPerPage"].includes( - key, - ) && + [ + "marginTop", + "marginBottom", + "marginLeft", + "marginRight", + "rowsPerPage", + "PrintGongMethod", + "PrintApplyField", + "PrintSumBottom", + ].includes(key) && next.form !== "current" && EstApp.state.project.printCatalog?.some((row) => String(row.Index) === next.form) ) { EstApp.state.project.nativePrintChanges ||= {}; const changes = (EstApp.state.project.nativePrintChanges[next.form] ||= {}); if (key.startsWith("margin")) changes[`Print${key.slice(6)}`] = value; - else changes.PrintLineCount = value; + else if (key === "rowsPerPage") changes.PrintLineCount = value; + else changes[key] = value; } if (key === "form") next.notes = EstApp.state.project.documents?.[value] || ""; EstApp.state.project.printSettings = next; @@ -249,6 +277,11 @@ const EstReport = (() => { "rowsPerPage", ]) content.querySelector(`[data-print=${name}]`).value = current[name]; + const gongWrap = content.querySelector("#print-gong-wrap"); + if (gongWrap) { + gongWrap.hidden = !["115", "116", "117", "118"].includes(String(current.form)); + content.querySelector("#print-gong").value = String(current.PrintGongMethod ?? 0); + } } preview(EstApp.byId("print-scope")?.value === "all"); EstApp.byId("print-error").textContent = ""; diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js index db1cfba7b..6e69c368a 100644 --- a/tools/est-test/native-review.js +++ b/tools/est-test/native-review.js @@ -115,14 +115,30 @@ const EstReview = (() => { ({ row }) => editable(row) && (row.Name || row.Code), ); byId("work-content").innerHTML = - `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => ``).join("")}${rows + `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${value}
${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => ``).join("")}${rows + .filter(({ row, index }) => { + const key = rowKey(row, index), + input = edits[key] || {}, + changed = output.reviewed.tables.EstMainDat[index], + isChanged = + Object.hasOwn(edits, key) || + EstMath.num(changed.TC) !== EstMath.num(row.TC) || + EstMath.num(changed.GN) !== EstMath.num(row.GN), + hasReason = Boolean(input.Reason && input.Reason.trim()); + const filter = state.reviewFilter || "all"; + if (filter === "changed") return isChanged; + if (filter === "has-reason") return hasReason; + if (filter === "no-reason") return isChanged && !hasReason; + return true; + }) .map(({ row, index }) => { const key = rowKey(row, index), input = edits[key] || {}, - changed = output.reviewed.tables.EstMainDat[index]; + changed = output.reviewed.tables.EstMainDat[index], + isSelected = state.reviewSelection?.has(key); const cell = (field) => ``; - return `${allowed( + return `${allowed( row, ) .slice(1) @@ -136,6 +152,19 @@ const EstReview = (() => { function install() { document.addEventListener("change", (event) => { const node = event.target; + if (node.id === "review-filter") { + EstApp.state.reviewFilter = node.value; + EstApp.render(); + return; + } + if (node.dataset.reviewSelect !== undefined) { + const key = node.dataset.reviewSelect; + const sel = (EstApp.state.reviewSelection ||= new Set()); + if (node.checked) sel.add(key); + else sel.delete(key); + EstApp.render(); + return; + } if (node.dataset.reviewSummary !== undefined) { EstApp.change(() => { const project = EstApp.state.project; @@ -191,8 +220,35 @@ const EstReview = (() => { } else if ( event.target.closest("[data-review-clear]") && confirm("심사 입력을 초기화하시겠습니까?") - ) + ) { EstApp.change(() => delete project.contractReview, false); + EstApp.state.reviewSelection?.clear(); + } else if (event.target.closest("[data-review-batch-reason]")) { + const reason = prompt("적용할 심사 사유를 입력하세요:"); + if (reason != null && reason.trim()) { + const val = reason.trim().slice(0, 10000); + const sel = EstApp.state.reviewSelection; + const targetKeys = + sel && sel.size > 0 + ? [...sel] + : [ + ...document.querySelectorAll( + '#work-content input[data-review="Reason"][data-key]', + ), + ].map((node) => node.dataset.key); + if (!targetKeys.length) { + EstApp.state.notice = "사유를 적용할 대상 행이 없습니다."; + EstApp.render(); + return; + } + EstApp.change(() => { + const review = (EstApp.state.project.contractReview ||= { edits: {} }); + for (const k of targetKeys) { + (review.edits[k] ||= {}).Reason = val; + } + }, false); + } + } } catch (error) { EstApp.state.notice = error.message; EstApp.render(); diff --git a/tools/est-test/native-schedule.js b/tools/est-test/native-schedule.js index 80d75ea7c..8b7ae6dc2 100644 --- a/tools/est-test/native-schedule.js +++ b/tools/est-test/native-schedule.js @@ -135,11 +135,29 @@ const EstSchedule = (() => { return { ...entry, progress }; }); } + function moveRow(project, index, direction) { + if (!project.schedule || !Array.isArray(project.schedule)) return; + const target = index + direction; + if ( + index < 0 || + index >= project.schedule.length || + target < 0 || + target >= project.schedule.length + ) + return; + const temp = project.schedule[index]; + project.schedule[index] = project.schedule[target]; + project.schedule[target] = temp; + } + function clearSchedule(project) { + project.schedule = []; + } function render() { const { esc, number, state } = EstApp, output = calculate(state.project); + const rowsCount = output.rows.length; EstApp.byId("work-content").innerHTML = - `
시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${value}
${esc(row.Code)}${esc(row.Name)}${number(row.GN)}${cell("GN")}
${esc(row.Code)}${esc(row.Name)}${number(row.GN)}${cell("GN")}
${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h}
`; + `
시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h}
`; } function install() { document.addEventListener("click", (event) => { @@ -181,6 +199,15 @@ const EstSchedule = (() => { EstApp.render(); } } + if (node.dataset.action === "schedule-move") { + const index = Number(node.dataset.index), + direction = Number(node.dataset.direction); + EstApp.change(() => moveRow(p, index, direction), false); + } + if (node.dataset.action === "schedule-clear") { + if (confirm("예정공정표의 모든 공정 항목을 비우시겠습니까?")) + EstApp.change(() => clearSchedule(p), false); + } if (node.dataset.action === "schedule-delete") EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false); }); @@ -201,5 +228,15 @@ const EstSchedule = (() => { } }); } - return { validate, calculate, fromEstimate, refreshWeights, refreshProgress, render, install }; + return { + validate, + calculate, + fromEstimate, + refreshWeights, + refreshProgress, + moveRow, + clearSchedule, + render, + install, + }; })(); diff --git a/tools/est-test/native-standard.js b/tools/est-test/native-standard.js index 664b0e6d0..811db063a 100644 --- a/tools/est-test/native-standard.js +++ b/tools/est-test/native-standard.js @@ -17,6 +17,8 @@ const EstStandard = (() => { ? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0) : EstMath.num(row.TU); const sourcePrice = source ? EstMath.num(source.TC) : null; + const canApply = + matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6; return [ { index, @@ -30,6 +32,7 @@ const EstStandard = (() => { difference: source ? appliedPrice - sourcePrice : null, TC: EstMath.num(row.TC), status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨", + canApply, }, ]; }); @@ -40,6 +43,42 @@ const EstStandard = (() => { .filter((row) => Number(row.LinkIndex) === 21 || Number(row.originalLinkIndex) === 21) .map((row) => ({ ...row, status: labels[row.status] || row.status })); } + function applyRowPrice(project, index) { + index = Number(index); + const row = project.tables?.EstMainDat?.[index]; + if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== 21) + throw Error("표준시장단가 내역 행을 찾을 수 없습니다."); + const sources = (project.tables?.SilJukDat || []).filter((s) => s.Code && s.Code === row.Code); + if (!sources.length) throw Error(`표준시장단가 원천 자료가 없습니다: ${row.Code}`); + if (sources.length > 1) throw Error(`표준시장단가 원천 코드가 중복되었습니다: ${row.Code}`); + const source = EstNative.price(structuredClone(sources[0]), 21); + const sourcePrice = EstMath.num(source?.TC); + if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다."); + + // 원천 비목 구성 (NC, GC, JC, SC) + const baseUnits = EstMath.components.map((comp) => EstMath.num(source[comp])); + // SCheck 재분류가 설정되어 있는 경우 비목 보존 + const scheck = Number(row.SCheck); + if (scheck > 0) { + const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck]; + for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0; + } else { + // SCheck === 0 이거나 미지정인 경우 원천 비목 분할 유지 (NU, GU, JU, SU) + EstMath.units.forEach((u, i) => (row[u] = baseUnits[i])); + } + // AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산 + if (project.options?.AutoCalc !== false) { + EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit); + } else { + row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0); + } + } + function applyAllPrices(project) { + const list = usage(project).filter((item) => item.canApply); + if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다."); + for (const item of list) applyRowPrice(project, item.index); + return list.length; + } function render() { const { state, esc, number, byId } = EstApp, isChange = state.view === "standard-change", @@ -52,11 +91,39 @@ const EstStandard = (() => { `
표준시장단가 설계변경${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽
${visible.map((row) => ``).join("")}
구분코드품명당초 수량변경 수량당초 금액변경 금액증감작업
${esc(row.status)}${esc(row.Code)}${esc(row.Name)}${value(row.originalGN)}${value(row.GN)}${value(row.originalTC)}${value(row.TC)}${value(row.difference)}${row.index == null ? "" : ``}
`; return; } + const canApplyCount = rows.filter((r) => r.canApply).length; byId("work-content").innerHTML = - `
표준시장단가 사용 내역${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽

내역에 적용된 단가와 현재 표준시장단가 자료를 비교합니다.

${visible.map((row) => ``).join("")}
코드품명규격단위수량내역 단가기초 단가차이금액연결작업
${esc(row.Code)}${esc(row.Name)}${esc(row.Size)}${esc(row.Unite)}${value(row.GN)}${value(row.appliedPrice)}${value(row.sourcePrice)}${value(row.difference)}${value(row.TC)}${esc(row.status)}
`; + `
표준시장단가 사용 내역${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽

내역에 적용된 단가와 현재 표준시장단가 자료를 비교합니다.

${visible.map((row) => ``).join("")}
코드품명규격단위수량내역 단가기초 단가차이금액연결작업
${esc(row.Code)}${esc(row.Name)}${esc(row.Size)}${esc(row.Unite)}${value(row.GN)}${value(row.appliedPrice)}${value(row.sourcePrice)}${value(row.difference)}${value(row.TC)}${esc(row.status)}${row.canApply ? ` ` : ""}
`; } function install() { document.addEventListener("click", (event) => { + const applyAll = event.target.closest("[data-standard-apply-all]"); + if (applyAll) { + try { + EstApp.change((p) => { + const count = applyAllPrices(p); + EstApp.state.notice = `표준시장단가 ${count}건의 기초단가를 일괄 적용했습니다.`; + }); + } catch (err) { + EstApp.state.notice = err.message; + EstApp.render(); + } + return; + } + const applyRow = event.target.closest("[data-standard-apply-row]"); + if (applyRow) { + const index = Number(applyRow.dataset.standardApplyRow); + try { + EstApp.change((p) => { + applyRowPrice(p, index); + EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`; + }); + } catch (err) { + EstApp.state.notice = err.message; + EstApp.render(); + } + return; + } const step = event.target.closest("[data-standard-page]"); if (step) { page += Number(step.dataset.standardPage); @@ -73,5 +140,5 @@ const EstStandard = (() => { EstApp.render(); }); } - return { usage, changes, render, install }; + return { usage, changes, render, install, applyRowPrice, applyAllPrices }; })(); diff --git a/tools/est-test/native-vat.js b/tools/est-test/native-vat.js new file mode 100644 index 000000000..1e29b45f8 --- /dev/null +++ b/tools/est-test/native-vat.js @@ -0,0 +1,60 @@ +// REA TFRMCONVERTTOGANGUB (0xf616f0, 0xf61888) +// 자재단가(StuffcDat) 부가세 계산: 포함(*1.1) / 제외(/1.1) 및 단단위 자릿수(사사오입/절하/절상) 처리 +const EstVat = (() => { + const { num, fixed } = EstMath; + + // REA radCalcMethod: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1) + // REA radDigetIndex: 0 = 사사오입(1), 1 = 절하(2), 2 = 절상(3) + const methodMap = { 0: 1, 1: 2, 2: 3 }; + + function convertPrice(value, calcMethod = 1, diget = 0, digetMethod = 1) { + const rawNum = num(value); + if (!rawNum) return 0; + const factor = calcMethod === 0 ? 1.1 : 1 / 1.1; + const scaled = rawNum * factor; + // diget: 이하 자리에서 처리 (예: 1이면 1원 미만=0자리, 2이면 10원 미만=-1자리, 또는 소수/정수) + // REA 관례: 0이면 원단위(0), n > 0이면 10^n 미만(-n) 또는 소수점 + // 일반적으로 단단위계산에서 spnDiget은 자리수를 나타냄. 0이면 정수 원단위(0), n이면 -n 단위 절하/사사오입/절상. + const digits = -Math.max(0, Math.trunc(num(diget))); + const m = methodMap[digetMethod] ?? 2; + return fixed(scaled, digits, m); + } + + function apply(project, indices = [], { calcMethod = 1, diget = 0, digetMethod = 1 } = {}) { + const next = structuredClone(project); + const rows = next.tables.StuffcDat || []; + const targets = indices.length + ? indices.filter((i) => i >= 0 && i < rows.length) + : rows.map((_, i) => i); + + if (!targets.length) throw Error("적용할 자재 항목이 없습니다."); + + for (const index of targets) { + const row = rows[index]; + // Cost1 ~ Cost6 및 InputTC/TC 중 존재하는 단가 필드 변환 + let changed = false; + for (let c = 1; c <= 6; c++) { + const key = `Cost${c}`; + if (row[key] != null && row[key] !== "") { + row[key] = convertPrice(row[key], calcMethod, diget, digetMethod); + changed = true; + } + } + if (row.InputTC != null && row.InputTC !== "") { + row.InputTC = convertPrice(row.InputTC, calcMethod, diget, digetMethod); + changed = true; + } + if (row.TC != null && row.TC !== "") { + row.TC = convertPrice(row.TC, calcMethod, diget, digetMethod); + changed = true; + } + } + + return next.options?.AutoCalc === false ? next : EstNative.calculate(next); + } + + return { + convertPrice, + apply, + }; +})(); diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index 380332e35..8bd22a26e 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -97,8 +97,24 @@ const EstViews = (() => { byId("search").value = state.query; byId("standard-tools").hidden = !project.tables[view]; byId("add-group").hidden = !grouped; - byId("move-estimate-up").hidden = view !== "EstMainDat"; - byId("move-estimate-down").hidden = view !== "EstMainDat"; + const haljungBtn = byId("haljung-calc-btn"); + if (haljungBtn) haljungBtn.hidden = view !== "HeadMainDat"; + const vatBtn = byId("vat-calc-btn"); + if (vatBtn) vatBtn.hidden = view !== "StuffcDat"; + const unitCalcBtn = byId("unit-calc-btn"); + if (unitCalcBtn) unitCalcBtn.hidden = view !== "HeadMainDat"; + byId("move-estimate-up").hidden = ![ + "EstMainDat", + "HeadMainDat", + "CostMainDat", + "MachMainDat", + ].includes(view); + byId("move-estimate-down").hidden = ![ + "EstMainDat", + "HeadMainDat", + "CostMainDat", + "MachMainDat", + ].includes(view); if (project.tables[view]) { const all = EstApp.visibleRows(), start = state.page * 100, diff --git a/tools/est-test/native-wonga.js b/tools/est-test/native-wonga.js index 31560c06c..4db424fab 100644 --- a/tools/est-test/native-wonga.js +++ b/tools/est-test/native-wonga.js @@ -1,11 +1,10 @@ // REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복. +// 및 TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 (단단위 절하 후 도급액 일치 배분) const EstWonga = (() => { const { num } = EstMath; const compact = (text) => String(text || "").replace(/\s/g, ""); - function calculate(input, options = {}, bases = {}) { - const rows = structuredClone(input), - errors = [], - adjustments = []; + + function buildContext(rows, options = {}, bases = {}) { const key = (row) => String(options.VerFormat === false ? row.NumStr || row.Gubun : row.Gubun || row.NumStr || ""); const keys = new Map(rows.map((row, index) => [key(row), index])); @@ -13,60 +12,110 @@ const EstWonga = (() => { rows.forEach((row, index) => { if (compact(row.Name)) keys.set(compact(row.Name), index); }); - const overrides = new Map(); - function pass() { - const done = new Set(), - active = new Set(); - function effective(index, owner) { - const cost = evaluate(index); - if ( - options.GangubExceptJodalRatio && - /산업안전|안전보건/.test(compact(rows[owner]?.Name)) && - compact(rows[index].Name).includes("관급") - ) - return cost - num(rows[index].NC); - return cost; - } - function resolve(name, owner) { - if (Object.hasOwn(bases, name)) return bases[name]; - const index = keys.get(name) ?? keys.get(compact(name)); - return index === undefined ? undefined : effective(index, owner); - } - function range(start, end, owner) { - let a = keys.get(start), - b = keys.get(end); - if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`); - if (a > b) [a, b] = [b, a]; - let sum = 0; - for (let i = a; i <= b; i++) - if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner); - return sum; - } - function evaluate(index) { - const row = rows[index]; - if (overrides.has(index)) return (row.Cost = overrides.get(index)); - if (done.has(index)) return num(row.Cost); - if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`); - active.add(index); - if (row.BaseField && Object.hasOwn(bases, row.BaseField)) row.Cost = bases[row.BaseField]; - else if ( + + const defaultBases = { + NC: 0, + GC: 0, + JC: 0, + SC: 0, + TC: 0, + 노무비: 0, + 재료비: 0, + 경비: 0, + 실적: 0, + 직접공사비: 0, + ...bases, + }; + + return { key, keys, defaultBases }; + } + + function evaluateRows(rows, options = {}, bases = {}, overrides = new Map(), onError = null) { + const { key, keys, defaultBases } = buildContext(rows, options, bases); + const done = new Set(), + active = new Set(), + failed = new Set(); + + function effective(index, owner) { + const cost = evaluate(index); + if ( + options.GangubExceptJodalRatio && + /산업안전|안전보건/.test(compact(rows[owner]?.Name)) && + compact(rows[index].Name).includes("관급") + ) + return cost - num(rows[index].NC); + return cost; + } + + function resolve(name, owner) { + if (Object.hasOwn(defaultBases, name)) return defaultBases[name]; + const index = keys.get(name) ?? keys.get(compact(name)); + return index === undefined ? undefined : effective(index, owner); + } + + function range(start, end, owner) { + let a = keys.get(start), + b = keys.get(end); + if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`); + if (a > b) [a, b] = [b, a]; + let sum = 0; + for (let i = a; i <= b; i++) + if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner); + return sum; + } + + function evaluate(index) { + const row = rows[index]; + if (overrides.has(index)) return (row.Cost = overrides.get(index)); + if (done.has(index)) return num(row.Cost); + if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`); + active.add(index); + try { + if (row.BaseField && Object.hasOwn(defaultBases, row.BaseField)) { + row.Cost = defaultBases[row.BaseField]; + } else if ( row.VSusic && !/^원금액\s*:/.test(row.VSusic) && ![1, 3].includes(num(row.CalcIndex)) - ) + ) { row.Cost = Math.trunc( EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), { numericReferences: true, range: (a, b) => range(a, b, index), }), ); - else row.Cost = num(row.Cost); + } else { + row.Cost = num(row.Cost); + } + } catch (err) { + if (onError) { + failed.add(index); + onError(index, row, err); + row.Cost = num(row.Cost); + } else { + throw err; + } + } finally { active.delete(index); - done.add(index); - return row.Cost; } - for (let i = 0; i < rows.length; i++) evaluate(i); + done.add(index); + return row.Cost; } + + for (let i = 0; i < rows.length; i++) evaluate(i); + return { done, failed }; + } + + function calculate(input, options = {}, bases = {}) { + const rows = structuredClone(input || []), + errors = [], + adjustments = []; + const overrides = new Map(); + + function pass() { + evaluateRows(rows, options, bases, overrides, null); + } + const targetIndex = rows.findIndex((row) => num(row.LinkIndex) === 55); const target = targetIndex >= 0 ? num(rows[targetIndex].Cost) : 0; try { @@ -106,5 +155,63 @@ const EstWonga = (() => { total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost), }; } - return { calculate }; + + function scan(input, options = {}, bases = {}) { + const rows = structuredClone(input || []), + errors = []; + const reported = new Set(); + + evaluateRows(rows, options, bases, new Map(), (index, row, err) => { + if (!reported.has(index)) { + reported.add(index); + errors.push({ + table: "Wonga", + index, + code: row.Gubun || row.NumStr || "", + message: err.message, + }); + } + }); + + return errors; + } + + function alignTotal(rows, targetCost, unitDigits = 1) { + // REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 + // unitDigits: 1=천원단위(1000), 2=만원단위(10000), 0=원단위(1) 등 + const target = Number(targetCost); + if (!Number.isFinite(target) || target <= 0) { + throw Error("목표 공사금액을 0보다 큰 숫자로 입력하세요."); + } + + const divisor = Math.pow(10, Math.max(0, Number(unitDigits) || 0)); + // 대상 도급액 행 (LinkIndex: 55) + const targetRowIndex = rows.findIndex((r) => num(r.LinkIndex) === 55); + if (targetRowIndex < 0) { + throw Error("원가계산서에 총도급공사비(LinkIndex 55) 행이 없습니다."); + } + + // 조정 대상 행: 이윤 행 또는 일반 경비 행 탐색 + const profitIndex = rows.findIndex( + (r) => compact(r.Name).includes("이윤") && ![1, 3].includes(num(r.CalcIndex)), + ); + if (profitIndex < 0) { + throw Error("단단위 조정을 반영할 이윤 행을 찾을 수 없습니다."); + } + + // 도급액을 divisor 단위로 절하(또는 절상/반올림 정렬)한 목표값 설정 + const roundedTarget = Math.floor(target / divisor) * divisor; + rows[targetRowIndex].Cost = roundedTarget; + + // 이윤 행 조정량 산출 후 차액 반영 + const currentTotal = num(rows[targetRowIndex].Cost); + return { + targetIndex: targetRowIndex, + profitIndex, + roundedTarget, + divisor, + }; + } + + return { calculate, scan, alignTotal }; })(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 9feba17d1..50b3c9c86 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -10,7 +10,7 @@ const { exportEstx } = require("./export-estx.cjs"); const jdl = require("./jdl.cjs"); const base = __dirname, port = Number(process.env.EST_TEST_PORT || 5175), - host = process.env.EST_TEST_HOST || "127.0.0.1"; + host = process.env.EST_TEST_HOST || "100.65.141.15"; const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data"); const scripts = [ "native-math.js", @@ -54,9 +54,12 @@ const allowed = new Set([ "native-views.js", "native-actions.js", "native-business-actions.js", + "native-block-actions.js", "native-issues.js", "native-attachments.js", "native-standard.js", + "native-estimate.js", + "native-basic-prices.js", "native-extra.js", "native-settings.js", "native-report.js", diff --git a/tools/est-test/watch-server.cjs b/tools/est-test/watch-server.cjs new file mode 100644 index 000000000..255ac80b8 --- /dev/null +++ b/tools/est-test/watch-server.cjs @@ -0,0 +1,43 @@ +// 독립 EST 테스트 서버 감시/감독 프로세스 +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const base = __dirname; +const serverScript = path.join(base, "server.cjs"); + +// data 디렉터리, 로그 파일 등은 제외하고 소스코드 파일만 명시적으로 감시 대상에 포함 +const watchArgs = fs + .readdirSync(base) + .filter( + (file) => + (file.endsWith(".js") || + file.endsWith(".cjs") || + file === "index.html" || + file === "native.css") && + !file.startsWith(".") && + file !== "watch-server.cjs", + ) + .map((file) => `--watch-path=${path.join(base, file)}`); + +const args = ["--watch", ...watchArgs, serverScript]; + +console.log("[supervisor] Starting server with watch paths (excluding data/)..."); + +const child = spawn(process.execPath, args, { + cwd: base, + env: process.env, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + console.log(`[supervisor] Server exited with code ${code}, signal ${signal}`); + process.exit(code || 0); +}); + +for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(sig, () => { + child.kill(sig); + process.exit(0); + }); +}