diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 4ddfc3f6d..e1f95dd86 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -2,7 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const base = path.resolve(__dirname, "../../tools/est-test"); -const context = vm.createContext({ structuredClone, console }); +const context = vm.createContext({ structuredClone, console, Blob }); for (const file of [ "native-math.js", "native-formula.js", @@ -15,12 +15,23 @@ for (const file of [ "native-escalation.js", "native-progress.js", "native-business.js", + "native-review.js", "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", "native-verify.js", + "native-extra.js", + "native-business-actions.js", + "native-forms.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); } diff --git a/resources/tester/test_est_annual_workflow.py b/resources/tester/test_est_annual_workflow.py index 0962d1d33..3beb2c091 100644 --- a/resources/tester/test_est_annual_workflow.py +++ b/resources/tester/test_est_annual_workflow.py @@ -39,3 +39,29 @@ def test_annual_copy_rejects_invalid_or_forward_references(): })()""", ) assert "참조 항목 없음" in result + + +def test_annual_rejects_names_that_shadow_contract_references(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{Code:'R',GN:1,GC:100,TC:100,CalcIndex:1}]; + return ['기준','계약','계약수량','first'].map(name=>{ + p.annual=[{name,formula:'[기준]*0.5'},{name:'next',formula:'[기준]-['+name+']'}]; + return EstBusiness.annual(p,false); + }); + })()""", + ) + assert all(item["errors"] for item in result[:3]) + assert result[3]["errors"] == [] + assert result[3]["results"][1]["project"]["tables"]["EstMainDat"][0]["TC"] == 50 + + +def test_new_annual_name_does_not_reuse_existing_or_referenced_names(): + result = run( + "math", + """[EstBusiness.newAnnualName([{name:'2차',formula:'[기준]-[1차]'}]), + EstBusiness.newAnnualName([{name:'2차',formula:'[기준]'}]), + EstBusiness.newAnnualName([])]""", + ) + assert result == ["3차", "1차", "1차"] diff --git a/resources/tester/test_est_attachments.py b/resources/tester/test_est_attachments.py new file mode 100644 index 000000000..7fbdb3470 --- /dev/null +++ b/resources/tester/test_est_attachments.py @@ -0,0 +1,124 @@ +from test_est_rea_parity import run +import subprocess +from pathlib import Path + + +def test_attachment_size_prediction_includes_utf8_and_base64_overhead(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.name='한글';const file={name:'파일.bin',type:'',size:4}; + const expected={...p,id:'0'.repeat(24),attachments:[{id:'0'.repeat(36),...file,data:'AAECAw=='}]}; + return {predicted:EstAttachments.requiredBytes(p,[file]),actual:new Blob([JSON.stringify(expected)]).size, + large:EstAttachments.requiredBytes(p,[{name:'large',type:'',size:210000000}]),count:p.attachments.length}; + })()""", + ) + assert result["predicted"] == result["actual"] + assert result["large"] > 256 * 1024 * 1024 + assert result["count"] == 0 + + +def test_attachment_validation_keeps_valid_data_and_rejects_malformed_content(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.attachments=[{id:'one',name:'test.bin',type:'application/octet-stream',size:4,data:'AAECAw=='}]; + const valid=EstModel.validate(p).attachments[0];let rejected=0; + for(const item of [{...valid,data:'AAECAw=A'},{...valid,size:5},{...valid,id:''},{...valid,name:''}]) { + const q=EstNative.empty();q.attachments=[item];try{EstModel.validate(q)}catch{rejected++} + } + const dup=EstNative.empty();dup.attachments=[valid,valid];try{EstModel.validate(dup)}catch{rejected++} + const empty=EstNative.empty();empty.attachments=[{id:'zero',name:'empty',type:'',size:0,data:''}]; + return {valid,rejected,empty:EstModel.validate(empty).attachments[0].size}; + })()""", + ) + assert result["valid"]["data"] == "AAECAw==" + assert result["rejected"] == 5 + assert result["empty"] == 0 + + +def test_estx_export_rejects_web_attachments_instead_of_dropping_them(): + script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',attachments:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result = subprocess.run( + ["node", "-e", script], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result.stdout.decode("utf-8") + + +def test_estx_export_rejects_web_excel_links_instead_of_dropping_them(): + script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',excelQuantityLinks:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result = subprocess.run( + ["node", "-e", script], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + 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 254fddba8..a1fb4a7e2 100644 --- a/resources/tester/test_est_business.py +++ b/resources/tester/test_est_business.py @@ -3,6 +3,72 @@ from test_est_rea_parity import run +def test_estimate_classification_refreshes_only_selected_source_and_preserves_manual_mode(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.AutoCalc=false; + p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0},{Code:'N',Name:'other',Cost1:800,ValueIndex:0}]; + p.tables.EstMainDat=[{PkID:1,Code:'M',Name:'material',LinkIndex:5,GN:2,GU:100,GC:200,JC:0}, + {PkID:2,Code:'N',Name:'other',LinkIndex:5,GN:1,GU:5,GC:5},{LinkIndex:54}]; + const row=p.tables.EstMainDat[0],other=JSON.stringify(p.tables.EstMainDat[1]); + EstBusinessActions.setEstimateClassification(p,row,0,3); + const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC}; + const auto=EstNative.calculate(p).tables.EstMainDat[0]; + EstBusinessActions.setEstimateClassification(p,row,0,0); + const restored={GU:row.GU,JU:row.JU}; + const missing={PkID:3,Code:'missing',LinkIndex:5,SCheck:0,GU:7};p.tables.EstMainDat.push(missing); + EstBusinessActions.setEstimateClassification(p,missing,3,3); + let rejected=0;try{EstBusinessActions.setEstimateClassification(p,missing,3,0)}catch{rejected++} + EstBusinessActions.setEstimateClassification(p,row,0,3); + p.tables.StuffcDat.push({...p.tables.StuffcDat[0]});try{EstBusinessActions.setEstimateClassification(p,row,0,0)}catch{rejected++} + const mismatch={PkID:4,Code:'N',LinkIndex:5,SCheck:0,GU:5};p.tables.EstMainDat.push(mismatch); + EstBusinessActions.setEstimateClassification(p,mismatch,4,3); + try{EstBusinessActions.setEstimateClassification(p,mismatch,4,0)}catch{rejected++} + p.tables.StuffcDat.push({Code:'L',Cost1:1000000000000,ValueIndex:0}); + const large={PkID:5,Code:'L',LinkIndex:5,SCheck:0,GU:999999999900};p.tables.EstMainDat.push(large); + EstBusinessActions.setEstimateClassification(p,large,5,3); + try{EstBusinessActions.setEstimateClassification(p,large,5,0)}catch{rejected++} + return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,missing,mismatch,large,other:JSON.stringify(p.tables.EstMainDat[1])===other,flag:p.options.SourceRefresh}; + })()""", + ) + assert result["manual"] == {"JU": 100, "GU": 0, "GC": 200, "JC": 0} + assert result["auto"] == {"GC": 0, "JC": 200} + assert result["restored"] == {"GU": 100, "JU": 0} + assert result["rejected"] == 4 + assert result["missing"]["SCheck"] == 3 and result["missing"]["JU"] == 7 + assert result["mismatch"]["SCheck"] == 3 and result["mismatch"]["JU"] == 5 + assert result["large"]["SCheck"] == 3 and result["large"]["JU"] == 999999999900 + assert result["other"] and not result["flag"] + + +def test_head_classification_preserves_other_groups_and_rejects_unsafe_restoration(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.AutoCalc=false; + p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0},{Code:'N',Name:'other',Cost1:800,ValueIndex:0}]; + p.tables.HeadMainDat=[{Index:1,Code:'H1',Name:'first'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200,JC:0}, + {Index:2,Code:'H2',Name:'second'},{Index:0,Code:'N',LinkIndex:5,GN:1,GU:5,GC:5}]; + const row=p.tables.HeadMainDat[1],other=JSON.stringify(p.tables.HeadMainDat[3]); + EstBusinessActions.setEstimateClassification(p,row,1,3,'HeadMainDat'); + const otherPreserved=JSON.stringify(p.tables.HeadMainDat[3])===other; + const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC}; + const auto=EstNative.calculate(p).tables.HeadMainDat[1]; + EstBusinessActions.setEstimateClassification(p,row,1,0,'HeadMainDat'); + const restored={GU:row.GU,JU:row.JU}; + const mismatch=p.tables.HeadMainDat[3];EstBusinessActions.setEstimateClassification(p,mismatch,3,3,'HeadMainDat'); + let rejected=false;try{EstBusinessActions.setEstimateClassification(p,mismatch,3,0,'HeadMainDat')}catch{rejected=true} + return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,mismatch,otherPreserved,flag:p.options.SourceRefresh}; + })()""", + ) + assert result["manual"] == {"JU": 100, "GU": 0, "GC": 200, "JC": 0} + assert result["auto"] == {"GC": 0, "JC": 200} + assert result["restored"] == {"GU": 100, "JU": 0} + assert result["rejected"] and result["mismatch"]["JU"] == 5 + assert result["otherPreserved"] and not result["flag"] + + def test_annual_formulas_split_component_amounts_and_preserve_remainder(): result = run( "math", @@ -312,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 ef3fa5ad4..613b3db9c 100644 --- a/resources/tester/test_est_change_workflow.py +++ b/resources/tester/test_est_change_workflow.py @@ -1,6 +1,504 @@ from test_est_rea_parity import run +def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows(): + result = run( + "math", + """(()=>{ + const rows=[{PkID:1,Code:'A'},{PkID:2,Code:'B'},{PkID:3,Code:'C'},{LinkIndex:54}]; + const moved=EstBusiness.moveEstimateRows(rows,new Set([1,2]),-1); + const order=rows.map(row=>row.PkID||0); + const restored=EstBusiness.moveEstimateRows(rows,new Set(moved),1); + let blocked=0; + for (const [data,selection,direction] of [ + [[{Code:'A'},{Code:'A'}],[0],1], + [[{PkID:1},{LinkIndex:54}],[0],1], + [[{PkID:1},{PkID:2},{PkID:3}],[0,2],1], + [[{PkID:1},{Name:'unknown'}],[0],1]]) { + try{EstBusiness.moveEstimateRows(data,new Set(selection),direction)}catch{blocked++} + } + return {moved,order,restored,blocked}; + })()""", + ) + 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", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{Code:'A',Name:'first',GN:2,TC:200}, + {Code:'A',Name:'second',GN:4,TC:400}]; + p.baseline={name:'old',tables:{EstMainDat:[{Code:'A',Name:'first',GN:1,TC:100}, + {Code:'A',Name:'second',GN:3,TC:300},{Code:'A',Name:'removed',GN:5,TC:500}]}}; + return EstBusiness.changes(p); + })()""", + ) + assert [row["originalTC"] for row in result] == [100, 300, 500] + assert [row["difference"] for row in result] == [100, 100, -500] + assert result[-1]["status"] == "deleted" + + +def test_design_change_costs_include_duplicate_new_and_deleted_cost_rows(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.Wonga=[ + {Gubun:'A',Name:'first',Cost:200},{Gubun:'A',Name:'second',Cost:60}, + {Gubun:'C',Name:'new',Cost:25}]; + p.baseline={name:'old',tables:{EstMainDat:[],Wonga:[ + {Gubun:'A',Name:'first',Cost:100},{Gubun:'A',Name:'second',Cost:50}, + {Gubun:'B',Name:'removed',Cost:30}]}}; + const before=JSON.stringify(p), rows=EstBusiness.costChanges(p); + const output=EstForms.data('change-costs',p); + const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node}; + EstExtra.render({project:p,view:'change'}); + return {rows,output,html:node.innerHTML,same:before===JSON.stringify(p)}; + })()""", + ) + assert result["same"] + assert [row["difference"] for row in result["rows"]] == [100, 10, 25, -30] + assert result["output"]["rows"] == result["rows"] + assert "removed" in result["html"] + + +def test_stored_original_costs_remain_attached_to_each_duplicate_row(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.Wonga=[ + {Gubun:'A',Name:'new',Cost:10},{Gubun:'A',Name:'existing',Cost:50,Cost_:30}]; + return EstBusiness.costChanges(p); + })()""", + ) + assert [row["originalCost"] for row in result] == [0, 30] + assert [row["difference"] for row in result] == [10, 20] + + +def test_change_view_includes_named_rows_without_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'earthwork',GN:2,TC:200}]; + p.baseline={name:'base',tables:{EstMainDat:[{PkID:1,Code:'',Name:'earthwork',GN:1,TC:100}]}}; + const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node}; + const state={project:p,view:'change'};EstExtra.render(state);return {html:node.innerHTML,changes:EstBusiness.changes(p)}; + })()""", + ) + assert "earthwork" in result["html"] + assert result["changes"][0]["difference"] == 100 + + def test_original_recalculation_modes_keep_old_quantities_and_only_refresh_selected_prices(): result = run( "math", @@ -67,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 7dab80c36..27c5247d3 100644 --- a/resources/tester/test_est_documents.py +++ b/resources/tester/test_est_documents.py @@ -1,6 +1,206 @@ from test_est_rea_parity import run +def test_print_margins_validate_independently_and_keep_legacy_setting(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); p.printSettings={margin:10,marginTop:11,marginBottom:12,marginLeft:13,marginRight:14}; + const valid=EstModel.validate(p).printSettings; let rejected=0; + for (const key of ['marginTop','marginBottom','marginLeft','marginRight']) { + const q=structuredClone(p);q.printSettings[key]=51;try{EstModel.validate(q)}catch{rejected++} + } + return {valid,rejected}; + })()""", + ) + assert result["valid"] == { + "margin": 10, + "marginTop": 11, + "marginBottom": 12, + "marginLeft": 13, + "marginRight": 14, + } + assert result["rejected"] == 4 + + +def test_custom_print_form_documents_are_saved_and_unknown_keys_rejected(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.documents={'annual-items':'stage note','progress-costs':'progress note','standard-usage':'standard note'}; + const valid=EstModel.validate(p).documents;let rejected=false; + const q=EstNative.empty();q.documents={'invalid-key':'bad'};try{EstModel.validate(q)}catch{rejected=true} + return {valid,rejected}; + })()""", + ) + assert result["valid"] == { + "annual-items": "stage note", + "progress-costs": "progress note", + "standard-usage": "standard note", + } + assert result["rejected"] + + +def test_schedule_refreshes_progress_from_latest_locked_round_without_losing_dates(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10},{PkID:2,Code:'B',GN:0}]; + p.schedule=[{id:'a',SourceKey:'id:1',Code:'A',Name:'work',start:'2026-01-01',end:'2026-02-01',weight:100,progress:10}, + {id:'b',SourceKey:'id:2',Code:'B',Name:'zero',start:'',end:'',weight:0,progress:40}, + {id:'c',Code:'X',Name:'manual',start:'',end:'',weight:10,progress:20}]; + p.progress=[{id:'one',locked:true,result:{rows:[{PkID:1,Code:'A',GN:10,TotalGN:2}]}}, + {id:'two',locked:true,result:{rows:[{PkID:1,Code:'A',GN:10,TotalGN:3},{PkID:2,Code:'B',GN:0,TotalGN:0}]}}]; + const before=JSON.stringify(p), updated=EstSchedule.refreshProgress(p);let bad=0; + p.progress[1].result.rows[0].TotalGN=11;try{EstSchedule.refreshProgress(p)}catch{bad++} + p.progress=[];try{EstSchedule.refreshProgress(p)}catch{bad++} + return {updated,bad,unchanged:JSON.parse(before).schedule}; + })()""", + ) + assert [row["progress"] for row in result["updated"]] == [30, 40, 20] + assert result["updated"][0]["start"] == "2026-01-01" + assert result["updated"][0]["weight"] == 100 + assert result["unchanged"][0]["progress"] == 10 + assert result["bad"] == 2 + + +def test_annual_print_contains_each_stage_and_calculation_errors(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:3,GU:100},{LinkIndex:54}]; + p.annual=[{name:'first',formula:'[기준]*0.25'},{name:'second',formula:'[기준]-[first]'}]; + const q=EstNative.calculate(p), good=EstForms.data('annual-summary',q), items=EstForms.data('annual-items',q); + q.annual[0].formula='[unknown]'; + return {good,items,bad:EstForms.data('annual-summary',q),badItems:EstForms.data('annual-items',q)}; + })()""", + ) + assert [row["directCost"] for row in result["good"]["rows"]] == [75, 225] + assert result["good"]["rows"][1]["formula"] == "[기준]-[first]" + assert result["good"]["errors"] == [] + assert result["bad"]["errors"] + assert [row["stage"] for row in result["items"]["rows"]] == ["first", "second"] + assert [row["TC"] for row in result["items"]["rows"]] == [75, 225] + assert result["badItems"]["errors"] + + +def test_progress_and_escalation_print_use_selected_calculations(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000; + p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'work',GN:10,GU:100,AGU:100,EscActual:3},{LinkIndex:54}]; + p.progress=[{id:'one',name:'first',inputs:{'1':{Gum:2}}}]; + const q=EstProgress.lock(EstNative.calculate(p),'one'); + q.tables.EstMainDat[0].GU=200; + q.progress.push({id:'two',name:'second',inputs:{'1':{Gum:3}}}); + return {locked:EstForms.data('progress-summary',q,{roundId:'one'}), + draft:EstForms.data('progress-summary',q,{roundId:'two'}), + escalation:EstForms.data('escalation-summary',p), + empty:EstForms.data('progress-summary',EstNative.empty())}; + })()""", + ) + assert result["locked"]["rows"][0]["GumTC"] == 200 + assert result["locked"]["rows"][0]["GU"] == 100 + assert result["draft"]["rows"][0]["GumTC"] == 600 + assert result["draft"]["rows"][0]["TotalTC"] == 800 + assert result["escalation"]["rows"][0]["EscExcluded"] == 3 + assert result["escalation"]["rows"][0]["EscApplied"] == 7 + assert result["empty"]["errors"] + + +def test_progress_cost_print_preserves_selected_round_costs(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'D',Name:'direct',BaseField:'GC'},{Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D'}]; + p.progress=[{id:'one',inputs:{'1':{Gum:2,Sa:1}},approved:true}]; + const q=EstProgress.lock(EstNative.calculate(p),'one'); + q.tables.EstMainDat[0].GU=200;q.progress.push({id:'two',inputs:{'1':{Gum:3}}}); + return {one:EstForms.data('progress-costs',q,{roundId:'one'}),two:EstForms.data('progress-costs',q,{roundId:'two'})}; + })()""", + ) + assert result["one"]["rows"][-1]["ContractCost"] == 1000 + assert result["one"]["rows"][-1]["GumCost"] == 200 + assert result["one"]["rows"][-1]["SaCost"] == 100 + assert result["two"]["rows"][-1]["JunCost"] == 100 + assert result["two"]["rows"][-1]["GumCost"] == 600 + assert result["two"]["rows"][-1]["TotalCost"] == 700 + + +def test_escalation_cost_print_uses_computed_cost_breakdowns(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:80},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'D',Name:'direct',BaseField:'GC'},{Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D'}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:3}); + return EstForms.data('escalation-costs',q); + })()""", + ) + assert result["errors"] == [] + assert result["rows"][-1]["ContractCost"] == 800 + assert result["rows"][-1]["adjustedCost"] == 960 + 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", @@ -44,3 +244,249 @@ def test_schedule_dates_ranges_and_weighted_user_progress(): assert result["valid"]["rows"][0]["width"] == 50 assert result["valid"]["rows"][1]["left"] == 50 assert result["bad"] == [True] * 4 + + +def test_schedule_import_and_update_weights_preserve_user_schedule(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{Code:'A',Name:'one',TC:100}, {Code:'B',Name:'two',TC:300}, + {Code:'HEADER',Level:1,TC:999},{Code:'TOTAL',LinkIndex:54,TC:400}]; + p.schedule=[{id:'old',Code:'A',Name:'custom',start:'2026-01-01',end:'2026-01-10',weight:1,progress:40}, + {id:'manual',Code:'other',Name:'manual',start:'',end:'',weight:5,progress:20}]; + let id=0;const added=EstSchedule.fromEstimate(p,()=>String(++id));p.schedule.push(...added); + p.tables.EstMainDat[0].TC=200;p.schedule=EstSchedule.refreshWeights(p); + let duplicate='';p.tables.EstMainDat.push({Code:'A',TC:20}); + try{EstSchedule.fromEstimate(p,()=>String(++id))}catch(e){duplicate=e.message} + return {added,rows:p.schedule,duplicate}; + })()""", + ) + assert [row["Code"] for row in result["added"]] == ["B"] + assert [row["weight"] for row in result["rows"]] == [200, 5, 300] + assert result["rows"][0]["Name"] == "custom" + assert result["rows"][0]["start"] == "2026-01-01" + assert result["rows"][0]["progress"] == 40 + assert result["duplicate"] + + +def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'X',Name:'one',TC:100},{PkID:2,Code:'X',Name:'two',TC:200}, + {PkID:3,Code:null,Name:'three',TC:300}]; + let id=0;p.schedule=EstSchedule.fromEstimate(p,()=>String(++id)); + p.schedule[0].start='2026-01-01';p.schedule[0].progress=50; + p.tables.EstMainDat.reverse();p.tables.EstMainDat[0].TC=400; + const updated=EstSchedule.refreshWeights(p); + return {rows:p.schedule,updated,again:EstSchedule.fromEstimate(p,()=>String(++id))}; + })()""", + ) + assert [row["SourceKey"] for row in result["rows"]] == ["id:1", "id:2", "id:3"] + assert [row["weight"] for row in result["updated"]] == [100, 200, 400] + assert result["updated"][0]["start"] == "2026-01-01" + assert result["updated"][0]["progress"] == 50 + 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", + """(()=>{ + const p=EstNative.empty();p.quantities=[ + {Code:'A',Name:'first',formula:'2',TargetCode:'X'}, + {Code:'B',Name:'second',formula:'[A]*3',TargetCode:'X'}, + {Code:'C',Name:'bad',formula:'[missing]',TargetCode:'X'}]; + p.quantitySheets=[{Code:'Q',Name:'sheet',options:{},rows:[ + {LineData:'4+5 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',Name:'sheet item',GN:0,TargetCode:'Y'}]}]; + return EstForms.data(14,p); + })()""", + ) + assert [row["GN"] for row in result["rows"]] == [2, 6, None, 9] + assert result["rows"][1]["formula"] == "[A]*3" + assert "4+5" in result["rows"][3]["formula"] + 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_escalation_workflow.py b/resources/tester/test_est_escalation_workflow.py index 36ed614c1..182cc3fbd 100644 --- a/resources/tester/test_est_escalation_workflow.py +++ b/resources/tester/test_est_escalation_workflow.py @@ -1,6 +1,42 @@ from test_est_rea_parity import run +def test_escalation_distinguishes_repeated_codes_without_row_ids(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000; + p.tables.EstMainDat=[{Code:'A',Name:'first',GN:10,GU:80}, + {Code:'A',Name:'second',GN:20,GU:50},{LinkIndex:54}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscActual:2}); + Object.assign(q.tables.EstMainDat[1],{AGU:100,GU:120,EscActual:5}); + return EstEscalation.calculate(q); + })()""", + ) + assert [row["EscApplied"] for row in result["rows"][:2]] == [8, 15] + assert [row["EGU"] for row in result["rows"][:2]] == [16, 10] + assert [row["ContractGU"] for row in result["rows"][:2]] == [80, 50] + assert result["excludedTotal"] == 410 + + +def test_progress_exclusions_preserve_new_rows_and_reject_ambiguous_keys(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',EscProgress:9}, + {PkID:2,Code:'B',EscProgress:7}]; + const before=JSON.stringify(p), saved={rows:[{PkID:1,Code:'A',TotalGN:3}]}; + const updates=EstBusiness.progressExclusions(p,saved);let blocked=0; + try{EstBusiness.progressExclusions(p,{rows:[{Code:'A',TotalGN:1},{Code:'A',TotalGN:2}]})}catch{blocked++} + const q=structuredClone(p);q.tables.EstMainDat=[{Code:'A'},{Code:'A'}]; + try{EstBusiness.progressExclusions(q,{rows:[{Code:'A',TotalGN:1}]})}catch{blocked++} + return {updates,blocked,same:before===JSON.stringify(p)}; + })()""", + ) + assert result == {"updates": [{"index": 0, "value": 3}], "blocked": 2, "same": True} + + def test_escalation_preserves_explicit_zero_total_and_falls_back_only_without_wonga(): result = run( "math", 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_exchange.py b/resources/tester/test_est_exchange.py index e9ffdfd63..2fda0429e 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -10,6 +10,33 @@ from test_est_rea_parity import run ROOT = Path(__file__).resolve().parents[2] +def test_report_excel_contains_numeric_values_and_separate_errors(): + import base64 + import openpyxl + + script = """const {server}=require('./tools/est-test/server.cjs'); + server.listen(0,'127.0.0.1',async()=>{try{ + const url=`http://127.0.0.1:${server.address().port}/api/export-report-xlsx`; + const post=body=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const ok=await post({name:'test',columns:['Name','Value'],rows:[['=1+1',7]],detail:'summary',errors:['bad input']}); + const bad=await post({columns:['Name'],rows:[[null]],errors:[]}); + process.stdout.write(JSON.stringify({data:Buffer.from(await ok.arrayBuffer()).toString('base64'),status:bad.status})); + }finally{server.close()}});""" + result = json.loads( + subprocess.run( + ["node", "-e", script], cwd=ROOT, capture_output=True, check=True, timeout=15 + ).stdout + ) + workbook = openpyxl.load_workbook(io.BytesIO(base64.b64decode(result["data"]))) + assert workbook["Report"]["A2"].value == "=1+1" + assert workbook["Report"]["A2"].data_type == "s" + assert workbook["Report"]["B2"].value == 7 + assert workbook["Summary"]["A2"].value == "summary" + assert workbook["Errors"]["A2"].value == "bad input" + assert result["status"] == 400 + workbook.close() + + def export_workbook(project): result = subprocess.run( [ @@ -25,6 +52,60 @@ def export_workbook(project): return result.stdout +def test_excel_escalation_fields_and_settings_roundtrip_validation(): + import openpyxl + + workbook = openpyxl.load_workbook( + io.BytesIO( + export_workbook( + { + "tables": { + "EstMainDat": [{"PkID": 1, "Code": "R", "AGU": 80}, {"LinkIndex": 54}] + }, + "options": {"EscDesignTotal": 1200}, + } + ) + ) + ) + sheet = workbook["Escalation"] + assert "AGU" not in [cell.value for cell in workbook["EstMainDat"][1]] + assert list(next(sheet.values)) == [ + "RowKey", + "ANU", + "AGU", + "AJU", + "ASU", + "EscPlanned", + "EscActual", + "EscProgress", + "EscAuto", + "EscManualQuantity", + "EscConstruction", + "EscPS", + "EscBefore", + ] + assert workbook["EscalationSettings"]["A2"].value == 1200 + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}]; + const before=JSON.stringify(p), sheets={Escalation:[{RowKey:'1',AGU:80,EscActual:3,EscAuto:false,EscManualQuantity:2}],EscalationSettings:[{EscDesignTotal:1200}]}; + const q=EstExchange.apply(p,sheets), output=EstEscalation.calculate(q); + let bad=0; + for(const rows of [[{RowKey:'missing'}],[{RowKey:'1'},{RowKey:'1'}],[{RowKey:'1',EscAuto:'false'}],[{RowKey:'1',AGU:'wrong'}]]) { + try{EstExchange.apply(p,{Escalation:rows})}catch{bad++} + } + try{EstExchange.apply(p,{EscalationSettings:[{EscDesignTotal:-1}]})}catch{bad++} + return {q,output,bad,same:before===JSON.stringify(p)}; + })()""", + ) + assert result["same"] and result["bad"] == 5 + assert result["q"]["options"]["EscDesignTotal"] == 1200 + assert result["q"]["tables"]["EstMainDat"][0]["AGU"] == 80 + assert result["output"]["rows"][0]["EscExcluded"] == 2 + + def test_excel_is_valid_office_zip_with_numeric_text_types_and_no_executable_formulas(): data = export_workbook( { @@ -86,3 +167,280 @@ def test_excel_price_edits_refresh_linked_values_and_keep_binary_fields(): assert result["rejected"] assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 300 assert result["next"]["tables"]["StuffcDat"][0]["Editor"]["binary"] == "AAAA" + + +def test_excel_exports_manual_quantities_and_schedule(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "quantities": [ + { + "Code": "Q1", + "Name": "quantity", + "formula": "[A]*3", + "digits": 2, + "TargetKey": "id:5", + } + ], + "schedule": [ + { + "id": "s1", + "SourceKey": "id:5", + "Code": "", + "Name": "work", + "start": "2026-01-01", + "end": "2026-01-10", + "weight": 100, + "progress": 25, + } + ], + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False) + assert list(workbook["Quantities"].values)[1] == ("Q1", "quantity", None, "id:5", "[A]*3", 2) + assert list(workbook["Schedule"].values)[1] == ( + "s1", + "id:5", + "", + "work", + "2026-01-01", + "2026-01-10", + 100, + 25, + ) + + +def test_excel_quantity_links_export_and_import_reject_invalid_targets(): + import openpyxl + + link = {"target": 5, "file": "source.xlsx", "sheet": "Data", "cell": "B2"} + data = export_workbook({"name": "project", "tables": {}, "excelQuantityLinks": [link]}) + workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False) + assert list(workbook["ExcelQuantityLinks"].values)[1] == (5, "source.xlsx", "Data", "B2") + workbook.close() + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'X',GN:1}]; + const link={target:5,file:'source.xlsx',sheet:'Data',cell:'B2'}; + const q=EstExchange.apply(p,{ExcelQuantityLinks:[link]});let rejected=0; + for(const rows of [[{...link,target:9}],[link,link],[{...link,cell:'XFE1'}]]){ + try{EstExchange.apply(p,{ExcelQuantityLinks:rows,EstMainDat:[{PkID:5,Code:'X',GN:7}]})}catch{rejected++} + } + const fresh=EstExchange.apply(EstNative.empty(),{EstMainDat:[{PkID:5,Code:'X',GN:7}],ExcelQuantityLinks:[link]}); + return {links:q.excelQuantityLinks,rejected,original:p.tables.EstMainDat[0].GN,fresh:fresh.excelQuantityLinks}; + })()""", + ) + assert result == {"links": [link], "rejected": 3, "original": 1, "fresh": [link]} + + +def test_excel_import_does_not_reuse_deleted_excel_link_target(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1}]; + const link={target:2,file:'source.xlsx',sheet:'Data',cell:'B2'};p.excelQuantityLinks=[link]; + const rows=[{PkID:1,Code:'A',GN:2},{PkID:2,Code:'B',GN:5}];let rejected=0; + for(const data of [{EstMainDat:rows},{EstMainDat:rows,ExcelQuantityLinks:[link]}]){ + try{EstExchange.apply(p,data)}catch{rejected++} + } + const cleared=EstExchange.apply(p,{EstMainDat:rows,ExcelQuantityLinks:[]}); + return {rejected,original:p.tables.EstMainDat[0].GN,cleared:cleared.excelQuantityLinks.length}; + })()""", + ) + assert result == {"rejected": 2, "original": 1, "cleared": 0} + + +def test_excel_imports_manual_quantities_and_schedule_and_rejects_invalid(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',Name:'work',GN:1}]; + const qty={Code:'Q1',Name:'quantity',formula:'2*3',digits:2,TargetKey:'id:5',TargetCode:''}; + const sched={id:'s1',SourceKey:'id:5',Code:'',Name:'work',start:'2026-01-01',end:'2026-01-10',weight:100,progress:25}; + const q=EstExchange.apply(p,{Quantities:[qty],Schedule:[sched]}); + EstQuantity.applyTotals(q,EstQuantity.totals(q,'manual')); + let invalid=0;for(const bad of [{Quantities:[qty,qty]},{Quantities:[{...qty,digits:7}]}, + {Schedule:[{...sched,progress:101}]}])try{EstExchange.apply(p,bad)}catch{invalid++} + return {q,invalid,original:p}; + })()""", + ) + assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6 + assert result["q"]["schedule"][0]["SourceKey"] == "id:5" + assert result["q"]["schedule"][0]["progress"] == 25 + assert result["original"]["tables"]["EstMainDat"][0]["GN"] == 1 + assert result["invalid"] == 3 + + +def test_excel_writes_quantity_marker_controls_as_valid_xml(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {"CostMainDat": [{"PkID": 1, "LineData": "A=\x121.5\x12 _x0012_"}]}, + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["CostMainDat"]["B2"].value == "A=_x0012_1.5_x0012_ _x005F_x0012_" + assert ( + run("math", "EstExchange.decodeExcelText('A=_x0012_1.5_x0012_ _x005F_x0012_')") + == "A=\x121.5\x12 _x0012_" + ) + + +def test_excel_exports_and_imports_quantity_documents_with_markers(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "quantitySheets": [ + { + "Code": "Q", + "Name": "sheet", + "options": {"digits": 2, "roundMode": 2}, + "rows": [{"LineData": "2*3 = \x120\x12", "markers": ["r1"]}], + "items": [{"Code": "r1", "Name": "item", "GN": 0, "TargetKey": "id:5"}], + } + ], + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["QDocuments"]["A2"].value == "Q" + assert workbook["QLines"]["C2"].value == "2*3 = _x0012_0_x0012_" + assert workbook["QItems"]["I2"].value == "id:5" + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',GN:1}]; + p.quantitySheets=[{Code:'Q',Name:'old',options:{digits:2,roundMode:2}, + rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',Name:'old',GN:0,Extra:'preserved'}]}]; + const tables={QDocuments:[{Code:'Q',Name:'edited',digits:2,roundMode:2}], + QLines:[{DocumentCode:'Q',Position:1,LineData:'2*3 = \\x120\\x12',Markers:'["r1"]'}], + QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',GN:0,TargetKey:'id:5'}]}; + const q=EstExchange.apply(p,tables),out=EstQuantity.calculate(q.quantitySheets[0]); + EstQuantity.applyTotals(q,EstQuantity.totals(q,'Q')); + let rejected=0;for(const bad of [{QDocuments:tables.QDocuments}, + {...tables,QLines:[{...tables.QLines[0],Markers:'not-json'}]}, + {...tables,QItems:[...tables.QItems,...tables.QItems]}]) + try{EstExchange.apply(p,bad)}catch{rejected++} + return {q,out,rejected,original:p.quantitySheets[0].Name}; + })()""", + ) + assert result["q"]["quantitySheets"][0]["Name"] == "edited" + assert result["q"]["quantitySheets"][0]["items"][0]["Extra"] == "preserved" + assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6 + assert result["out"]["errors"] == [] + assert result["rejected"] == 3 + assert result["original"] == "old" + + +def test_excel_quantity_document_roundtrip_keeps_optional_original_fields(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.quantitySheets=[{Code:'Q',Name:'sheet',options:{digits:2,roundMode:2}, + rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',Name:'item',Size:null,Unite:null,GN:null,LinkCode:'X',Extra:'keep'}]}]; + const tables={QDocuments:[{Code:'Q',Name:'sheet',digits:2,roundMode:2}], + QLines:[{DocumentCode:'Q',Position:1,LineData:'1 = \\x120\\x12',Markers:'["r1"]'}], + QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',Size:null,Unite:null,GN:null,TargetCode:'X',TargetKey:null}]}; + return {before:p.quantitySheets,after:EstExchange.apply(p,tables).quantitySheets}; + })()""", + ) + assert result["before"] == result["after"] + + +def test_excel_exports_and_imports_contract_review_inputs(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "contractReview": {"edits": {"row:1": {"GN": 2, "GU": 80, "Reason": "checked"}}}, + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["Review"]["A2"].value == "row:1" + assert workbook["Review"]["D2"].value == 80 + assert workbook["Review"]["K2"].value == "checked" + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:2,GU:100}]; + const row={RowKey:'row:1',GN:1,GU:80,Reason:'checked'}; + const q=EstExchange.apply(p,{Review:[row]});let rejected=0; + for(const bad of [[row,row],[{...row,GU:'bad'}],[{...row,RowKey:'not-key'}], + [{...row,RowKey:'row:missing'}],[{RowKey:'row:1',GC:4}]]) + try{EstExchange.apply(p,{Review:bad})}catch{rejected++} + return {review:EstReview.calculate(q),edit:q.contractReview.edits['row:1'],rejected}; + })()""", + ) + assert result["review"]["after"] == 80 + assert result["edit"]["Reason"] == "checked" + assert result["rejected"] == 5 + + +def test_excel_exports_only_unlocked_progress_and_annual_stages(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "annual": [{"name": "first", "formula": "[기준]*0.5"}], + "progress": [ + {"id": "locked", "name": "old", "locked": True, "inputs": {"1": {"Gum": 2}}}, + { + "id": "draft", + "name": "new", + "locked": False, + "inputs": {"1": {"Gum": 3, "GumMode": "quantity"}}, + }, + ], + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["Annual"]["A2"].value == "first" + assert workbook["ProgressDrafts"]["A2"].value == "draft" + assert workbook["ProgressDrafts"].max_row == 2 + assert workbook["ProgressInputs"]["A2"].value == "draft" + assert workbook["ProgressInputs"]["D2"].value == 3 + + +def test_excel_imports_progress_drafts_without_changing_locked_rounds(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10,GU:100},{LinkIndex:54}]; + p.progress=[{id:'locked',name:'old',inputs:{'1':{Gum:2}}}]; + const q=EstProgress.lock(EstNative.calculate(p),'locked'); + q.progress.push({id:'draft',name:'new',locked:false,inputs:{'1':{Gum:1}}}); + const locked=JSON.stringify(q.progress[0]); + const tables={ProgressDrafts:[{id:'draft',name:'new',approved:false,separateComponents:true}], + ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'quantity',Gum:3}], + Annual:[{name:'first',formula:'[기준]*0.5'}]}; + const next=EstExchange.apply(q,tables),progress=EstProgress.calculate(next,'draft'); + let rejected=0;for(const bad of [ + {ProgressDrafts:tables.ProgressDrafts}, + {...tables,ProgressInputs:[{RoundId:'locked',RowKey:'1',Gum:9}]}, + {...tables,ProgressDrafts:[{id:'locked'}]}, + {...tables,ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'invalid'}]}, + {...tables,ProgressInputs:[{RoundId:'draft',RowKey:'missing',Gum:1}]}]) + try{EstExchange.apply(q,bad)}catch{rejected++} + return {same:JSON.stringify(next.progress[0])===locked,progress,annual:next.annual,rejected}; + })()""", + ) + assert result["same"] + assert result["progress"]["rows"][0]["JunTC"] == 200 + assert result["progress"]["rows"][0]["TotalTC"] == 500 + assert result["annual"] == [{"name": "first", "formula": "[기준]*0.5"}] + assert result["rejected"] == 5 diff --git a/resources/tester/test_est_issues.py b/resources/tester/test_est_issues.py new file mode 100644 index 000000000..524e54061 --- /dev/null +++ b/resources/tester/test_est_issues.py @@ -0,0 +1,536 @@ +from test_est_rea_parity import run + + +def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modifying_data(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.StuffcDat=[{Code:'A',Name:'sand',Size:'large'}, + {Code:'B',Name:'first'},{Code:'B',Name:'second'}]; + p.tables.HeadMainDat=[{Index:1,Code:'H',Name:'group'},{Index:0,Code:'A',LinkIndex:5,Name:'sand',Size:'large'}]; + p.tables.EstMainDat=[{Code:'A',LinkIndex:5,Name:'stone',Size:'small'}, + {Code:'B',LinkIndex:5,Name:'ambiguous'},{Code:'missing',LinkIndex:5,Name:'missing'}, + {Code:'H',LinkIndex:103,Name:'group'}]; + const before=JSON.stringify(p),issues=EstIssues.scan(p); + return {issues,same:before===JSON.stringify(p)}; + })()""", + ) + assert result["same"] + items = {(row["table"], row["index"], row["message"]) for row in result["issues"]} + assert ("StuffcDat", 1, "코드 중복") in items + assert ("StuffcDat", 2, "코드 중복") in items + assert ("EstMainDat", 0, "연결 품명 차이") in items + assert ("EstMainDat", 0, "연결 규격 차이") in items + assert ("EstMainDat", 1, "연결 코드 중복") in items + assert ("EstMainDat", 2, "연결 자료 없음") in items + 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", + """(()=>{ + const p=EstNative.empty(); + p.tables.CostMainDat=[{Index:1,Code:'A'}, {Index:0}, {Index:0}, + {Index:1,Code:'B'}, {Index:0}, {Index:1,Code:'B'}, {Index:0}]; + p.calculation={errors:['A:2 bad formula','A:8 invalid row','B:2 duplicate','other error']}; + return EstIssues.scan(p).filter(x=>x.message.includes('formula') || + x.message.includes('row') || x.message.includes('duplicate') || x.message==='other error'); + })()""", + ) + 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 a7cc4682e..a205d6d82 100644 --- a/resources/tester/test_est_native_export.py +++ b/resources/tester/test_est_native_export.py @@ -52,7 +52,7 @@ def test_estx_print_edits_patch_only_requested_fields_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'),s=require('./tools/est-test/server.cjs'); const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),before=structuredClone(p.printCatalog); - p.nativePrintChanges={'1':{PrintTop:13,PrintBottom:13,PrintLeft:13,PrintRight:13,PrintLineCount:19}}; + p.nativePrintChanges={'1':{PrintTop:13,PrintBottom:14,PrintLeft:15,PrintRight:16,PrintLineCount:19}}; const retained=s.calculate(structuredClone(p)).nativePrintChanges['1'].PrintLineCount===19; const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out),form=q.printCatalog.find(r=>r.Index===1); const other=before.filter(r=>r.Index!==1).every(r=>JSON.stringify(r)===JSON.stringify(q.printCatalog.find(x=>x.Index===r.Index))); @@ -65,9 +65,9 @@ def test_estx_print_edits_patch_only_requested_fields_when_sample_exists(): ) assert result == { "top": 13, - "bottom": 13, - "left": 13, - "right": 13, + "bottom": 14, + "left": 15, + "right": 16, "lines": 19, "other": True, "sameForm": True, @@ -90,6 +90,62 @@ def test_quantity_document_edits_keep_styles_and_bookmarks_and_block_marker_edit assert result == {"same": True, "tags": True, "rejected": True} +def test_estx_quantity_item_labels_round_trip_when_sample_exists(): + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.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'); + 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].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'); + const other=JSON.stringify(q.quantitySheets[0].items.slice(1))===JSON.stringify(before[0].items.slice(1)); + 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,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" + ) + ) + assert result == { + "name": "수정된 품명", + "size": "수정 규격", + "unite": "m2", + "other": True, + "rest": True, + "preserved": True, + "rejected": True, + } + + +def test_estx_quantity_description_preserves_mismatched_result_when_sample_exists(): + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.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'),w=require('./tools/est-test/export-quantity.cjs'); + const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),base=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>s.Code==='N0000000083'); + sheet.rows[0].LineData=sheet.rows[0].LineData.replace('석축쌓기','석축공사'); + const q=m.importEstx(e.exportEstx(p)),saved=q.quantitySheets.find(s=>s.Code===sheet.Code); + sheet.rows[3].LineData=sheet.rows[3].LineData.replace('220','221');let rejected=false; + try{w.updateQuantities(p,base,e.cp949,e.memory)}catch{rejected=true} + sheet.rows[3].LineData=sheet.rows[3].LineData.replace('221','220'); + sheet.rows[5].LineData=sheet.rows[5].LineData.replace('286','287');let markerRejected=false; + try{w.updateQuantities(p,base,e.cp949,e.memory)}catch{markerRejected=true} + console.log(JSON.stringify({text:saved.rows[0].LineData,quantity:saved.items[4].GN, + preserved:JSON.stringify(saved.rows.slice(1))===JSON.stringify(base.quantitySheets.find(s=>s.Code===sheet.Code).rows.slice(1)),rejected,markerRejected}));""" + result = json.loads( + subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode( + "utf-8" + ) + ) + assert result["text"].startswith("석축공사") + assert result["quantity"] == 286 + assert result["preserved"] and result["rejected"] and result["markerRejected"] + + def test_quantity_document_edit_across_character_styles_preserves_tags(): script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); const doc='심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.
${esc(output.errors.join(" / "))}
| ${value} | `).join("")}||||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| ${esc(row.Code)} | ${esc(row.Name)} | ${number(row.GN)} | ${cell("GN")} | ${allowed( + row, + ) + .slice(1) + .map((field) => `${cell(field)} | `) + .join( + "", + )}${number(row.TC)} | ${number(changed.TC)} | ${number(EstMath.num(changed.TC) - EstMath.num(row.TC))} |
기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.
| ${h} | `).join("")}||
|---|---|---|
| `).join("")} |
기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.
| ${h} | `).join("")}||
|---|---|---|
| `).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 ? "" : ``} |
내역에 적용된 단가와 현재 표준시장단가 자료를 비교합니다.
| 코드 | 품명 | 규격 | 단위 | 수량 | 내역 단가 | 기초 단가 | 차이 | 금액 | 연결 | 작업 |
|---|---|---|---|---|---|---|---|---|---|---|
| ${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 ? ` ` : ""} |