diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index b0a2350e5..e9617ce0a 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -12,6 +12,8 @@ for (const file of [ "native-quantity.cjs", "native-wonga.js", "native-engine.js", + "native-escalation.js", + "native-progress.js", "native-business.js", "native-resources.js", "native-model.js", diff --git a/resources/tester/test_est_annual_workflow.py b/resources/tester/test_est_annual_workflow.py new file mode 100644 index 000000000..0962d1d33 --- /dev/null +++ b/resources/tester/test_est_annual_workflow.py @@ -0,0 +1,41 @@ +from test_est_rea_parity import run + + +def test_annual_preview_is_financial_only_and_expanded_copy_preserves_assets(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.id='123456789012345678901234';p.name='source'; + p.options.SourceRefresh=false;p.originalArchive='original binary'; + p.drawings=[{name:'drawing',binary:'AA=='}]; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:3,GU:101},{LinkIndex:54}]; + p.tables.StuffcDat=[{Code:'M',InputTC:123,ValueIndex:4}]; + p.annual=[{name:' first ',formula:'[기준]*0.25'},{name:'second',formula:'[기준]-[first]'}]; + const q=EstNative.calculate(p),before=JSON.stringify(q); + const preview=EstBusiness.annual(q,false),copy=EstBusiness.annualCopy(q,0); + copy.tables.StuffcDat[0].InputTC=456; + return {preview,copy,same:before===JSON.stringify(q)}; + })()""", + ) + assert result["same"] + assert result["preview"]["errors"] == [] + assert result["preview"]["results"][0]["name"] == "first" + assert "originalArchive" not in result["preview"]["results"][0]["project"] + assert result["copy"]["originalArchive"] == "original binary" + assert result["copy"]["drawings"][0]["binary"] == "AA==" + assert "id" not in result["copy"] + assert result["copy"]["progress"] == result["copy"]["annual"] == [] + assert result["copy"]["tables"]["EstMainDat"][0]["TC"] == 75 + assert result["preview"]["results"][1]["project"]["tables"]["EstMainDat"][0]["TC"] == 228 + + +def test_annual_copy_rejects_invalid_or_forward_references(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.annual=[{name:'first',formula:'[second]'},{name:'second',formula:'[기준]'}]; + p.tables.EstMainDat=[{Code:'R',GN:1,GC:100,TC:100,CalcIndex:1}]; + try{EstBusiness.annualCopy(p,0);return false;}catch(error){return error.message;} + })()""", + ) + assert "참조 항목 없음" in result diff --git a/resources/tester/test_est_change_workflow.py b/resources/tester/test_est_change_workflow.py new file mode 100644 index 000000000..ef3fa5ad4 --- /dev/null +++ b/resources/tester/test_est_change_workflow.py @@ -0,0 +1,69 @@ +from test_est_rea_parity import run + + +def test_original_recalculation_modes_keep_old_quantities_and_only_refresh_selected_prices(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.StuffcDat=[{PkID:1,Code:'M',ValueIndex:4,InputTC:100}]; + p.tables.EstMainDat=[{PkID:1,Code:'M',LinkIndex:5,GN:2},{PkID:2,LinkIndex:54}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + const before=JSON.stringify(q.baseline); + q.tables.StuffcDat[0].InputTC=150;q.tables.EstMainDat[0].GN=7; + q.tables.EstMainDat.splice(1,0,{PkID:3,Code:'M',LinkIndex:5,GN:1,NewItem:true}); + const modes=[0,1,2].map(method=>EstBusiness.original(q,method).tables.EstMainDat); + return {modes,unchanged:before===JSON.stringify(q.baseline)}; + })()""", + ) + assert result["unchanged"] + for mode, rows in enumerate(result["modes"]): + assert rows[0]["GN"] == 2 + assert rows[0]["TC"] == (200 if mode == 0 else 300) + assert rows[1]["TC"] == 150 + assert rows[-1]["TC"] == (350 if mode == 0 else 450) + + +def test_design_change_includes_deleted_and_new_rows_without_modifying_contract(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'A',Name:'kept',GN:2,GU:100}, + {PkID:3,Code:'B',Name:'removed',GN:3,NU:50},{PkID:2,LinkIndex:54}]; + let q=EstBusiness.baseline(EstNative.calculate(p)); + const before=JSON.stringify(q.baseline); + q.tables.EstMainDat.splice(1,1); + q.tables.EstMainDat[0].GN=4; + q.tables.EstMainDat.splice(1,0,{...EstModel.newRow('EstMainDat',q.tables.EstMainDat,q),Code:'C',Name:'new',GN:1,JU:25}); + q=EstNative.calculate(q); + return {changes:EstBusiness.changes(q),same:before===JSON.stringify(q.baseline)}; + })()""", + ) + assert result["same"] + rows = {row["Code"]: row for row in result["changes"] if row["Code"]} + assert rows["A"]["status"] == "changed" + assert rows["A"]["difference"] == 200 + assert rows["B"]["status"] == "deleted" + assert rows["B"]["originalGN"] == 3 + assert rows["B"]["GN"] == rows["B"]["TC"] == 0 + assert rows["B"]["difference"] == -150 + assert rows["C"]["status"] == "new" + assert rows["C"]["difference"] == 25 + + +def test_blank_codes_have_distinct_keys_and_metadata_changes_are_reported(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'A',Name:'renamed',GN:1,TC:10}, + {Code:'',Name:'first'},{Code:'',Name:'second'}]; + p.baseline={name:'old',tables:{EstMainDat:[ + {PkID:1,Code:'A',Name:'old',GN:1,TC:10}]}}; + return {rows:EstBusiness.changes(p),keys:p.tables.EstMainDat.map(EstBusiness.rowKey)}; + })()""", + ) + assert len(set(result["keys"][1:])) == 2 + assert result["rows"][0]["status"] == "changed" + assert result["rows"][0]["difference"] == 0 diff --git a/resources/tester/test_est_escalation_workflow.py b/resources/tester/test_est_escalation_workflow.py new file mode 100644 index 000000000..bb62d535e --- /dev/null +++ b/resources/tester/test_est_escalation_workflow.py @@ -0,0 +1,94 @@ +from test_est_rea_parity import run + + +def test_escalation_uses_design_denominator_and_all_price_branches(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:80},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'G',Name:'direct',BaseField:'GC'}, + {Gubun:'T',Name:'total',LinkIndex:55,VSusic:'G'}]; + const q=EstBusiness.baseline(EstNative.calculate(p)); + Object.assign(q.tables.EstMainDat[0],{AGU:100,GU:120,EscPlanned:2,EscActual:3,EscProgress:1}); + const before=JSON.stringify(q),output=EstBusiness.escalation(q); + const cases=[[100,80,120],[80,100,120],[100,80,90],[100,80,60]].map(([a,c,b])=>{ + const x=structuredClone(q);x.tables.EstMainDat[0].AGU=a;x.tables.EstMainDat[0].GU=b; + x.baseline.tables.EstMainDat[0].GU=c;return EstBusiness.escalation(x).rows[0]; + }); + return {output,cases,same:before===JSON.stringify(q)}; + })()""", + ) + assert result["same"] + output = result["output"] + assert output["errors"] == [] + assert output["designTotal"] == 1000 + assert output["contractTotal"] == 800 + assert output["adjustedTotal"] == 960 + assert output["excludedTotal"] == 240 + assert output["applied"] == 560 + assert output["rate"] == 16 + assert output["adjustment"] == 89 + assert [row["EGU"] for row in result["cases"]] == [16, 20, 0, 0] + + +def test_native_contract_columns_and_manual_exclusions_and_percent_units(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:50,GN_:50,Unite:'%',GU:120,GU_:80,AGU:100, + EscAuto:false,EscManualQuantity:10,EscPlanned:40},{LinkIndex:54}]; + return EstBusiness.escalation(p); + })()""", + ) + assert result["errors"] == [] + assert result["rows"][0]["EscExcluded"] == 10 + assert result["rows"][0]["EscApplied"] == 40 + assert result["rows"][0]["GC"] == 48 + assert result["contractTotal"] == 40 + assert result["excludedTotal"] == 8 + assert result["designTotal"] == 50 + + +def test_fixed_contract_amount_excludes_only_the_selected_quantity(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GC:1000,TC:1000,CalcIndex:1,EscActual:3}, + {LinkIndex:54}];p.options.EscDesignTotal=1200; + return EstBusiness.escalation(p); + })()""", + ) + assert result["errors"] == [] + assert result["contractTotal"] == 1000 + assert result["excludedTotal"] == 300 + assert result["applied"] == 700 + assert result["rate"] == 0 + + +def test_contract_and_bid_file_import_follow_native_record_order_and_preserve_working_file(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.id='123456789012345678901234';p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:7,Code:'R',GN:10,GU:120},{PkID:8,LinkIndex:54}]; + const incoming=structuredClone(p);incoming.name='contract'; + incoming.tables.EstMainDat[0].PkID=1;incoming.tables.EstMainDat[0].GU=80; + const before=JSON.stringify(p),q=EstBusiness.importContract(p,incoming); + incoming.tables.EstMainDat[0].GU=100; + const r=EstBusiness.importBid(q,incoming); + const output=EstBusiness.escalation(r); + incoming.tables.EstMainDat[0].Code='different';let rejected=false; + try{EstBusiness.importContract(p,incoming);}catch{rejected=true;} + return {r,output,rejected,same:before===JSON.stringify(p)}; + })()""", + ) + assert result["same"] and result["rejected"] + assert result["r"]["id"] == "123456789012345678901234" + assert result["r"]["tables"]["EstMainDat"][0]["GU"] == 120 + assert result["r"]["baseline"]["tables"]["EstMainDat"][0]["PkID"] == 7 + assert result["output"]["contractTotal"] == 800 + assert result["output"]["designTotal"] == 1000 + assert result["output"]["rate"] == 16 diff --git a/resources/tester/test_est_progress_workflow.py b/resources/tester/test_est_progress_workflow.py new file mode 100644 index 000000000..c5b398488 --- /dev/null +++ b/resources/tester/test_est_progress_workflow.py @@ -0,0 +1,104 @@ +from test_est_rea_parity import run + + +def test_progress_amount_input_preserves_exact_money_and_component_total(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:3,NU:33,GU:33,JU:34},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'D',BaseField:'TC'},{Gubun:'T',LinkIndex:55,VSusic:'D'}]; + p.progress=[{id:'one',inputs:{'1':{Gum:100,GumMode:'amount',Sa:90,SaMode:'amount'}},approved:true}]; + return EstProgress.calculate(EstNative.calculate(p),'one'); + })()""", + ) + assert result["errors"] == [] + row = result["rows"][0] + assert row["GumTC"] == 100 + assert sum(row["Gum" + key] for key in ["NC", "GC", "JC", "SC"]) == 100 + assert row["TotalTC"] == 90 + assert result["total"] == 90 + + +def test_progress_fixed_amount_and_zero_quantity_ratio(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'R',GN:0,GC:1000,TC:1000,CalcIndex:1},{LinkIndex:54}]; + p.progress=[{id:'one',inputs:{'1':{Gum:25,GumMode:'ratio',Sa:333,SaMode:'amount'}}}]; + return EstProgress.calculate(p,'one'); + })()""", + ) + assert result["errors"] == [] + assert result["rows"][0]["GumGN"] == 0 + assert result["rows"][0]["GumTC"] == 250 + assert result["rows"][0]["SaTC"] == 333 + + +def test_progress_ratio_quantity_precision_and_bankers_percentage(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'R',GN:10,NU:10,NC:100,TC:100}, + {PkID:2,Name:'percentage',GN:5,LinkIndex:67,SCheck:2,Size:'노무',NC:5,TC:5}, + {LinkIndex:54}]; + p.progress=[{id:'one',inputs:{'1':{Gum:25,GumMode:'ratio',Sa:5}}}]; + return EstProgress.calculate(p,'one'); + })()""", + ) + assert result["errors"] == [] + assert result["rows"][0]["GumGN"] == 3 + assert result["rows"][1]["GumTC"] == 2 + assert result["rows"][1]["SaTC"] == 2 # Round(2.5) = 2 in c9261c. + + +def test_locked_progress_uses_its_contract_and_server_rebuilds_its_totals(): + 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:'material',BaseField:'GC'}, + {Gubun:'V',Name:'부가세',Youl:'10%',VSusic:'D*0.1'}, + {Gubun:'T',Name:'total',LinkIndex:55,VSusic:'D+V'}]; + p.progress=[{id:'one',inputs:{'1':{Gum:2}}}]; + const q=EstProgress.lock(EstNative.calculate(p),'one'); + q.tables.EstMainDat[0].GU=200; + q.progress[0].result.rows[0].TotalTC=99999; + EstProgress.rebuild(q); + q.progress.push({id:'two',inputs:{'1':{Gum:3}}}); + const second=EstProgress.calculate(q,'two'); + const unlocked=EstProgress.unlock(q,'one'); + return {q,second,unlocked}; + })()""", + ) + assert result["q"]["progress"][0]["result"]["rows"][0]["TotalTC"] == 200 + second = result["second"] + assert second["errors"] == [] + assert second["rows"][0]["JunTC"] == 200 + assert second["rows"][0]["GumTC"] == 600 + assert second["rows"][0]["TotalTC"] == 800 + assert second["costs"][-1]["TotalCost"] == 880 + assert all(not row["locked"] for row in result["unlocked"]["progress"]) + assert all("result" not in row for row in result["unlocked"]["progress"]) + + +def test_progress_total_only_mode_and_invalid_saved_input_rejection(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,TC:1000,CalcIndex:1},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'D',BaseField:'TC'},{Gubun:'T',LinkIndex:55,VSusic:'D'}]; + p.progress=[{id:'one',separateComponents:false,inputs:{'1':{Gum:25,GumMode:'ratio'}}}]; + const output=EstProgress.calculate(p,'one'); + p.progress[0].inputs['1'].Gum='invalid';let rejected=false; + try{EstModel.validate(p);}catch{rejected=true;} + return {output,rejected}; + })()""", + ) + assert result["rejected"] + assert result["output"]["errors"] == [] + assert result["output"]["rows"][0]["GumTC"] == 250 + assert result["output"]["total"] == 250 diff --git a/resources/tester/test_est_rea_parity.py b/resources/tester/test_est_rea_parity.py index d144b2a47..18ec845ed 100644 --- a/resources/tester/test_est_rea_parity.py +++ b/resources/tester/test_est_rea_parity.py @@ -223,14 +223,15 @@ def test_progress_accepted_rounds_annual_allocation_and_resources(): result = run("business") first, second = result["first"], result["second"] assert first["errors"] == second["errors"] == [] - assert first["rows"][0]["GumGN"] == 2.5 + # c914d4 rounds ratio-derived quantities to the contract quantity's digits. + assert first["rows"][0]["GumGN"] == 3 assert first["rows"][0]["TotalGN"] == 1.5 assert first["total"] == 330 assert second["rows"][0]["JunGN"] == 1.5 assert second["rows"][0]["GumGN"] == 2.5 assert second["rows"][0]["TotalGN"] == 4 assert second["rows"][0]["TotalTC"] == 800 - assert first["rows"][-1]["GumTC"] == 550 + assert first["rows"][-1]["GumTC"] == 660 assert first["rows"][-1]["SaTC"] == first["rows"][-1]["TotalTC"] == 330 assert second["rows"][-1]["JunTC"] == 330 assert second["rows"][-1]["TotalTC"] == 880 @@ -246,8 +247,10 @@ def test_progress_accepted_rounds_annual_allocation_and_resources(): assert escalation["adjustedTotal"] == 2640 assert escalation["excludedTotal"] == 660 assert escalation["applied"] == 1540 - assert escalation["rate"] == 20 - assert escalation["adjustment"] == 308 + # REA 10a8fa8: investigation amount 1,980 is the denominator, not contract 2,200. + assert escalation["designTotal"] == 1980 + assert escalation["rate"] == 22.22 + assert escalation["adjustment"] == 342 assert [ next(row["TC"] for row in item["tables"]["EstMainDat"] if row.get("LinkIndex") == 54) for item in result["originals"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index b8967b6df..a7c447efb 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -46,12 +46,18 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력·연차 전용 시험 74개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다. -**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다. +2026-11-06 지정 범위인 설계변경·기성·물가변동·연차의 웹앱 구현을 완료했습니다. 설계변경은 당초 보존·재계산 0/1/2와 신규·삭제·변경 비교를 제공합니다. 삭제된 당초/기성 행 번호를 새 행에 재사용하지 않습니다. + +기성은 수량·금액·비율·비목별 입력, 사정 승인, 전회·금회·누계 원가를 계산합니다. 확정 회차는 당시 계약 자료를 보존하고 서버 저장 시 결과를 재검산합니다. 전회 수정은 이후 회차의 확정도 해제하며, 전회 미확정 상태에서 다음 회차를 확정할 수 없습니다. + +물가변동은 같은 행 구조의 계약·입찰 자료(JSON/ESTX)를 불러와 조사·계약·변동 단가를 나눕니다. 원본 레코드 순서 방식에 따라 행 순서·코드·레벨이 다른 자료는 거부합니다. 등락 분기, 조사금액 기준 조정률, 자동 최대수량/수동 적용제외, 간접비 포함 적용대가와 기성 누계 불러오기를 제공합니다. 연차는 수식 참조 오류를 차단하며, 미리보기 뒤 선택한 차수만 독립 사본으로 엽니다. + +**전체 원본 실행 동등성은 미확인입니다.** 위 네 업무는 REA 분기·합성 시나리오·실제 Edge 화면 조작·서버 저장/재열기로 검증했으며, 원본 업무 실행 결과 샘플 대조는 없습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 이번 지정 범위 밖이며 미구현입니다. 검사: ```powershell -./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py resources/tester/test_est_history.py resources/tester/test_est_text_units.py -q +./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py resources/tester/test_est_history.py resources/tester/test_est_text_units.py resources/tester/test_est_change_workflow.py resources/tester/test_est_progress_workflow.py resources/tester/test_est_escalation_workflow.py resources/tester/test_est_annual_workflow.py -q ``` `native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index ac8aea017..b6562c8bc 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -13,6 +13,8 @@ + + @@ -21,6 +23,7 @@ +
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 3245504c3..eb6afee0e 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -58,7 +58,7 @@ const EstActions = (() => { const rows = activeRows(), { state } = EstApp; EstApp.change(() => { - const row = EstModel.newRow(state.view, rows); + const row = EstModel.newRow(state.view, rows, state.project); if (state.view === "MachMainDat") row.Index = -3; const group = EstNative.groups(rows).get(state.group); const at = @@ -84,7 +84,7 @@ const EstActions = (() => { EstApp.change(() => { const index = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)) + 1; const row = { - ...EstModel.newRow(state.view, rows), + ...EstModel.newRow(state.view, rows, state.project), Index: index, Code: values.code, Name: values.name, @@ -172,7 +172,7 @@ const EstActions = (() => { break; } const copies = structuredClone(clipboard); - let next = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0)); + let next = EstModel.newRow(state.view, rows, state.project).PkID - 1; let groupCode = "", nextIndex = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)); copies.forEach((r) => { @@ -399,24 +399,21 @@ const EstActions = (() => { state.roundId = next.id; }, false); } else if (name === "lock-round") { - const round = project.progress.find((r) => r.id === state.roundId), - result = EstBusiness.progress(project, round.id); - if (result.errors.length) throw Error(result.errors.join("\n")); - if (confirm("이 회차를 확정하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.")) - EstApp.change(() => { - round.result = result; - round.locked = true; - }, false); + if ( + confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.") + ) { + EstApp.change(() => (state.project = EstProgress.lock(project, state.roundId)), false); + await EstApp.save(); + } } else if (name === "add-annual") EstApp.change( () => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }), false, ); else if (name === "open-annual") { - const result = EstBusiness.annual(project); - if (result.errors.length) throw Error(result.errors.join("\n")); + const result = EstBusiness.annualCopy(project, Number(node.dataset.row)); if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) { - EstApp.setProject(result.results[Number(node.dataset.row)].project); + EstApp.setProject(result); EstApp.navigate("EstMainDat"); } } else if (name === "delete-extra") { @@ -430,8 +427,10 @@ const EstActions = (() => { pending = null; EstApp.byId("dialog").close(); } else if (name === "close-dialog") EstApp.byId("dialog").close(); + else await EstBusinessActions.handle(name, node); } function install() { + EstBusinessActions.install(); document.addEventListener("click", async (event) => { try { const marker = event.target.closest("[data-marker]"); @@ -470,6 +469,7 @@ const EstActions = (() => { project = state.project; state.notice = ""; try { + if (EstBusinessActions.change(node)) return; if (node.matches("[data-select]")) { const index = Number(node.dataset.select); node.checked ? state.selection.add(index) : state.selection.delete(index); @@ -552,6 +552,8 @@ const EstActions = (() => { EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value); if (state.group === previous && Number(row.Index) > 0) state.group = row.Code; } else row[key] = value; + if (state.view === "escalation" && EstMath.units.includes(key)) + project.options.SourceRefresh = false; if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) { if (key === "TC") { row.InputTC = value; diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index 43a32588c..42dcd10b5 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -100,6 +100,9 @@ const EstApp = (() => { state.selection.clear(); state.group = ""; state.page = 0; + state.roundId = + state.project.progress.find((round) => round.id === state.roundId)?.id || + state.project.progress[0]?.id; if (state.project.options.AutoCalc !== false) calculate(); else state.project.calculation ||= { diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js new file mode 100644 index 000000000..060b27801 --- /dev/null +++ b/tools/est-test/native-business-actions.js @@ -0,0 +1,186 @@ +const EstBusinessActions = (() => { + let purpose = "", + documentKey; + const currentRound = () => + EstApp.state.project.progress.find((round) => round.id === EstApp.state.roundId); + function numeric(value) { + const n = Number(value); + if (!Number.isFinite(n) || Math.abs(n) > Number.MAX_SAFE_INTEGER) + throw Error("유효한 수치를 입력하세요."); + return n; + } + function change(node) { + const { state } = EstApp; + if (node.dataset.option === "EscDesignTotal") { + const value = numeric(node.value); + if (value < 0) throw Error("조사 도급액은 0 이상입니다."); + EstApp.change(() => (state.project.options.EscDesignTotal = value), false); + return true; + } + if (node.dataset.escAuto !== undefined) { + EstApp.change( + () => + (state.project.tables.EstMainDat[Number(node.dataset.escAuto)].EscAuto = node.checked), + false, + ); + return true; + } + if (node.id === "progress-components" || node.id === "progress-digits") { + const round = currentRound(); + if (round.locked) throw Error("확정 회차입니다."); + const value = node.id === "progress-components" ? node.checked : numeric(node.value); + if (node.id === "progress-digits" && (!Number.isInteger(value) || value < 0 || value > 4)) + throw Error("금액 소수자리는 0~4입니다."); + EstApp.change( + () => + (round[node.id === "progress-components" ? "separateComponents" : "amountDigits"] = + value), + false, + ); + return true; + } + return false; + } + async function handle(name) { + const { state } = EstApp, + project = state.project, + round = currentRound(); + if (name === "unlock-round") { + if (confirm("이 회차와 이후 회차의 확정을 해제하시겠습니까? 입력값은 유지됩니다.")) + EstApp.change(() => (state.project = EstProgress.unlock(project, round.id)), false); + } else if (name === "delete-round") { + if (round.locked) throw Error("확정을 먼저 해제하세요."); + if (project.progress.at(-1) !== round) throw Error("마지막 회차부터 삭제하세요."); + if (confirm("선택한 기성 회차를 삭제하시겠습니까?")) + EstApp.change(() => { + project.progress.pop(); + state.roundId = project.progress.at(-1)?.id; + }, false); + } else if (name === "rename-round") { + if (round.locked) throw Error("확정을 먼저 해제하세요."); + EstActions.form( + "기성 회차 이름", + [{ name: "name", label: "회차 이름", value: round.name }], + (values) => { + if ( + !values.name.trim() || + project.progress.some((item) => item !== round && item.name === values.name) + ) + throw Error("회차 이름은 비어 있거나 중복될 수 없습니다."); + EstApp.change(() => (round.name = values.name.trim()), false); + }, + ); + } else if (name === "fill-progress") { + if (round.locked) throw Error("확정 회차입니다."); + EstActions.form( + "기성 일괄 입력", + [ + { + name: "prefix", + label: "대상", + value: "Gum", + choices: [ + ["Gum", "금회기성"], + ["Sa", "금회사정"], + ], + }, + { + name: "mode", + label: "방법", + value: "ratio", + choices: [ + ["ratio", "비율 입력"], + ["full", "전량"], + ], + }, + { name: "ratio", label: "비율 %", type: "number", value: 100 }, + ], + (values) => + EstApp.change(() => { + const value = numeric(values.ratio); + project.tables.EstMainDat.forEach((row, index) => { + if ( + Number(row.Level) > 0 || + Number(row.CalcIndex) >= 2 || + [54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)) + ) + return; + if (values.mode === "full" && Number(row.LinkIndex) === 57) return; + const key = EstBusiness.rowKey(row, index); + round.inputs[key] ||= {}; + round.inputs[key][values.prefix + "Mode"] = + values.mode === "full" ? "quantity" : "ratio"; + round.inputs[key][values.prefix] = + values.mode === "full" ? Number(row.GN) || 0 : value; + }); + }, false), + ); + } else if (name === "load-progress-exclusion") { + const locked = project.progress.filter((round) => round.locked && round.result); + if (!locked.length) throw Error("확정한 기성 회차가 없습니다."); + EstActions.form( + "기성 누계수량 불러오기", + [ + { + name: "id", + label: "기성 회차", + value: locked.at(-1).id, + choices: locked.map((round) => [round.id, round.name]), + }, + ], + (values) => + EstApp.change(() => { + const result = locked.find((round) => round.id === values.id).result; + const rows = new Map( + result.rows.map((row, index) => [EstBusiness.rowKey(row, index), row]), + ); + project.tables.EstMainDat.forEach( + (row, index) => + (row.EscProgress = Number(rows.get(EstBusiness.rowKey(row, index))?.TotalGN) || 0), + ); + }, false), + ); + } else if (name === "load-contract" || name === "load-bid") { + purpose = name; + documentKey = state.documentKey; + document.getElementById("business-file").click(); + } + } + function install() { + const input = document.createElement("input"); + input.type = "file"; + input.id = "business-file"; + input.accept = ".json,.estx"; + input.hidden = true; + document.body.append(input); + input.addEventListener("change", async () => { + try { + const file = input.files[0]; + if (!file) return; + const raw = /\.estx$/i.test(file.name) + ? await EstApp.api("/api/import?name=" + encodeURIComponent(file.name), { + method: "POST", + body: await file.arrayBuffer(), + }) + : JSON.parse(await file.text()); + const incoming = EstModel.legacy(raw); + if (documentKey !== EstApp.state.documentKey) + throw Error("문서가 변경되어 불러오기를 취소했습니다."); + EstApp.change( + () => + (EstApp.state.project = + purpose === "load-contract" + ? EstBusiness.importContract(EstApp.state.project, incoming) + : EstBusiness.importBid(EstApp.state.project, incoming)), + false, + ); + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } finally { + input.value = ""; + } + }); + } + return { handle, change, install }; +})(); diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index d56146b69..f8cd7b612 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -2,7 +2,7 @@ const EstBusiness = (() => { const { num, components, units, floating, sum } = EstMath; const copy = (value) => structuredClone(value); - const rowKey = (row, index) => String(row.PkID ?? row.Code ?? index); + const rowKey = (row, index) => String(row.PkID ?? (row.Code || `row:${index}`)); const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex)); function baseline(project) { const working = { ...project }; @@ -54,26 +54,56 @@ const EstBusiness = (() => { } function changes(project) { const old = project.baseline?.tables.EstMainDat || []; - return project.tables.EstMainDat.map((row, index) => { + const seen = new Set(); + const changes = project.tables.EstMainDat.map((row, index) => { + seen.add(rowKey(row, index)); const saved = old.find((r, i) => rowKey(r, i) === rowKey(row, index)); const stored = !project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null) - ? { GN: row.GN_, TC: row.TC_ } + ? Object.fromEntries( + Object.entries(row) + .filter(([key]) => key.endsWith("_")) + .map(([key, value]) => [key.slice(0, -1), value]), + ) : null; const original = saved || stored; return { key: rowKey(row, index), - Code: row.Code, - Name: row.Name, + Code: row.Code || "", + Name: row.Name || "", GN: num(row.GN), originalGN: num(original?.GN), TC: num(row.TC), originalTC: num(original?.TC), difference: num(row.TC) - num(original?.TC), NewItem: !original, + status: !original + ? "new" + : ["Code", "Name", "Size", "Unite", "GN", "TC", ...units, ...components].some( + (key) => original[key] != null && original[key] !== row[key], + ) + ? "changed" + : "unchanged", originalSource: saved ? "baseline" : stored ? "file" : null, }; }); + old.forEach((row, index) => { + if (seen.has(rowKey(row, index))) return; + changes.push({ + key: rowKey(row, index), + Code: row.Code || "", + Name: row.Name || "", + originalGN: num(row.GN), + originalTC: num(row.TC), + GN: 0, + TC: 0, + difference: -num(row.TC), + NewItem: false, + status: "deleted", + originalSource: "baseline", + }); + }); + return changes; } function bid(project, rates) { const next = copy(project); @@ -95,6 +125,55 @@ const EstBusiness = (() => { next.options.SourceRefresh = false; return EstNative.calculate(next); } + function aligned(project, incoming) { + const a = project.tables.EstMainDat, + b = incoming.tables.EstMainDat; + // 10b5934 moves the source table to the current record number, rather than seeking a code. + if ( + a.length !== b.length || + a.some( + (row, index) => + String(row.Code || "") !== String(b[index].Code || "") || + num(row.Level) !== num(b[index].Level), + ) + ) + throw Error( + "불러올 내역의 행 순서·코드·레벨이 다릅니다. 같은 구조의 계약/입찰 내역을 선택하세요.", + ); + return incoming.options?.AutoCalc === false ? incoming : EstNative.calculate(incoming); + } + function importContract(project, incoming) { + const source = aligned(project, incoming), + next = copy(project), + snapshot = baseline(source).baseline; + snapshot.tables.EstMainDat.forEach((row, index) => { + const current = next.tables.EstMainDat[index]; + if (current.PkID != null) row.PkID = current.PkID; + else delete row.PkID; + for (const field of [ + "Code", + "Name", + "Size", + "Unite", + "GN", + "TU", + "TC", + ...units, + ...components, + ]) + if (row[field] != null) current[field + "_"] = copy(row[field]); + }); + next.baseline = snapshot; + return next; + } + function importBid(project, incoming) { + const source = aligned(project, incoming), + next = copy(project); + next.tables.EstMainDat.forEach((row, index) => + units.forEach((unit) => (row["A" + unit] = num(source.tables.EstMainDat[index][unit]))), + ); + return next; + } function original(project, method = 0) { if (!project.baseline) throw Error("당초 내역을 먼저 확정하세요."); const old = { @@ -131,244 +210,27 @@ const EstBusiness = (() => { if (calculated.calculation.errors.length) throw Error(calculated.calculation.errors.join("\n")); return { name: old.name, tables: calculated.tables }; } - function inputValue(row, input, prefix) { - const mode = input[`${prefix}Mode`] || "quantity", - value = num(input[prefix]); - if (mode === "amount") return num(row.TC) ? (value / num(row.TC)) * num(row.GN) : 0; - if (mode === "ratio") - return floating((value / 100) * num(row.GN), input.QuantityDigits ?? 4, 1); - return value; - } function progress(project, roundId) { - const round = project.progress.find((r) => r.id === roundId); - if (!round) throw Error("기성 회차 없음"); - const position = project.progress.indexOf(round), - rows = copy(project.tables.EstMainDat), - errors = []; - const previousRounds = project.progress.slice(0, position); - if (previousRounds.some((r) => !r.locked)) errors.push("전회 기성 회차를 먼저 확정하세요."); - const previous = previousRounds.at(-1)?.result?.rows || []; - const segment = { Gum: [], Sa: [] }; - rows.forEach((row, index) => { - const key = rowKey(row, index), - input = round.inputs?.[key] || {}; - const old = previous.find((r, i) => rowKey(r, i) === key); - row.JunGN = num(old?.TotalGN); - row.JunTC = num(old?.TotalTC); - row.JunRatio = num(old?.TotalRatio); - for (const k of components) row[`Jun${k}`] = num(old?.[`Total${k}`]); - if (num(row.Level) > 0) { - segment.Gum = []; - segment.Sa = []; - return; - } - for (const prefix of ["Gum", "Sa"]) { - const quantity = inputValue(row, input, prefix), - factor = special(row) ? quantity / 100 : quantity; - row[`${prefix}GN`] = quantity; - components.forEach( - (k, i) => - (row[`${prefix}${k}`] = EstMath.fixed( - num(row[units[i]]) * factor, - input.AmountDigits || 0, - )), - ); - if ([62, 67].includes(num(row.LinkIndex))) { - const percent = { ...row, GN: num(row.GN), CalcIndex: 0 }; - const converted = segment[prefix].map((r) => - Object.fromEntries( - [...components, "TC"] - .map((k) => [k, num(r[`${prefix}${k}`])]) - .concat([["SKind", r.SKind]]), - ), - ); - EstRows.percent(percent, converted); - components.forEach((k) => (row[`${prefix}${k}`] = num(percent[k]))); - } - row[`${prefix}TC`] = components.reduce((n, k) => n + num(row[`${prefix}${k}`]), 0); - row[`${prefix}Ratio`] = num(row.TC) - ? floating((row[`${prefix}TC`] / num(row.TC)) * 100, 2, 1) - : 0; - segment[prefix].push(row); - } - const accepted = round.approved ? "Sa" : "Gum"; - for (const k of ["GN", "TC", "Ratio", ...components]) - row[`Total${k}`] = num(row[`Jun${k}`]) + num(row[`${accepted}${k}`]); - }); - for (let i = rows.length - 1; i >= 0; i--) { - const row = rows[i]; - if (num(row.Level) <= 0) continue; - const children = []; - for ( - let j = i + 1; - j < rows.length && - num(rows[j].Level) < num(row.Level) && - ![54, 59].includes(num(rows[j].LinkIndex)); - j++ - ) - if (num(rows[j].Level) === 0 && num(rows[j].CalcIndex) < 2) children.push(rows[j]); - for (const prefix of ["Jun", "Gum", "Sa", "Total"]) - for (const k of ["TC", ...components]) - row[`${prefix}${k}`] = children.reduce((n, r) => n + num(r[`${prefix}${k}`]), 0); - } - const boundary = rows.findIndex((r) => num(r.LinkIndex) === 54); - const direct = rows - .slice(0, boundary < 0 ? rows.length : boundary) - .filter((r) => !num(r.Level) && num(r.CalcIndex) < 2); - const accepted = round.approved ? "Sa" : "Gum"; - const current = direct.reduce((n, r) => n + num(r[`${accepted}TC`]), 0); - const contract = direct.reduce((n, r) => n + num(r.TC), 0), - ratio = contract ? current / contract : 0; - function costsFor(prefix) { - const part = direct.reduce((n, r) => n + num(r[`${prefix}TC`]), 0); - const proportion = contract ? part / contract : 0; - const bases = Object.fromEntries( - [...components, "TC"].map((key) => [ - key, - direct.reduce((n, r) => n + num(r[`${prefix}${key}`]), 0), - ]), - ); - const material = EstNative.materialBase(rows, project.options, `${prefix}GC`); - const wonga = copy(project.tables.Wonga); - for (const row of wonga) { - if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) { - row.Cost = Math.trunc(num(row.Cost) * proportion); - row.VSusic = ""; - } else if (!row.VSusic && !row.BaseField && ![1, 3].includes(num(row.CalcIndex))) - row.Cost = Math.trunc(num(row.Cost) * proportion); - if (row.BaseField) { - row.Cost = num(bases[row.BaseField]); - row.VSusic = ""; - } else if (!row.VSusic && !row.Youl && ![1, 3].includes(num(row.CalcIndex))) { - const name = EstNative.normalize(row.Name); - if (name.includes("직접재료")) row.Cost = material; - else if (name.includes("간접재료")) row.Cost = bases.GC - material; - else if (name.includes("직접노무")) row.Cost = bases.NC; - else if (name.includes("산출경비")) row.Cost = bases.JC; - } - } - const costs = EstWonga.calculate(wonga, { ...project.options, TotalFix: false }, bases); - errors.push(...costs.errors); - const byName = new Map( - costs.rows - .filter((r) => num(r.CalcIndex) < 2 && r.Gubun) - .map((r) => [EstNative.normalize(r.Name), r]), - ); - for (const row of rows) { - if (num(row.LinkIndex) === 54) { - for (const key of [...components, "TC"]) row[`${prefix}${key}`] = bases[key]; - } else if ( - [55, 58].includes(num(row.LinkIndex)) && - byName.has(EstNative.normalize(row.Name)) - ) - row[`${prefix}TC`] = num(byName.get(EstNative.normalize(row.Name)).Cost); - } - return costs; - } - const gumCosts = costsFor("Gum"), - saCosts = costsFor("Sa"), - costs = round.approved ? saCosts : gumCosts; - for (const row of rows) - if ([54, 55, 58].includes(num(row.LinkIndex))) { - for (const key of [...components, "TC"]) - row[`Total${key}`] = num(row[`Jun${key}`]) + num(row[`${accepted}${key}`]); - } - return { - rows, - wonga: costs.rows, - total: costs.total || current, - ratio: floating(ratio * 100, 2, 1), - errors, - }; + return EstProgress.calculate(project, roundId); } function escalation(project) { - const rows = copy(project.tables.EstMainDat), - errors = []; - const contractRows = project.baseline?.tables.EstMainDat || rows; - for (let index = 0; index < rows.length; index++) { - const row = rows[index], - old = contractRows.find((r, i) => rowKey(r, i) === rowKey(row, index)) || row; - if (num(row.Level) > 0 || [1, 3].includes(num(row.CalcIndex))) continue; - let total = 0; - units.forEach((unit, i) => { - const A = num(row[`A${unit}`]), - C = num(old[unit]), - B = num(row[unit]); - const D = A && B ? floating(((B - A) / A) * 100, 2) : 0; - let E = 0; - if (A && B && A >= C && B >= A) E = Math.trunc((C * D) / 100); - else if (A && B && A < C && B >= C) E = Math.trunc(B - C); - row[`D${unit}`] = D; - row[`E${unit}`] = E; - row[`F${unit}`] = C + E; - row[`E${components[i]}`] = Math.trunc(E * num(row.GN)); - row[components[i]] = Math.trunc((C + E) * num(row.GN)); - total += row[components[i]]; - }); - row.TC = total; - const excluded = Math.max(num(row.EscPlanned), num(row.EscActual), num(row.EscProgress)); - row.EscExcluded = excluded; - row.EscApplied = num(old.GN) - excluded; - } - // 내역 재집계는 조정 단가를 사용한다. - for (const row of rows) - units.forEach((unit) => { - if (Object.hasOwn(row, `F${unit}`)) row[unit] = row[`F${unit}`]; - }); - const adjusted = EstRows.estimate(rows, { ...project.options, SourceRefresh: false }); - function costCopy(estimateRows) { - const next = copy(project); - next.tables.EstMainDat = estimateRows; - next.options = { ...next.options, SourceRefresh: false, TotalFix: false }; - return EstNative.calculate(next); - } - const adjustedProject = costCopy(adjusted.rows); - const contractProject = costCopy(copy(contractRows)); - const excludedRows = copy(contractRows); - excludedRows.forEach((row, index) => { - if (num(row.Level) > 0 || [54, 55, 57, 58, 59, 62, 67].includes(num(row.LinkIndex))) return; - const current = rows.find((r, i) => rowKey(r, i) === rowKey(row, index)); - row.GN = num(current?.EscExcluded); - }); - const excludedProject = costCopy(excludedRows); - const contractTotal = num(contractProject.calculation.total); - const applied = contractTotal - num(excludedProject.calculation.total); - const rate = contractTotal - ? floating( - ((num(adjustedProject.calculation.total) - contractTotal) / contractTotal) * 100, - 2, - ) - : 0; - return { - rows: adjusted.rows, - direct: adjusted.direct, - wonga: adjustedProject.tables.Wonga, - contractTotal, - adjustedTotal: num(adjustedProject.calculation.total), - excludedTotal: num(excludedProject.calculation.total), - applied, - rate, - adjustment: Math.trunc((applied * rate) / 100), - errors: [ - ...new Set([ - ...errors, - ...adjusted.errors, - ...adjustedProject.calculation.errors, - ...contractProject.calculation.errors, - ...excludedProject.calculation.errors, - ]), - ], - }; + return EstEscalation.calculate(project); } - function annual(project) { + function annual(project, full = true) { const errors = [], results = []; - const names = (project.annual || []).map((year) => String(year.name || "")); + const names = (project.annual || []).map((year) => String(year.name || "").trim()); if (names.some((name) => !name.trim()) || new Set(names).size !== names.length) return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] }; for (const year of project.annual || []) { - const next = copy(project); - next.name = `${project.name} · ${year.name}`; + const next = full ? copy(project) : EstNative.empty(); + if (!full) { + next.tables.EstMainDat = copy(project.tables.EstMainDat); + next.tables.Wonga = copy(project.tables.Wonga); + next.options = { ...project.options }; + } + const name = String(year.name).trim(); + next.name = `${project.name} · ${name}`; delete next.id; next.progress = []; next.annual = []; @@ -403,9 +265,37 @@ const EstBusiness = (() => { } const calculated = EstNative.calculate(next); errors.push(...calculated.calculation.errors); - results.push({ name: year.name, project: calculated }); + results.push({ name, project: calculated }); } return { results, errors }; } - return { baseline, changes, bid, original, progress, escalation, annual, rowKey }; + function annualCopy(project, index) { + const result = annual(project, false); + if (result.errors.length) throw Error(result.errors.join("\n")); + const chosen = result.results[index]?.project; + if (!chosen) throw Error("연차 내역이 없습니다."); + const next = copy(project); + delete next.id; + next.name = chosen.name; + next.annual = []; + next.progress = []; + next.options = chosen.options; + next.tables.EstMainDat = chosen.tables.EstMainDat; + next.tables.Wonga = chosen.tables.Wonga; + next.calculation = chosen.calculation; + return next; + } + return { + baseline, + changes, + bid, + original, + progress, + escalation, + annual, + rowKey, + importContract, + importBid, + annualCopy, + }; })(); diff --git a/tools/est-test/native-escalation.js b/tools/est-test/native-escalation.js new file mode 100644 index 000000000..967705058 --- /dev/null +++ b/tools/est-test/native-escalation.js @@ -0,0 +1,113 @@ +// REA 10a7ad4/10a7d7c/10a82cc/10a8fa8, e42260, 10ad5ac. +const EstEscalation = (() => { + const { num, units, components, floating, sum } = EstMath; + const special = (row) => [54, 55, 57, 58, 59, 62, 67].includes(num(row.LinkIndex)); + const total = (p) => num(p.calculation.total) || num(p.calculation.direct.TC); + function calculate(project) { + const key = EstBusiness.rowKey, + errors = [], + rows = structuredClone(project.tables.EstMainDat); + const original = + project.baseline?.tables.EstMainDat || + rows.map((row) => { + const old = { ...row }; + for (const field of ["GN", "TU", "TC", ...units, ...components]) + if (row[field + "_"] != null) old[field] = row[field + "_"]; + return old; + }); + const contracts = new Map(original.map((row, index) => [key(row, index), row])); + const design = structuredClone(original), + excluded = structuredClone(original); + rows.forEach((row, index) => { + const old = contracts.get(key(row, index)) || row; + const auto = row.EscAuto !== false; + const quantity = auto + ? Math.max(num(row.EscPlanned), num(row.EscActual), num(row.EscProgress)) + : num(row.EscManualQuantity ?? row.EscExcluded); + row.EscExcluded = quantity; + row.EscApplied = num(old.GN) - quantity; + if (num(row.Level) > 0 || special(row)) return; + const factor = num(old.GN) / (["%", "%"].includes(row.Unite) ? 100 : 1); + units.forEach((unit, i) => { + const A = num(row["A" + unit]), + B = num(row[unit]), + C = num(old[unit]); + const D = A && B ? floating(((B - A) / A) * 100, 2, 2) : 0; + let E = 0; + if (A && B && A >= C && B >= A) E = Math.trunc((C * D) / 100); + else if (A && B && A < C && B >= C) E = Math.trunc(B - C); + row["D" + unit] = D; + row["E" + unit] = E; + row["F" + unit] = C + E; + row["E" + components[i]] = Math.trunc(E * factor); + if (![1, 3].includes(num(row.CalcIndex))) { + row[unit] = C + E; + row[components[i]] = Math.trunc((C + E) * factor); + } + }); + if (![1, 3].includes(num(row.CalcIndex))) row.TC = sum(row); + }); + const current = new Map(rows.map((row, index) => [key(row, index), row])); + design.forEach((row, index) => { + if (num(row.Level) > 0 || special(row)) return; + const now = current.get(key(row, index)); + if (!now) return; + units.forEach((unit) => { + if (now["A" + unit] != null) row[unit] = num(now["A" + unit]); + }); + }); + excluded.forEach((row, index) => { + if (num(row.Level) > 0 || special(row)) return; + const now = current.get(key(row, index)), + quantity = num(now?.EscExcluded), + oldQuantity = num(row.GN); + if ([1, 3].includes(num(row.CalcIndex))) { + const fraction = oldQuantity ? quantity / oldQuantity : 0; + components.forEach((field) => (row[field] = Math.trunc(num(row[field]) * fraction))); + row.TC = sum(row); + } + row.GN = quantity; + }); + function costCopy(source) { + const copy = EstNative.empty(); + copy.tables.EstMainDat = source; + copy.tables.Wonga = project.baseline?.tables.Wonga || project.tables.Wonga; + copy.options = { ...project.options, SourceRefresh: false, TotalFix: false }; + const calculated = EstNative.calculate(copy); + errors.push(...calculated.calculation.errors); + return calculated; + } + const adjusted = costCopy(rows), + contract = costCopy(structuredClone(original)), + designProject = costCopy(design), + excludedProject = costCopy(excluded); + const originalTotal = total(contract), + adjustedTotal = total(adjusted), + excludedTotal = total(excludedProject); + const target = project.tables.Wonga.find((row) => num(row.LinkIndex) === 55); + const designTotal = + num(project.options.EscDesignTotal) || num(target?.EsDesignCost) || total(designProject); + if (!designTotal) + errors.push("조사금액이 없습니다. 입찰 당시 단가 또는 조사 도급액을 입력하세요."); + const applied = originalTotal - excludedTotal; + const rate = designTotal + ? floating(((adjustedTotal - originalTotal) / designTotal) * 100, 2, 2) + : 0; + return { + rows: adjusted.tables.EstMainDat, + direct: adjusted.calculation.direct, + wonga: adjusted.tables.Wonga, + contractWonga: contract.tables.Wonga, + excludedWonga: excludedProject.tables.Wonga, + designTotal, + contractTotal: originalTotal, + adjustedTotal, + excludedTotal, + applied, + rate, + adjustment: Math.trunc((applied * rate) / 100), + errors: [...new Set(errors)], + }; + } + return { calculate }; +})(); diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 310e0c746..3d0c15b2b 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -57,13 +57,15 @@ const EstExtra = (() => { html = box( "설계변경", button("capture-baseline", "현재 내역을 당초로 확정") + + button("load-contract", "당초·계약 내역 불러오기") + button("recalculate-original", "당초 단가 재계산") + button("bid-rates", "낙찰률 적용"), table( - ["코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"], + ["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"], changes .filter((row) => row.Code) .map((row) => [ + esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]), esc(row.Code), esc(row.Name), number(row.originalGN), @@ -87,7 +89,14 @@ const EstExtra = (() => { if (round) { tools += `` + - button("lock-round", "회차 확정") + + button( + round.locked ? "unlock-round" : "lock-round", + round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장", + ) + + button("fill-progress", "전량·비율 일괄 입력") + + button("rename-round", "회차 이름") + + button("delete-round", "회차 삭제") + + `` + ``; let output; try { @@ -114,26 +123,42 @@ const EstExtra = (() => { "누계금액", ], output.rows - .filter((r) => r.Code && !EstMath.num(r.Level)) - .map((row, index) => { - const originalIndex = project.tables.EstMainDat.findIndex( - (r) => r.PkID === row.PkID && r.Code === row.Code, - ), - key = EstBusiness.rowKey(row, originalIndex), + .map((row, index) => ({ row, index })) + .filter( + ({ row }) => + !EstMath.num(row.Level) && + (row.Code || + row.Name || + [54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))), + ) + .map(({ row, index }) => { + const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)); + const key = EstBusiness.rowKey(row, index), input = round.inputs?.[key] || {}; const edit = (prefix) => - ``; + `` + + (input[prefix + "Mode"] === "components" + ? EstMath.components + .map( + (field, i) => + ``, + ) + .join("") + : ""); const method = (prefix) => - ``; + !editable + ? "자동계산" + : ``; return [ esc(row.Code), esc(row.Name), @@ -151,6 +176,17 @@ const EstExtra = (() => { }), ) + `${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%
`; + body += table( + ["원가 비목", "도급금액", "전회", "금회", "사정", "누계"], + (output.costs || []).map((row) => [ + esc(row.Name || row.Gubun), + number(row.ContractCost), + number(row.JunCost), + number(row.GumCost), + number(row.SaCost), + number(row.TotalCost), + ]), + ); } html = box( "기성고", @@ -169,19 +205,24 @@ const EstExtra = (() => { ) .join(""); const rows = project.tables.EstMainDat.map((row, index) => ({ row, index })) - .filter(({ row }) => row.Code && !EstMath.num(row.Level)) + .filter( + ({ row }) => + (row.Code || row.Name) && + !EstMath.num(row.Level) && + ![54, 55, 58, 59].includes(Number(row.LinkIndex)), + ) .map(({ row, index }) => { const old = project.baseline?.tables.EstMainDat.find( - (r) => r.PkID === row.PkID && r.Code === row.Code, + (r, i) => EstBusiness.rowKey(r, i) === EstBusiness.rowKey(row, index), ) || row, result = output.rows[index]; return [ esc(row.Code), esc(row.Name), EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]), - number(old[comp]), - number(row[comp]), + number(project.baseline ? old[comp] : (row[comp + "_"] ?? row[comp])), + EstViews.cell("EstMainDat", index, comp, row[comp]), number(result[`D${comp}`]), number(result[`E${comp}`]), number(result[`F${comp}`]), @@ -190,13 +231,22 @@ const EstExtra = (() => { EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0), number(result.EscExcluded), number(result.EscApplied), + ``, + EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0), + EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0), + EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0), + EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0), ]; }); html = box( "물가변동 품목조정", `` + button("capture-bid", "현재 단가를 입찰단가로 기록") + - button("capture-baseline", "계약 내역 확정"), + button("load-bid", "입찰단가 불러오기") + + button("load-contract", "계약 내역 불러오기") + + button("load-progress-exclusion", "기성 누계수량 불러오기") + + button("capture-baseline", "계약 내역 확정") + + ``, table( [ "코드", @@ -212,13 +262,18 @@ const EstExtra = (() => { "기성수량", "적용제외", "적용수량", + "제외 자동", + "수동 제외수량", + "시공중 수량(참고)", + "PS 수량(참고)", + "선시공 수량(참고)", ], rows, ), - `계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. ${esc(output.errors.join(" / "))}`, + `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`, ); } else if (view === "annual") { - const output = EstBusiness.annual(project); + const output = EstBusiness.annual(project, false); state.business = output; html = box( "연차공사", diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js index b1d57a10f..21f6deaa8 100644 --- a/tools/est-test/native-model.js +++ b/tools/est-test/native-model.js @@ -78,8 +78,24 @@ const EstModel = (() => { "Cost", ...Array.from({ length: 6 }, (_, i) => `Cost${i + 1}`), ]); - for (const key of ["ANU", "AGU", "AJU", "ASU", "EscPlanned", "EscActual", "EscProgress"]) + for (const key of [ + "ANU", + "AGU", + "AJU", + "ASU", + "EscPlanned", + "EscActual", + "EscProgress", + "EscManualQuantity", + "EscConstruction", + "EscPS", + "EscBefore", + ]) numeric.add(key); + for (const prefix of ["Jun", "Gum", "Sa", "Total"]) + for (const field of ["GN", "TC", "Ratio", "NC", "GC", "JC", "SC"]) numeric.add(prefix + field); + for (const field of ["ContractCost", "JunCost", "GumCost", "SaCost", "TotalCost"]) + numeric.add(field); const definitions = { EstMainDat: [ "Level", @@ -282,10 +298,95 @@ const EstModel = (() => { } } project.options ||= {}; + if (typeof project.options !== "object" || Array.isArray(project.options)) + throw Error("계산 옵션 형식 오류"); + const validNumber = (value, label) => { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + Math.abs(value) > Number.MAX_SAFE_INTEGER + ) + throw Error(`수치 필드 오류: ${label}`); + }; + if (project.options.EscDesignTotal != null) { + validNumber(project.options.EscDesignTotal, "조사 도급액"); + if (project.options.EscDesignTotal < 0) throw Error("조사 도급액은 0 이상입니다."); + } for (const key of ["quantities", "progress", "annual", "drawings"]) { if (project[key] === undefined) project[key] = []; if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`); } + const ids = new Set(); + for (const round of project.progress) { + if ( + !round || + typeof round !== "object" || + typeof round.id !== "string" || + !round.id || + ids.has(round.id) + ) + throw Error("기성 회차 ID가 없거나 중복됩니다."); + ids.add(round.id); + for (const field of ["locked", "approved", "separateComponents"]) + if (round[field] != null && typeof round[field] !== "boolean") + throw Error("기성 옵션 형식 오류"); + if (round.name != null && typeof round.name !== "string") + throw Error("기성 회차 이름 형식 오류"); + round.inputs ??= {}; + if (typeof round.inputs !== "object" || Array.isArray(round.inputs)) + throw Error("기성 입력 형식 오류"); + round.inputs = Object.assign(Object.create(null), round.inputs); + for (const input of Object.values(round.inputs)) { + if (!input || typeof input !== "object" || Array.isArray(input)) + throw Error("기성 입력 행 형식 오류"); + for (const prefix of ["Gum", "Sa"]) { + if ( + input[prefix + "Mode"] != null && + !["quantity", "amount", "ratio", "components"].includes(input[prefix + "Mode"]) + ) + throw Error("기성 입력방법 오류"); + for (const field of ["", "NC", "GC", "JC", "SC"]) + if (input[prefix + field] != null) + validNumber(input[prefix + field], "기성 " + prefix + field); + } + for (const field of ["QuantityDigits", "AmountDigits"]) + if (input[field] != null) { + validNumber(input[field], field); + if (!Number.isInteger(input[field]) || input[field] < 0 || input[field] > 12) + throw Error("기성 자릿수 형식 오류"); + } + } + if ( + round.amountDigits != null && + (!Number.isInteger(round.amountDigits) || round.amountDigits < 0 || round.amountDigits > 4) + ) + throw Error("기성 금액 자릿수는 0~4입니다."); + if (round.contract) { + validate({ + schema: "est-web-2", + name: "기성 계약", + tables: { EstMainDat: round.contract.rows, Wonga: round.contract.wonga }, + options: round.contract.options, + }); + } + if (round.result?.rows) + validate({ + schema: "est-web-2", + name: "기성 결과", + tables: { EstMainDat: round.result.rows }, + }); + if (round.result) + for (const field of ["total", "ratio"]) + if (round.result[field] != null) validNumber(round.result[field], "확정 기성 " + field); + } + for (const year of project.annual) + if (!year || typeof year.name !== "string" || typeof year.formula !== "string") + throw Error("연차 이름·수식 형식 오류"); + if (project.baseline) { + if (!project.baseline.tables || !Array.isArray(project.baseline.tables.EstMainDat)) + throw Error("당초 내역 형식 오류"); + validate({ schema: "est-web-2", name: "당초", tables: project.baseline.tables }); + } return project; } function legacy(data) { @@ -416,8 +517,16 @@ const EstModel = (() => { if (rows[at].TitleCode === old) rows[at].TitleCode = code; } } - function newRow(table, rows) { - const max = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0)); + function newRow(table, rows, project) { + const historical = [rows, project?.baseline?.tables?.[table] || []]; + if (table === "EstMainDat") + for (const round of project?.progress || []) + historical.push(round.contract?.rows || [], round.result?.rows || []); + const max = historical.reduce( + (maximum, list) => + list.reduce((value, row) => Math.max(value, Number(row.PkID) || 0), maximum), + 0, + ); const row = { PkID: max + 1, Index: 0, diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js new file mode 100644 index 000000000..3b2e6e076 --- /dev/null +++ b/tools/est-test/native-progress.js @@ -0,0 +1,293 @@ +// REA c90bd4/c91100/c914d4/c9261c/c9199c, c98928, ca4814/ca5e98. +const EstProgress = (() => { + const { num, components, units, floating, sum } = EstMath; + const precision = (value) => { + const [mantissa, exponent] = String(value ?? 0) + .toLowerCase() + .split("e"); + return Math.max(0, (mantissa.split(".")[1]?.length || 0) - Number(exponent || 0)); + }; + const ordinary = (row) => num(row.Level) === 0 && ![54, 55, 58, 59].includes(num(row.LinkIndex)); + function distribute(amount, row, digits) { + const parts = components.map((field) => num(row[field])); + const base = sum(row); + const output = parts.map((part) => (base ? EstMath.fixed((amount * part) / base, digits) : 0)); + if (base) { + const index = parts.map(Math.abs).indexOf(Math.max(...parts.map(Math.abs))); + output[index] += amount - output.reduce((a, b) => a + b, 0); + } + return output; + } + function calculate(project, roundId) { + const round = project.progress.find((item) => item.id === roundId); + if (!round) throw Error("기성 회차 없음"); + const source = round.contract || { + rows: project.tables.EstMainDat, + wonga: project.tables.Wonga, + options: project.options, + }; + const rows = structuredClone(source.rows), + options = source.options || project.options; + const errors = [], + position = project.progress.indexOf(round), + key = EstBusiness.rowKey; + const earlier = project.progress.slice(0, position); + if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요."); + const previous = earlier.at(-1)?.result, + oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row])); + const segments = { Gum: [], Sa: [] }; + const separated = round.separateComponents !== false; + rows.forEach((row, index) => { + const id = key(row, index), + input = round.inputs?.[id] || {}, + old = oldRows.get(id); + for (const field of ["GN", "TC", "Ratio", ...components]) + row["Jun" + field] = num(old?.["Total" + field]); + if (num(row.Level) > 0 || [54, 59].includes(num(row.LinkIndex))) { + segments.Gum = []; + segments.Sa = []; + } + for (const prefix of ["Gum", "Sa"]) { + components.forEach((field) => (row[prefix + field] = 0)); + row[prefix + "GN"] = 0; + row[prefix + "TC"] = 0; + row[prefix + "Ratio"] = 0; + if (!ordinary(row)) continue; + const mode = input[prefix + "Mode"] || "quantity", + value = num(input[prefix]), + digits = input.AmountDigits ?? round.amountDigits ?? 0; + if (!["quantity", "amount", "ratio", "components"].includes(mode)) { + errors.push(`${row.Name || id}: 지원하지 않는 기성 입력방법`); + continue; + } + let quantity = value, + amount, + standaloneAmount; + if (mode === "amount") { + amount = value; + quantity = num(row.TC) ? (amount / num(row.TC)) * num(row.GN) : 0; + if (!num(row.TC) && amount) + errors.push(`${row.Name || id}: 도급금액이 0인 항목은 비목 금액으로 입력하세요.`); + } else if (mode === "components") { + components.forEach((field) => (row[prefix + field] = num(input[prefix + field]))); + amount = components.reduce((n, field) => n + row[prefix + field], 0); + quantity = num(row.TC) ? (amount / num(row.TC)) * num(row.GN) : 0; + } else if (mode === "ratio") + quantity = floating( + (num(row.GN) * value) / 100, + input.QuantityDigits ?? precision(row.GN), + 1, + ); + const factor = + quantity / + ([57, 62, 67].includes(num(row.LinkIndex)) || ["%", "%"].includes(row.Unite) ? 100 : 1); + if ([62, 67].includes(num(row.LinkIndex))) { + const selected = segments[prefix].filter( + (item) => + num(item.CalcIndex) < 2 && + (num(row.LinkIndex) !== 62 || + (num(item.SKind) > 0 && + String(row.Code || "") + .toUpperCase() + .includes(String.fromCharCode(64 + num(item.SKind))))), + ); + const base = EstRows.baseKind(row), + target = { 1: "GC", 2: "NC", 3: "JC", 4: "SC" }[num(row.SCheck)] || "JC"; + if (base) + row[prefix + target] = EstMath.even( + (selected.reduce((n, item) => n + num(item[prefix + base]), 0) * num(row.GN)) / 100, + ); + else + components.forEach( + (field) => + (row[prefix + field] = EstMath.even( + (selected.reduce((n, item) => n + num(item[prefix + field]), 0) * num(row.GN)) / + 100, + )), + ); + quantity = num(row.GN); + } else if (mode === "amount") { + distribute(amount, row, digits).forEach( + (value, i) => (row[prefix + components[i]] = value), + ); + if (separated && !sum(row) && amount) + errors.push(`${row.Name || id}: 도급 비목 금액이 없어 비목별 배분을 할 수 없습니다.`); + } else if (mode !== "components") { + const fixed = units.every((field) => !num(row[field])) && num(row.TC) !== 0; + if (fixed) { + const share = mode === "ratio" ? value / 100 : num(row.GN) ? quantity / num(row.GN) : 0; + components.forEach( + (field) => (row[prefix + field] = EstMath.fixed(num(row[field]) * share, digits)), + ); + if (!separated) standaloneAmount = EstMath.fixed(num(row.TC) * share, digits); + } else + components.forEach( + (field, i) => + (row[prefix + field] = EstMath.fixed(num(row[units[i]]) * factor, digits)), + ); + } + row[prefix + "GN"] = quantity; + row[prefix + "TC"] = + !separated && (mode === "amount" || standaloneAmount !== undefined) + ? mode === "amount" + ? amount + : standaloneAmount + : components.reduce((n, field) => n + num(row[prefix + field]), 0); + row[prefix + "Ratio"] = num(row.TC) + ? floating((row[prefix + "TC"] / num(row.TC)) * 100, 2, 1) + : 0; + if (num(row.CalcIndex) < 2) segments[prefix].push(row); + } + }); + const boundary = rows.findIndex((row) => num(row.LinkIndex) === 54); + const direct = rows + .slice(0, boundary < 0 ? rows.length : boundary) + .filter((row) => ordinary(row) && num(row.CalcIndex) < 2); + const contract = direct.reduce((n, row) => n + num(row.TC), 0); + function costsFor(prefix) { + const current = direct.reduce((n, row) => n + num(row[prefix + "TC"]), 0), + ratio = contract ? current / contract : 0; + const working = EstNative.empty(); + working.options = { ...options, SourceRefresh: false, TotalFix: false }; + working.tables.EstMainDat = rows.map((row) => { + const copy = { ...row, CalcIndex: num(row.CalcIndex) >= 2 ? 3 : 1 }; + components.forEach((field) => (copy[field] = num(row[prefix + field]))); + copy.TC = num(row[prefix + "TC"]); + return copy; + }); + working.tables.Wonga = structuredClone(source.wonga); + for (const row of working.tables.Wonga) { + if (!separated && row.BaseField === "TC") { + row.Cost = current; + row.BaseField = ""; + row.CalcIndex = 1; + } else if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) { + row.Cost = Math.trunc(num(row.Cost) * ratio); + row.VSusic = ""; + row.CalcIndex = 1; + } else if ( + !row.VSusic && + !row.BaseField && + ![1, 3].includes(num(row.CalcIndex)) && + !/관급/.test(String(row.Name)) + ) + row.Cost = Math.trunc(num(row.Cost) * ratio); + } + const calculated = EstNative.calculate(working); + if (!separated) calculated.calculation.direct.TC = current; + errors.push(...calculated.calculation.errors); + const names = new Map( + calculated.tables.Wonga.filter((row) => num(row.CalcIndex) < 2).map((row) => [ + EstNative.normalize(row.Name), + row, + ]), + ); + for (const row of rows) { + if (num(row.LinkIndex) === 54) { + for (const field of [...components, "TC"]) + row[prefix + field] = num(calculated.calculation.direct[field]); + } else if ( + [55, 58].includes(num(row.LinkIndex)) && + names.has(EstNative.normalize(row.Name)) + ) { + row[prefix + "TC"] = num(names.get(EstNative.normalize(row.Name)).Cost); + } + row[prefix + "Ratio"] = num(row.TC) + ? floating((num(row[prefix + "TC"]) / num(row.TC)) * 100, 2, 1) + : 0; + } + return { rows: calculated.tables.Wonga, total: num(calculated.calculation.total) || current }; + } + const gum = costsFor("Gum"), + sa = costsFor("Sa"), + accepted = round.approved ? "Sa" : "Gum"; + for (const row of rows) + for (const field of ["GN", "TC", "Ratio", ...components]) + row["Total" + field] = num(row["Jun" + field]) + num(row[accepted + field]); + for (let i = rows.length - 1; i >= 0; i--) { + const row = rows[i]; + if (num(row.Level) <= 0) continue; + const children = []; + for ( + let j = i + 1; + j < rows.length && + num(rows[j].Level) < num(row.Level) && + ![54, 59].includes(num(rows[j].LinkIndex)); + j++ + ) + if (num(rows[j].Level) === 0 && num(rows[j].CalcIndex) < 2) children.push(rows[j]); + for (const prefix of ["Jun", "Gum", "Sa", "Total"]) + for (const field of [...components, "TC"]) + row[prefix + field] = children.reduce((n, child) => n + num(child[prefix + field]), 0); + } + const acceptedCost = round.approved ? sa : gum; + const priorCosts = new Map( + (previous?.costs || previous?.wonga || []).map((row) => [row.Gubun ?? row.Name, row]), + ); + const costs = acceptedCost.rows.map((row, index) => { + const previous = priorCosts.get(row.Gubun ?? row.Name); + const jun = num(previous?.TotalCost ?? previous?.Cost); + return { + ...row, + ContractCost: num(source.wonga[index]?.Cost), + JunCost: jun, + GumCost: num(gum.rows[index]?.Cost), + SaCost: num(sa.rows[index]?.Cost), + TotalCost: jun + num(row.Cost), + }; + }); + return { + rows, + wonga: acceptedCost.rows, + costs, + gumWonga: gum.rows, + saWonga: sa.rows, + total: acceptedCost.total, + ratio: contract + ? floating( + (direct.reduce((n, row) => n + num(row[accepted + "TC"]), 0) / contract) * 100, + 2, + 1, + ) + : 0, + errors: [...new Set(errors)], + }; + } + function lock(project, id) { + const next = structuredClone(project), + round = next.progress.find((item) => item.id === id); + if (!round) throw Error("기성 회차 없음"); + if (round.locked) throw Error("이미 확정한 회차입니다."); + round.contract = { + rows: structuredClone(project.tables.EstMainDat), + wonga: structuredClone(project.tables.Wonga), + options: { ...project.options }, + }; + const result = calculate(next, id); + if (result.errors.length) throw Error(result.errors.join("\n")); + round.result = result; + round.locked = true; + return next; + } + function unlock(project, id) { + const next = structuredClone(project), + at = next.progress.findIndex((item) => item.id === id); + if (at < 0) throw Error("기성 회차 없음"); + for (const round of next.progress.slice(at)) { + round.locked = false; + delete round.result; + delete round.contract; + } + return next; + } + function rebuild(project) { + for (const round of project.progress || []) + if (round.locked && round.contract) { + const result = calculate(project, round.id); + if (result.errors.length) throw Error(result.errors.join("\n")); + round.result = result; + } + return project; + } + return { calculate, lock, unlock, rebuild }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index fa728e607..ce0173a90 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -17,6 +17,8 @@ const scripts = [ "native-quantity.cjs", "native-wonga.js", "native-engine.js", + "native-escalation.js", + "native-progress.js", "native-business.js", "native-resources.js", "native-model.js", @@ -29,7 +31,7 @@ function calculate(project) { engine.input = project; try { return vm.runInContext( - "input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input))", + "EstProgress.rebuild(input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input)))", engine, { timeout: 20000, @@ -45,6 +47,7 @@ const allowed = new Set([ "native-app.js", "native-views.js", "native-actions.js", + "native-business-actions.js", "native-extra.js", "native-settings.js", "native-report.js",