feat(est): complete four business workflows
This commit is contained in:
@@ -12,6 +12,8 @@ for (const file of [
|
|||||||
"native-quantity.cjs",
|
"native-quantity.cjs",
|
||||||
"native-wonga.js",
|
"native-wonga.js",
|
||||||
"native-engine.js",
|
"native-engine.js",
|
||||||
|
"native-escalation.js",
|
||||||
|
"native-progress.js",
|
||||||
"native-business.js",
|
"native-business.js",
|
||||||
"native-resources.js",
|
"native-resources.js",
|
||||||
"native-model.js",
|
"native-model.js",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -223,14 +223,15 @@ def test_progress_accepted_rounds_annual_allocation_and_resources():
|
|||||||
result = run("business")
|
result = run("business")
|
||||||
first, second = result["first"], result["second"]
|
first, second = result["first"], result["second"]
|
||||||
assert first["errors"] == second["errors"] == []
|
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["rows"][0]["TotalGN"] == 1.5
|
||||||
assert first["total"] == 330
|
assert first["total"] == 330
|
||||||
assert second["rows"][0]["JunGN"] == 1.5
|
assert second["rows"][0]["JunGN"] == 1.5
|
||||||
assert second["rows"][0]["GumGN"] == 2.5
|
assert second["rows"][0]["GumGN"] == 2.5
|
||||||
assert second["rows"][0]["TotalGN"] == 4
|
assert second["rows"][0]["TotalGN"] == 4
|
||||||
assert second["rows"][0]["TotalTC"] == 800
|
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 first["rows"][-1]["SaTC"] == first["rows"][-1]["TotalTC"] == 330
|
||||||
assert second["rows"][-1]["JunTC"] == 330
|
assert second["rows"][-1]["JunTC"] == 330
|
||||||
assert second["rows"][-1]["TotalTC"] == 880
|
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["adjustedTotal"] == 2640
|
||||||
assert escalation["excludedTotal"] == 660
|
assert escalation["excludedTotal"] == 660
|
||||||
assert escalation["applied"] == 1540
|
assert escalation["applied"] == 1540
|
||||||
assert escalation["rate"] == 20
|
# REA 10a8fa8: investigation amount 1,980 is the denominator, not contract 2,200.
|
||||||
assert escalation["adjustment"] == 308
|
assert escalation["designTotal"] == 1980
|
||||||
|
assert escalation["rate"] == 22.22
|
||||||
|
assert escalation["adjustment"] == 342
|
||||||
assert [
|
assert [
|
||||||
next(row["TC"] for row in item["tables"]["EstMainDat"] if row.get("LinkIndex") == 54)
|
next(row["TC"] for row in item["tables"]["EstMainDat"] if row.get("LinkIndex") == 54)
|
||||||
for item in result["originals"]
|
for item in result["originals"]
|
||||||
|
|||||||
@@ -46,12 +46,18 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
|||||||
|
|
||||||
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력·연차 전용 시험 74개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
|
이 보완 후 해양 자료는 소계 표지까지 포함해 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
|
```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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
|
`native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<script defer src="native-quantity.cjs"></script>
|
<script defer src="native-quantity.cjs"></script>
|
||||||
<script defer src="native-wonga.js"></script>
|
<script defer src="native-wonga.js"></script>
|
||||||
<script defer src="native-engine.js"></script>
|
<script defer src="native-engine.js"></script>
|
||||||
|
<script defer src="native-escalation.js"></script>
|
||||||
|
<script defer src="native-progress.js"></script>
|
||||||
<script defer src="native-business.js"></script>
|
<script defer src="native-business.js"></script>
|
||||||
<script defer src="native-resources.js"></script>
|
<script defer src="native-resources.js"></script>
|
||||||
<script defer src="native-model.js"></script>
|
<script defer src="native-model.js"></script>
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
<script defer src="native-settings.js"></script>
|
<script defer src="native-settings.js"></script>
|
||||||
<script defer src="native-report.js"></script>
|
<script defer src="native-report.js"></script>
|
||||||
<script defer src="native-actions.js"></script>
|
<script defer src="native-actions.js"></script>
|
||||||
|
<script defer src="native-business-actions.js"></script>
|
||||||
<script defer src="native-app.js"></script>
|
<script defer src="native-app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const EstActions = (() => {
|
|||||||
const rows = activeRows(),
|
const rows = activeRows(),
|
||||||
{ state } = EstApp;
|
{ state } = EstApp;
|
||||||
EstApp.change(() => {
|
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;
|
if (state.view === "MachMainDat") row.Index = -3;
|
||||||
const group = EstNative.groups(rows).get(state.group);
|
const group = EstNative.groups(rows).get(state.group);
|
||||||
const at =
|
const at =
|
||||||
@@ -84,7 +84,7 @@ const EstActions = (() => {
|
|||||||
EstApp.change(() => {
|
EstApp.change(() => {
|
||||||
const index = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)) + 1;
|
const index = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)) + 1;
|
||||||
const row = {
|
const row = {
|
||||||
...EstModel.newRow(state.view, rows),
|
...EstModel.newRow(state.view, rows, state.project),
|
||||||
Index: index,
|
Index: index,
|
||||||
Code: values.code,
|
Code: values.code,
|
||||||
Name: values.name,
|
Name: values.name,
|
||||||
@@ -172,7 +172,7 @@ const EstActions = (() => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const copies = structuredClone(clipboard);
|
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 = "",
|
let groupCode = "",
|
||||||
nextIndex = Math.max(0, ...rows.map((r) => Number(r.Index) || 0));
|
nextIndex = Math.max(0, ...rows.map((r) => Number(r.Index) || 0));
|
||||||
copies.forEach((r) => {
|
copies.forEach((r) => {
|
||||||
@@ -399,24 +399,21 @@ const EstActions = (() => {
|
|||||||
state.roundId = next.id;
|
state.roundId = next.id;
|
||||||
}, false);
|
}, false);
|
||||||
} else if (name === "lock-round") {
|
} else if (name === "lock-round") {
|
||||||
const round = project.progress.find((r) => r.id === state.roundId),
|
if (
|
||||||
result = EstBusiness.progress(project, round.id);
|
confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.")
|
||||||
if (result.errors.length) throw Error(result.errors.join("\n"));
|
) {
|
||||||
if (confirm("이 회차를 확정하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다."))
|
EstApp.change(() => (state.project = EstProgress.lock(project, state.roundId)), false);
|
||||||
EstApp.change(() => {
|
await EstApp.save();
|
||||||
round.result = result;
|
}
|
||||||
round.locked = true;
|
|
||||||
}, false);
|
|
||||||
} else if (name === "add-annual")
|
} else if (name === "add-annual")
|
||||||
EstApp.change(
|
EstApp.change(
|
||||||
() => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }),
|
() => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
else if (name === "open-annual") {
|
else if (name === "open-annual") {
|
||||||
const result = EstBusiness.annual(project);
|
const result = EstBusiness.annualCopy(project, Number(node.dataset.row));
|
||||||
if (result.errors.length) throw Error(result.errors.join("\n"));
|
|
||||||
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) {
|
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) {
|
||||||
EstApp.setProject(result.results[Number(node.dataset.row)].project);
|
EstApp.setProject(result);
|
||||||
EstApp.navigate("EstMainDat");
|
EstApp.navigate("EstMainDat");
|
||||||
}
|
}
|
||||||
} else if (name === "delete-extra") {
|
} else if (name === "delete-extra") {
|
||||||
@@ -430,8 +427,10 @@ const EstActions = (() => {
|
|||||||
pending = null;
|
pending = null;
|
||||||
EstApp.byId("dialog").close();
|
EstApp.byId("dialog").close();
|
||||||
} else if (name === "close-dialog") EstApp.byId("dialog").close();
|
} else if (name === "close-dialog") EstApp.byId("dialog").close();
|
||||||
|
else await EstBusinessActions.handle(name, node);
|
||||||
}
|
}
|
||||||
function install() {
|
function install() {
|
||||||
|
EstBusinessActions.install();
|
||||||
document.addEventListener("click", async (event) => {
|
document.addEventListener("click", async (event) => {
|
||||||
try {
|
try {
|
||||||
const marker = event.target.closest("[data-marker]");
|
const marker = event.target.closest("[data-marker]");
|
||||||
@@ -470,6 +469,7 @@ const EstActions = (() => {
|
|||||||
project = state.project;
|
project = state.project;
|
||||||
state.notice = "";
|
state.notice = "";
|
||||||
try {
|
try {
|
||||||
|
if (EstBusinessActions.change(node)) return;
|
||||||
if (node.matches("[data-select]")) {
|
if (node.matches("[data-select]")) {
|
||||||
const index = Number(node.dataset.select);
|
const index = Number(node.dataset.select);
|
||||||
node.checked ? state.selection.add(index) : state.selection.delete(index);
|
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);
|
EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value);
|
||||||
if (state.group === previous && Number(row.Index) > 0) state.group = row.Code;
|
if (state.group === previous && Number(row.Index) > 0) state.group = row.Code;
|
||||||
} else row[key] = value;
|
} 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 (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) {
|
||||||
if (key === "TC") {
|
if (key === "TC") {
|
||||||
row.InputTC = value;
|
row.InputTC = value;
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ const EstApp = (() => {
|
|||||||
state.selection.clear();
|
state.selection.clear();
|
||||||
state.group = "";
|
state.group = "";
|
||||||
state.page = 0;
|
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();
|
if (state.project.options.AutoCalc !== false) calculate();
|
||||||
else
|
else
|
||||||
state.project.calculation ||= {
|
state.project.calculation ||= {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
+126
-236
@@ -2,7 +2,7 @@
|
|||||||
const EstBusiness = (() => {
|
const EstBusiness = (() => {
|
||||||
const { num, components, units, floating, sum } = EstMath;
|
const { num, components, units, floating, sum } = EstMath;
|
||||||
const copy = (value) => structuredClone(value);
|
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));
|
const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex));
|
||||||
function baseline(project) {
|
function baseline(project) {
|
||||||
const working = { ...project };
|
const working = { ...project };
|
||||||
@@ -54,26 +54,56 @@ const EstBusiness = (() => {
|
|||||||
}
|
}
|
||||||
function changes(project) {
|
function changes(project) {
|
||||||
const old = project.baseline?.tables.EstMainDat || [];
|
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 saved = old.find((r, i) => rowKey(r, i) === rowKey(row, index));
|
||||||
const stored =
|
const stored =
|
||||||
!project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null)
|
!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;
|
: null;
|
||||||
const original = saved || stored;
|
const original = saved || stored;
|
||||||
return {
|
return {
|
||||||
key: rowKey(row, index),
|
key: rowKey(row, index),
|
||||||
Code: row.Code,
|
Code: row.Code || "",
|
||||||
Name: row.Name,
|
Name: row.Name || "",
|
||||||
GN: num(row.GN),
|
GN: num(row.GN),
|
||||||
originalGN: num(original?.GN),
|
originalGN: num(original?.GN),
|
||||||
TC: num(row.TC),
|
TC: num(row.TC),
|
||||||
originalTC: num(original?.TC),
|
originalTC: num(original?.TC),
|
||||||
difference: num(row.TC) - num(original?.TC),
|
difference: num(row.TC) - num(original?.TC),
|
||||||
NewItem: !original,
|
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,
|
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) {
|
function bid(project, rates) {
|
||||||
const next = copy(project);
|
const next = copy(project);
|
||||||
@@ -95,6 +125,55 @@ const EstBusiness = (() => {
|
|||||||
next.options.SourceRefresh = false;
|
next.options.SourceRefresh = false;
|
||||||
return EstNative.calculate(next);
|
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) {
|
function original(project, method = 0) {
|
||||||
if (!project.baseline) throw Error("당초 내역을 먼저 확정하세요.");
|
if (!project.baseline) throw Error("당초 내역을 먼저 확정하세요.");
|
||||||
const old = {
|
const old = {
|
||||||
@@ -131,244 +210,27 @@ const EstBusiness = (() => {
|
|||||||
if (calculated.calculation.errors.length) throw Error(calculated.calculation.errors.join("\n"));
|
if (calculated.calculation.errors.length) throw Error(calculated.calculation.errors.join("\n"));
|
||||||
return { name: old.name, tables: calculated.tables };
|
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) {
|
function progress(project, roundId) {
|
||||||
const round = project.progress.find((r) => r.id === roundId);
|
return EstProgress.calculate(project, 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
function escalation(project) {
|
function escalation(project) {
|
||||||
const rows = copy(project.tables.EstMainDat),
|
return EstEscalation.calculate(project);
|
||||||
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,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
function annual(project) {
|
function annual(project, full = true) {
|
||||||
const errors = [],
|
const errors = [],
|
||||||
results = [];
|
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)
|
if (names.some((name) => !name.trim()) || new Set(names).size !== names.length)
|
||||||
return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] };
|
return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] };
|
||||||
for (const year of project.annual || []) {
|
for (const year of project.annual || []) {
|
||||||
const next = copy(project);
|
const next = full ? copy(project) : EstNative.empty();
|
||||||
next.name = `${project.name} · ${year.name}`;
|
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;
|
delete next.id;
|
||||||
next.progress = [];
|
next.progress = [];
|
||||||
next.annual = [];
|
next.annual = [];
|
||||||
@@ -403,9 +265,37 @@ const EstBusiness = (() => {
|
|||||||
}
|
}
|
||||||
const calculated = EstNative.calculate(next);
|
const calculated = EstNative.calculate(next);
|
||||||
errors.push(...calculated.calculation.errors);
|
errors.push(...calculated.calculation.errors);
|
||||||
results.push({ name: year.name, project: calculated });
|
results.push({ name, project: calculated });
|
||||||
}
|
}
|
||||||
return { results, errors };
|
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,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -57,13 +57,15 @@ const EstExtra = (() => {
|
|||||||
html = box(
|
html = box(
|
||||||
"설계변경",
|
"설계변경",
|
||||||
button("capture-baseline", "현재 내역을 당초로 확정") +
|
button("capture-baseline", "현재 내역을 당초로 확정") +
|
||||||
|
button("load-contract", "당초·계약 내역 불러오기") +
|
||||||
button("recalculate-original", "당초 단가 재계산") +
|
button("recalculate-original", "당초 단가 재계산") +
|
||||||
button("bid-rates", "낙찰률 적용"),
|
button("bid-rates", "낙찰률 적용"),
|
||||||
table(
|
table(
|
||||||
["코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
|
["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
|
||||||
changes
|
changes
|
||||||
.filter((row) => row.Code)
|
.filter((row) => row.Code)
|
||||||
.map((row) => [
|
.map((row) => [
|
||||||
|
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
|
||||||
esc(row.Code),
|
esc(row.Code),
|
||||||
esc(row.Name),
|
esc(row.Name),
|
||||||
number(row.originalGN),
|
number(row.originalGN),
|
||||||
@@ -87,7 +89,14 @@ const EstExtra = (() => {
|
|||||||
if (round) {
|
if (round) {
|
||||||
tools +=
|
tools +=
|
||||||
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
|
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
|
||||||
button("lock-round", "회차 확정") +
|
button(
|
||||||
|
round.locked ? "unlock-round" : "lock-round",
|
||||||
|
round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장",
|
||||||
|
) +
|
||||||
|
button("fill-progress", "전량·비율 일괄 입력") +
|
||||||
|
button("rename-round", "회차 이름") +
|
||||||
|
button("delete-round", "회차 삭제") +
|
||||||
|
`<label><input id="progress-components" type="checkbox" ${round.separateComponents !== false ? "checked" : ""} ${round.locked ? "disabled" : ""}> 비목별 계산</label><label>금액 소수자리 <input id="progress-digits" type="number" min="0" max="4" value="${round.amountDigits ?? 0}" ${round.locked ? "disabled" : ""}></label>` +
|
||||||
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
|
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
|
||||||
let output;
|
let output;
|
||||||
try {
|
try {
|
||||||
@@ -114,26 +123,42 @@ const EstExtra = (() => {
|
|||||||
"누계금액",
|
"누계금액",
|
||||||
],
|
],
|
||||||
output.rows
|
output.rows
|
||||||
.filter((r) => r.Code && !EstMath.num(r.Level))
|
.map((row, index) => ({ row, index }))
|
||||||
.map((row, index) => {
|
.filter(
|
||||||
const originalIndex = project.tables.EstMainDat.findIndex(
|
({ row }) =>
|
||||||
(r) => r.PkID === row.PkID && r.Code === row.Code,
|
!EstMath.num(row.Level) &&
|
||||||
),
|
(row.Code ||
|
||||||
key = EstBusiness.rowKey(row, originalIndex),
|
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] || {};
|
input = round.inputs?.[key] || {};
|
||||||
const edit = (prefix) =>
|
const edit = (prefix) =>
|
||||||
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? 0}" ${round.locked ? "disabled" : ""}>`;
|
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
|
||||||
|
(input[prefix + "Mode"] === "components"
|
||||||
|
? EstMath.components
|
||||||
|
.map(
|
||||||
|
(field, i) =>
|
||||||
|
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: "");
|
||||||
const method = (prefix) =>
|
const method = (prefix) =>
|
||||||
`<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
|
!editable
|
||||||
["quantity", "수량"],
|
? "자동계산"
|
||||||
["amount", "금액"],
|
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
|
||||||
["ratio", "비율 %"],
|
["quantity", "수량"],
|
||||||
]
|
["amount", "금액"],
|
||||||
.map(
|
["ratio", "비율 %"],
|
||||||
([v, label]) =>
|
["components", "비목 금액"],
|
||||||
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
|
]
|
||||||
)
|
.map(
|
||||||
.join("")}</select>`;
|
([v, label]) =>
|
||||||
|
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
|
||||||
|
)
|
||||||
|
.join("")}</select>`;
|
||||||
return [
|
return [
|
||||||
esc(row.Code),
|
esc(row.Code),
|
||||||
esc(row.Name),
|
esc(row.Name),
|
||||||
@@ -151,6 +176,17 @@ const EstExtra = (() => {
|
|||||||
}),
|
}),
|
||||||
) +
|
) +
|
||||||
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
|
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
|
||||||
|
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(
|
html = box(
|
||||||
"기성고",
|
"기성고",
|
||||||
@@ -169,19 +205,24 @@ const EstExtra = (() => {
|
|||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
const rows = project.tables.EstMainDat.map((row, index) => ({ row, index }))
|
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 }) => {
|
.map(({ row, index }) => {
|
||||||
const old =
|
const old =
|
||||||
project.baseline?.tables.EstMainDat.find(
|
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,
|
) || row,
|
||||||
result = output.rows[index];
|
result = output.rows[index];
|
||||||
return [
|
return [
|
||||||
esc(row.Code),
|
esc(row.Code),
|
||||||
esc(row.Name),
|
esc(row.Name),
|
||||||
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
|
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
|
||||||
number(old[comp]),
|
number(project.baseline ? old[comp] : (row[comp + "_"] ?? row[comp])),
|
||||||
number(row[comp]),
|
EstViews.cell("EstMainDat", index, comp, row[comp]),
|
||||||
number(result[`D${comp}`]),
|
number(result[`D${comp}`]),
|
||||||
number(result[`E${comp}`]),
|
number(result[`E${comp}`]),
|
||||||
number(result[`F${comp}`]),
|
number(result[`F${comp}`]),
|
||||||
@@ -190,13 +231,22 @@ const EstExtra = (() => {
|
|||||||
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
|
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
|
||||||
number(result.EscExcluded),
|
number(result.EscExcluded),
|
||||||
number(result.EscApplied),
|
number(result.EscApplied),
|
||||||
|
`<input type="checkbox" data-esc-auto="${index}" ${row.EscAuto !== false ? "checked" : ""} aria-label="적용제외 자동계산">`,
|
||||||
|
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(
|
html = box(
|
||||||
"물가변동 품목조정",
|
"물가변동 품목조정",
|
||||||
`<select id="escalation-component">${choices}</select>` +
|
`<select id="escalation-component">${choices}</select>` +
|
||||||
button("capture-bid", "현재 단가를 입찰단가로 기록") +
|
button("capture-bid", "현재 단가를 입찰단가로 기록") +
|
||||||
button("capture-baseline", "계약 내역 확정"),
|
button("load-bid", "입찰단가 불러오기") +
|
||||||
|
button("load-contract", "계약 내역 불러오기") +
|
||||||
|
button("load-progress-exclusion", "기성 누계수량 불러오기") +
|
||||||
|
button("capture-baseline", "계약 내역 확정") +
|
||||||
|
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
|
||||||
table(
|
table(
|
||||||
[
|
[
|
||||||
"코드",
|
"코드",
|
||||||
@@ -212,13 +262,18 @@ const EstExtra = (() => {
|
|||||||
"기성수량",
|
"기성수량",
|
||||||
"적용제외",
|
"적용제외",
|
||||||
"적용수량",
|
"적용수량",
|
||||||
|
"제외 자동",
|
||||||
|
"수동 제외수량",
|
||||||
|
"시공중 수량(참고)",
|
||||||
|
"PS 수량(참고)",
|
||||||
|
"선시공 수량(참고)",
|
||||||
],
|
],
|
||||||
rows,
|
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") {
|
} else if (view === "annual") {
|
||||||
const output = EstBusiness.annual(project);
|
const output = EstBusiness.annual(project, false);
|
||||||
state.business = output;
|
state.business = output;
|
||||||
html = box(
|
html = box(
|
||||||
"연차공사",
|
"연차공사",
|
||||||
|
|||||||
@@ -78,8 +78,24 @@ const EstModel = (() => {
|
|||||||
"Cost",
|
"Cost",
|
||||||
...Array.from({ length: 6 }, (_, i) => `Cost${i + 1}`),
|
...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);
|
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 = {
|
const definitions = {
|
||||||
EstMainDat: [
|
EstMainDat: [
|
||||||
"Level",
|
"Level",
|
||||||
@@ -282,10 +298,95 @@ const EstModel = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
project.options ||= {};
|
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"]) {
|
for (const key of ["quantities", "progress", "annual", "drawings"]) {
|
||||||
if (project[key] === undefined) project[key] = [];
|
if (project[key] === undefined) project[key] = [];
|
||||||
if (!Array.isArray(project[key])) throw Error(`${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;
|
return project;
|
||||||
}
|
}
|
||||||
function legacy(data) {
|
function legacy(data) {
|
||||||
@@ -416,8 +517,16 @@ const EstModel = (() => {
|
|||||||
if (rows[at].TitleCode === old) rows[at].TitleCode = code;
|
if (rows[at].TitleCode === old) rows[at].TitleCode = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function newRow(table, rows) {
|
function newRow(table, rows, project) {
|
||||||
const max = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
|
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 = {
|
const row = {
|
||||||
PkID: max + 1,
|
PkID: max + 1,
|
||||||
Index: 0,
|
Index: 0,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -17,6 +17,8 @@ const scripts = [
|
|||||||
"native-quantity.cjs",
|
"native-quantity.cjs",
|
||||||
"native-wonga.js",
|
"native-wonga.js",
|
||||||
"native-engine.js",
|
"native-engine.js",
|
||||||
|
"native-escalation.js",
|
||||||
|
"native-progress.js",
|
||||||
"native-business.js",
|
"native-business.js",
|
||||||
"native-resources.js",
|
"native-resources.js",
|
||||||
"native-model.js",
|
"native-model.js",
|
||||||
@@ -29,7 +31,7 @@ function calculate(project) {
|
|||||||
engine.input = project;
|
engine.input = project;
|
||||||
try {
|
try {
|
||||||
return vm.runInContext(
|
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,
|
engine,
|
||||||
{
|
{
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
@@ -45,6 +47,7 @@ const allowed = new Set([
|
|||||||
"native-app.js",
|
"native-app.js",
|
||||||
"native-views.js",
|
"native-views.js",
|
||||||
"native-actions.js",
|
"native-actions.js",
|
||||||
|
"native-business-actions.js",
|
||||||
"native-extra.js",
|
"native-extra.js",
|
||||||
"native-settings.js",
|
"native-settings.js",
|
||||||
"native-report.js",
|
"native-report.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user