feat(est): complete four business workflows
This commit is contained in:
@@ -12,6 +12,8 @@ for (const file of [
|
||||
"native-quantity.cjs",
|
||||
"native-wonga.js",
|
||||
"native-engine.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-business.js",
|
||||
"native-resources.js",
|
||||
"native-model.js",
|
||||
|
||||
@@ -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")
|
||||
first, second = result["first"], result["second"]
|
||||
assert first["errors"] == second["errors"] == []
|
||||
assert first["rows"][0]["GumGN"] == 2.5
|
||||
# c914d4 rounds ratio-derived quantities to the contract quantity's digits.
|
||||
assert first["rows"][0]["GumGN"] == 3
|
||||
assert first["rows"][0]["TotalGN"] == 1.5
|
||||
assert first["total"] == 330
|
||||
assert second["rows"][0]["JunGN"] == 1.5
|
||||
assert second["rows"][0]["GumGN"] == 2.5
|
||||
assert second["rows"][0]["TotalGN"] == 4
|
||||
assert second["rows"][0]["TotalTC"] == 800
|
||||
assert first["rows"][-1]["GumTC"] == 550
|
||||
assert first["rows"][-1]["GumTC"] == 660
|
||||
assert first["rows"][-1]["SaTC"] == first["rows"][-1]["TotalTC"] == 330
|
||||
assert second["rows"][-1]["JunTC"] == 330
|
||||
assert second["rows"][-1]["TotalTC"] == 880
|
||||
@@ -246,8 +247,10 @@ def test_progress_accepted_rounds_annual_allocation_and_resources():
|
||||
assert escalation["adjustedTotal"] == 2640
|
||||
assert escalation["excludedTotal"] == 660
|
||||
assert escalation["applied"] == 1540
|
||||
assert escalation["rate"] == 20
|
||||
assert escalation["adjustment"] == 308
|
||||
# REA 10a8fa8: investigation amount 1,980 is the denominator, not contract 2,200.
|
||||
assert escalation["designTotal"] == 1980
|
||||
assert escalation["rate"] == 22.22
|
||||
assert escalation["adjustment"] == 342
|
||||
assert [
|
||||
next(row["TC"] for row in item["tables"]["EstMainDat"] if row.get("LinkIndex") == 54)
|
||||
for item in result["originals"]
|
||||
|
||||
@@ -46,12 +46,18 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력·연차 전용 시험 74개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
|
||||
|
||||
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
|
||||
2026-11-06 지정 범위인 설계변경·기성·물가변동·연차의 웹앱 구현을 완료했습니다. 설계변경은 당초 보존·재계산 0/1/2와 신규·삭제·변경 비교를 제공합니다. 삭제된 당초/기성 행 번호를 새 행에 재사용하지 않습니다.
|
||||
|
||||
기성은 수량·금액·비율·비목별 입력, 사정 승인, 전회·금회·누계 원가를 계산합니다. 확정 회차는 당시 계약 자료를 보존하고 서버 저장 시 결과를 재검산합니다. 전회 수정은 이후 회차의 확정도 해제하며, 전회 미확정 상태에서 다음 회차를 확정할 수 없습니다.
|
||||
|
||||
물가변동은 같은 행 구조의 계약·입찰 자료(JSON/ESTX)를 불러와 조사·계약·변동 단가를 나눕니다. 원본 레코드 순서 방식에 따라 행 순서·코드·레벨이 다른 자료는 거부합니다. 등락 분기, 조사금액 기준 조정률, 자동 최대수량/수동 적용제외, 간접비 포함 적용대가와 기성 누계 불러오기를 제공합니다. 연차는 수식 참조 오류를 차단하며, 미리보기 뒤 선택한 차수만 독립 사본으로 엽니다.
|
||||
|
||||
**전체 원본 실행 동등성은 미확인입니다.** 위 네 업무는 REA 분기·합성 시나리오·실제 Edge 화면 조작·서버 저장/재열기로 검증했으며, 원본 업무 실행 결과 샘플 대조는 없습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 이번 지정 범위 밖이며 미구현입니다.
|
||||
|
||||
검사:
|
||||
|
||||
```powershell
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py resources/tester/test_est_history.py resources/tester/test_est_text_units.py -q
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py resources/tester/test_est_history.py resources/tester/test_est_text_units.py resources/tester/test_est_change_workflow.py resources/tester/test_est_progress_workflow.py resources/tester/test_est_escalation_workflow.py resources/tester/test_est_annual_workflow.py -q
|
||||
```
|
||||
|
||||
`native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<script defer src="native-quantity.cjs"></script>
|
||||
<script defer src="native-wonga.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-resources.js"></script>
|
||||
<script defer src="native-model.js"></script>
|
||||
@@ -21,6 +23,7 @@
|
||||
<script defer src="native-settings.js"></script>
|
||||
<script defer src="native-report.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-business-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -58,7 +58,7 @@ const EstActions = (() => {
|
||||
const rows = activeRows(),
|
||||
{ state } = EstApp;
|
||||
EstApp.change(() => {
|
||||
const row = EstModel.newRow(state.view, rows);
|
||||
const row = EstModel.newRow(state.view, rows, state.project);
|
||||
if (state.view === "MachMainDat") row.Index = -3;
|
||||
const group = EstNative.groups(rows).get(state.group);
|
||||
const at =
|
||||
@@ -84,7 +84,7 @@ const EstActions = (() => {
|
||||
EstApp.change(() => {
|
||||
const index = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)) + 1;
|
||||
const row = {
|
||||
...EstModel.newRow(state.view, rows),
|
||||
...EstModel.newRow(state.view, rows, state.project),
|
||||
Index: index,
|
||||
Code: values.code,
|
||||
Name: values.name,
|
||||
@@ -172,7 +172,7 @@ const EstActions = (() => {
|
||||
break;
|
||||
}
|
||||
const copies = structuredClone(clipboard);
|
||||
let next = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
|
||||
let next = EstModel.newRow(state.view, rows, state.project).PkID - 1;
|
||||
let groupCode = "",
|
||||
nextIndex = Math.max(0, ...rows.map((r) => Number(r.Index) || 0));
|
||||
copies.forEach((r) => {
|
||||
@@ -399,24 +399,21 @@ const EstActions = (() => {
|
||||
state.roundId = next.id;
|
||||
}, false);
|
||||
} else if (name === "lock-round") {
|
||||
const round = project.progress.find((r) => r.id === state.roundId),
|
||||
result = EstBusiness.progress(project, round.id);
|
||||
if (result.errors.length) throw Error(result.errors.join("\n"));
|
||||
if (confirm("이 회차를 확정하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다."))
|
||||
EstApp.change(() => {
|
||||
round.result = result;
|
||||
round.locked = true;
|
||||
}, false);
|
||||
if (
|
||||
confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.")
|
||||
) {
|
||||
EstApp.change(() => (state.project = EstProgress.lock(project, state.roundId)), false);
|
||||
await EstApp.save();
|
||||
}
|
||||
} else if (name === "add-annual")
|
||||
EstApp.change(
|
||||
() => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }),
|
||||
false,
|
||||
);
|
||||
else if (name === "open-annual") {
|
||||
const result = EstBusiness.annual(project);
|
||||
if (result.errors.length) throw Error(result.errors.join("\n"));
|
||||
const result = EstBusiness.annualCopy(project, Number(node.dataset.row));
|
||||
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) {
|
||||
EstApp.setProject(result.results[Number(node.dataset.row)].project);
|
||||
EstApp.setProject(result);
|
||||
EstApp.navigate("EstMainDat");
|
||||
}
|
||||
} else if (name === "delete-extra") {
|
||||
@@ -430,8 +427,10 @@ const EstActions = (() => {
|
||||
pending = null;
|
||||
EstApp.byId("dialog").close();
|
||||
} else if (name === "close-dialog") EstApp.byId("dialog").close();
|
||||
else await EstBusinessActions.handle(name, node);
|
||||
}
|
||||
function install() {
|
||||
EstBusinessActions.install();
|
||||
document.addEventListener("click", async (event) => {
|
||||
try {
|
||||
const marker = event.target.closest("[data-marker]");
|
||||
@@ -470,6 +469,7 @@ const EstActions = (() => {
|
||||
project = state.project;
|
||||
state.notice = "";
|
||||
try {
|
||||
if (EstBusinessActions.change(node)) return;
|
||||
if (node.matches("[data-select]")) {
|
||||
const index = Number(node.dataset.select);
|
||||
node.checked ? state.selection.add(index) : state.selection.delete(index);
|
||||
@@ -552,6 +552,8 @@ const EstActions = (() => {
|
||||
EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value);
|
||||
if (state.group === previous && Number(row.Index) > 0) state.group = row.Code;
|
||||
} else row[key] = value;
|
||||
if (state.view === "escalation" && EstMath.units.includes(key))
|
||||
project.options.SourceRefresh = false;
|
||||
if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) {
|
||||
if (key === "TC") {
|
||||
row.InputTC = value;
|
||||
|
||||
@@ -100,6 +100,9 @@ const EstApp = (() => {
|
||||
state.selection.clear();
|
||||
state.group = "";
|
||||
state.page = 0;
|
||||
state.roundId =
|
||||
state.project.progress.find((round) => round.id === state.roundId)?.id ||
|
||||
state.project.progress[0]?.id;
|
||||
if (state.project.options.AutoCalc !== false) calculate();
|
||||
else
|
||||
state.project.calculation ||= {
|
||||
|
||||
@@ -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 { num, components, units, floating, sum } = EstMath;
|
||||
const copy = (value) => structuredClone(value);
|
||||
const rowKey = (row, index) => String(row.PkID ?? row.Code ?? index);
|
||||
const rowKey = (row, index) => String(row.PkID ?? (row.Code || `row:${index}`));
|
||||
const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex));
|
||||
function baseline(project) {
|
||||
const working = { ...project };
|
||||
@@ -54,26 +54,56 @@ const EstBusiness = (() => {
|
||||
}
|
||||
function changes(project) {
|
||||
const old = project.baseline?.tables.EstMainDat || [];
|
||||
return project.tables.EstMainDat.map((row, index) => {
|
||||
const seen = new Set();
|
||||
const changes = project.tables.EstMainDat.map((row, index) => {
|
||||
seen.add(rowKey(row, index));
|
||||
const saved = old.find((r, i) => rowKey(r, i) === rowKey(row, index));
|
||||
const stored =
|
||||
!project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null)
|
||||
? { GN: row.GN_, TC: row.TC_ }
|
||||
? Object.fromEntries(
|
||||
Object.entries(row)
|
||||
.filter(([key]) => key.endsWith("_"))
|
||||
.map(([key, value]) => [key.slice(0, -1), value]),
|
||||
)
|
||||
: null;
|
||||
const original = saved || stored;
|
||||
return {
|
||||
key: rowKey(row, index),
|
||||
Code: row.Code,
|
||||
Name: row.Name,
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
GN: num(row.GN),
|
||||
originalGN: num(original?.GN),
|
||||
TC: num(row.TC),
|
||||
originalTC: num(original?.TC),
|
||||
difference: num(row.TC) - num(original?.TC),
|
||||
NewItem: !original,
|
||||
status: !original
|
||||
? "new"
|
||||
: ["Code", "Name", "Size", "Unite", "GN", "TC", ...units, ...components].some(
|
||||
(key) => original[key] != null && original[key] !== row[key],
|
||||
)
|
||||
? "changed"
|
||||
: "unchanged",
|
||||
originalSource: saved ? "baseline" : stored ? "file" : null,
|
||||
};
|
||||
});
|
||||
old.forEach((row, index) => {
|
||||
if (seen.has(rowKey(row, index))) return;
|
||||
changes.push({
|
||||
key: rowKey(row, index),
|
||||
Code: row.Code || "",
|
||||
Name: row.Name || "",
|
||||
originalGN: num(row.GN),
|
||||
originalTC: num(row.TC),
|
||||
GN: 0,
|
||||
TC: 0,
|
||||
difference: -num(row.TC),
|
||||
NewItem: false,
|
||||
status: "deleted",
|
||||
originalSource: "baseline",
|
||||
});
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
function bid(project, rates) {
|
||||
const next = copy(project);
|
||||
@@ -95,6 +125,55 @@ const EstBusiness = (() => {
|
||||
next.options.SourceRefresh = false;
|
||||
return EstNative.calculate(next);
|
||||
}
|
||||
function aligned(project, incoming) {
|
||||
const a = project.tables.EstMainDat,
|
||||
b = incoming.tables.EstMainDat;
|
||||
// 10b5934 moves the source table to the current record number, rather than seeking a code.
|
||||
if (
|
||||
a.length !== b.length ||
|
||||
a.some(
|
||||
(row, index) =>
|
||||
String(row.Code || "") !== String(b[index].Code || "") ||
|
||||
num(row.Level) !== num(b[index].Level),
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"불러올 내역의 행 순서·코드·레벨이 다릅니다. 같은 구조의 계약/입찰 내역을 선택하세요.",
|
||||
);
|
||||
return incoming.options?.AutoCalc === false ? incoming : EstNative.calculate(incoming);
|
||||
}
|
||||
function importContract(project, incoming) {
|
||||
const source = aligned(project, incoming),
|
||||
next = copy(project),
|
||||
snapshot = baseline(source).baseline;
|
||||
snapshot.tables.EstMainDat.forEach((row, index) => {
|
||||
const current = next.tables.EstMainDat[index];
|
||||
if (current.PkID != null) row.PkID = current.PkID;
|
||||
else delete row.PkID;
|
||||
for (const field of [
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"TU",
|
||||
"TC",
|
||||
...units,
|
||||
...components,
|
||||
])
|
||||
if (row[field] != null) current[field + "_"] = copy(row[field]);
|
||||
});
|
||||
next.baseline = snapshot;
|
||||
return next;
|
||||
}
|
||||
function importBid(project, incoming) {
|
||||
const source = aligned(project, incoming),
|
||||
next = copy(project);
|
||||
next.tables.EstMainDat.forEach((row, index) =>
|
||||
units.forEach((unit) => (row["A" + unit] = num(source.tables.EstMainDat[index][unit]))),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
function original(project, method = 0) {
|
||||
if (!project.baseline) throw Error("당초 내역을 먼저 확정하세요.");
|
||||
const old = {
|
||||
@@ -131,244 +210,27 @@ const EstBusiness = (() => {
|
||||
if (calculated.calculation.errors.length) throw Error(calculated.calculation.errors.join("\n"));
|
||||
return { name: old.name, tables: calculated.tables };
|
||||
}
|
||||
function inputValue(row, input, prefix) {
|
||||
const mode = input[`${prefix}Mode`] || "quantity",
|
||||
value = num(input[prefix]);
|
||||
if (mode === "amount") return num(row.TC) ? (value / num(row.TC)) * num(row.GN) : 0;
|
||||
if (mode === "ratio")
|
||||
return floating((value / 100) * num(row.GN), input.QuantityDigits ?? 4, 1);
|
||||
return value;
|
||||
}
|
||||
function progress(project, roundId) {
|
||||
const round = project.progress.find((r) => r.id === roundId);
|
||||
if (!round) throw Error("기성 회차 없음");
|
||||
const position = project.progress.indexOf(round),
|
||||
rows = copy(project.tables.EstMainDat),
|
||||
errors = [];
|
||||
const previousRounds = project.progress.slice(0, position);
|
||||
if (previousRounds.some((r) => !r.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
|
||||
const previous = previousRounds.at(-1)?.result?.rows || [];
|
||||
const segment = { Gum: [], Sa: [] };
|
||||
rows.forEach((row, index) => {
|
||||
const key = rowKey(row, index),
|
||||
input = round.inputs?.[key] || {};
|
||||
const old = previous.find((r, i) => rowKey(r, i) === key);
|
||||
row.JunGN = num(old?.TotalGN);
|
||||
row.JunTC = num(old?.TotalTC);
|
||||
row.JunRatio = num(old?.TotalRatio);
|
||||
for (const k of components) row[`Jun${k}`] = num(old?.[`Total${k}`]);
|
||||
if (num(row.Level) > 0) {
|
||||
segment.Gum = [];
|
||||
segment.Sa = [];
|
||||
return;
|
||||
}
|
||||
for (const prefix of ["Gum", "Sa"]) {
|
||||
const quantity = inputValue(row, input, prefix),
|
||||
factor = special(row) ? quantity / 100 : quantity;
|
||||
row[`${prefix}GN`] = quantity;
|
||||
components.forEach(
|
||||
(k, i) =>
|
||||
(row[`${prefix}${k}`] = EstMath.fixed(
|
||||
num(row[units[i]]) * factor,
|
||||
input.AmountDigits || 0,
|
||||
)),
|
||||
);
|
||||
if ([62, 67].includes(num(row.LinkIndex))) {
|
||||
const percent = { ...row, GN: num(row.GN), CalcIndex: 0 };
|
||||
const converted = segment[prefix].map((r) =>
|
||||
Object.fromEntries(
|
||||
[...components, "TC"]
|
||||
.map((k) => [k, num(r[`${prefix}${k}`])])
|
||||
.concat([["SKind", r.SKind]]),
|
||||
),
|
||||
);
|
||||
EstRows.percent(percent, converted);
|
||||
components.forEach((k) => (row[`${prefix}${k}`] = num(percent[k])));
|
||||
}
|
||||
row[`${prefix}TC`] = components.reduce((n, k) => n + num(row[`${prefix}${k}`]), 0);
|
||||
row[`${prefix}Ratio`] = num(row.TC)
|
||||
? floating((row[`${prefix}TC`] / num(row.TC)) * 100, 2, 1)
|
||||
: 0;
|
||||
segment[prefix].push(row);
|
||||
}
|
||||
const accepted = round.approved ? "Sa" : "Gum";
|
||||
for (const k of ["GN", "TC", "Ratio", ...components])
|
||||
row[`Total${k}`] = num(row[`Jun${k}`]) + num(row[`${accepted}${k}`]);
|
||||
});
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (num(row.Level) <= 0) continue;
|
||||
const children = [];
|
||||
for (
|
||||
let j = i + 1;
|
||||
j < rows.length &&
|
||||
num(rows[j].Level) < num(row.Level) &&
|
||||
![54, 59].includes(num(rows[j].LinkIndex));
|
||||
j++
|
||||
)
|
||||
if (num(rows[j].Level) === 0 && num(rows[j].CalcIndex) < 2) children.push(rows[j]);
|
||||
for (const prefix of ["Jun", "Gum", "Sa", "Total"])
|
||||
for (const k of ["TC", ...components])
|
||||
row[`${prefix}${k}`] = children.reduce((n, r) => n + num(r[`${prefix}${k}`]), 0);
|
||||
}
|
||||
const boundary = rows.findIndex((r) => num(r.LinkIndex) === 54);
|
||||
const direct = rows
|
||||
.slice(0, boundary < 0 ? rows.length : boundary)
|
||||
.filter((r) => !num(r.Level) && num(r.CalcIndex) < 2);
|
||||
const accepted = round.approved ? "Sa" : "Gum";
|
||||
const current = direct.reduce((n, r) => n + num(r[`${accepted}TC`]), 0);
|
||||
const contract = direct.reduce((n, r) => n + num(r.TC), 0),
|
||||
ratio = contract ? current / contract : 0;
|
||||
function costsFor(prefix) {
|
||||
const part = direct.reduce((n, r) => n + num(r[`${prefix}TC`]), 0);
|
||||
const proportion = contract ? part / contract : 0;
|
||||
const bases = Object.fromEntries(
|
||||
[...components, "TC"].map((key) => [
|
||||
key,
|
||||
direct.reduce((n, r) => n + num(r[`${prefix}${key}`]), 0),
|
||||
]),
|
||||
);
|
||||
const material = EstNative.materialBase(rows, project.options, `${prefix}GC`);
|
||||
const wonga = copy(project.tables.Wonga);
|
||||
for (const row of wonga) {
|
||||
if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) {
|
||||
row.Cost = Math.trunc(num(row.Cost) * proportion);
|
||||
row.VSusic = "";
|
||||
} else if (!row.VSusic && !row.BaseField && ![1, 3].includes(num(row.CalcIndex)))
|
||||
row.Cost = Math.trunc(num(row.Cost) * proportion);
|
||||
if (row.BaseField) {
|
||||
row.Cost = num(bases[row.BaseField]);
|
||||
row.VSusic = "";
|
||||
} else if (!row.VSusic && !row.Youl && ![1, 3].includes(num(row.CalcIndex))) {
|
||||
const name = EstNative.normalize(row.Name);
|
||||
if (name.includes("직접재료")) row.Cost = material;
|
||||
else if (name.includes("간접재료")) row.Cost = bases.GC - material;
|
||||
else if (name.includes("직접노무")) row.Cost = bases.NC;
|
||||
else if (name.includes("산출경비")) row.Cost = bases.JC;
|
||||
}
|
||||
}
|
||||
const costs = EstWonga.calculate(wonga, { ...project.options, TotalFix: false }, bases);
|
||||
errors.push(...costs.errors);
|
||||
const byName = new Map(
|
||||
costs.rows
|
||||
.filter((r) => num(r.CalcIndex) < 2 && r.Gubun)
|
||||
.map((r) => [EstNative.normalize(r.Name), r]),
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (num(row.LinkIndex) === 54) {
|
||||
for (const key of [...components, "TC"]) row[`${prefix}${key}`] = bases[key];
|
||||
} else if (
|
||||
[55, 58].includes(num(row.LinkIndex)) &&
|
||||
byName.has(EstNative.normalize(row.Name))
|
||||
)
|
||||
row[`${prefix}TC`] = num(byName.get(EstNative.normalize(row.Name)).Cost);
|
||||
}
|
||||
return costs;
|
||||
}
|
||||
const gumCosts = costsFor("Gum"),
|
||||
saCosts = costsFor("Sa"),
|
||||
costs = round.approved ? saCosts : gumCosts;
|
||||
for (const row of rows)
|
||||
if ([54, 55, 58].includes(num(row.LinkIndex))) {
|
||||
for (const key of [...components, "TC"])
|
||||
row[`Total${key}`] = num(row[`Jun${key}`]) + num(row[`${accepted}${key}`]);
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
wonga: costs.rows,
|
||||
total: costs.total || current,
|
||||
ratio: floating(ratio * 100, 2, 1),
|
||||
errors,
|
||||
};
|
||||
return EstProgress.calculate(project, roundId);
|
||||
}
|
||||
function escalation(project) {
|
||||
const rows = copy(project.tables.EstMainDat),
|
||||
errors = [];
|
||||
const contractRows = project.baseline?.tables.EstMainDat || rows;
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index],
|
||||
old = contractRows.find((r, i) => rowKey(r, i) === rowKey(row, index)) || row;
|
||||
if (num(row.Level) > 0 || [1, 3].includes(num(row.CalcIndex))) continue;
|
||||
let total = 0;
|
||||
units.forEach((unit, i) => {
|
||||
const A = num(row[`A${unit}`]),
|
||||
C = num(old[unit]),
|
||||
B = num(row[unit]);
|
||||
const D = A && B ? floating(((B - A) / A) * 100, 2) : 0;
|
||||
let E = 0;
|
||||
if (A && B && A >= C && B >= A) E = Math.trunc((C * D) / 100);
|
||||
else if (A && B && A < C && B >= C) E = Math.trunc(B - C);
|
||||
row[`D${unit}`] = D;
|
||||
row[`E${unit}`] = E;
|
||||
row[`F${unit}`] = C + E;
|
||||
row[`E${components[i]}`] = Math.trunc(E * num(row.GN));
|
||||
row[components[i]] = Math.trunc((C + E) * num(row.GN));
|
||||
total += row[components[i]];
|
||||
});
|
||||
row.TC = total;
|
||||
const excluded = Math.max(num(row.EscPlanned), num(row.EscActual), num(row.EscProgress));
|
||||
row.EscExcluded = excluded;
|
||||
row.EscApplied = num(old.GN) - excluded;
|
||||
}
|
||||
// 내역 재집계는 조정 단가를 사용한다.
|
||||
for (const row of rows)
|
||||
units.forEach((unit) => {
|
||||
if (Object.hasOwn(row, `F${unit}`)) row[unit] = row[`F${unit}`];
|
||||
});
|
||||
const adjusted = EstRows.estimate(rows, { ...project.options, SourceRefresh: false });
|
||||
function costCopy(estimateRows) {
|
||||
const next = copy(project);
|
||||
next.tables.EstMainDat = estimateRows;
|
||||
next.options = { ...next.options, SourceRefresh: false, TotalFix: false };
|
||||
return EstNative.calculate(next);
|
||||
}
|
||||
const adjustedProject = costCopy(adjusted.rows);
|
||||
const contractProject = costCopy(copy(contractRows));
|
||||
const excludedRows = copy(contractRows);
|
||||
excludedRows.forEach((row, index) => {
|
||||
if (num(row.Level) > 0 || [54, 55, 57, 58, 59, 62, 67].includes(num(row.LinkIndex))) return;
|
||||
const current = rows.find((r, i) => rowKey(r, i) === rowKey(row, index));
|
||||
row.GN = num(current?.EscExcluded);
|
||||
});
|
||||
const excludedProject = costCopy(excludedRows);
|
||||
const contractTotal = num(contractProject.calculation.total);
|
||||
const applied = contractTotal - num(excludedProject.calculation.total);
|
||||
const rate = contractTotal
|
||||
? floating(
|
||||
((num(adjustedProject.calculation.total) - contractTotal) / contractTotal) * 100,
|
||||
2,
|
||||
)
|
||||
: 0;
|
||||
return {
|
||||
rows: adjusted.rows,
|
||||
direct: adjusted.direct,
|
||||
wonga: adjustedProject.tables.Wonga,
|
||||
contractTotal,
|
||||
adjustedTotal: num(adjustedProject.calculation.total),
|
||||
excludedTotal: num(excludedProject.calculation.total),
|
||||
applied,
|
||||
rate,
|
||||
adjustment: Math.trunc((applied * rate) / 100),
|
||||
errors: [
|
||||
...new Set([
|
||||
...errors,
|
||||
...adjusted.errors,
|
||||
...adjustedProject.calculation.errors,
|
||||
...contractProject.calculation.errors,
|
||||
...excludedProject.calculation.errors,
|
||||
]),
|
||||
],
|
||||
};
|
||||
return EstEscalation.calculate(project);
|
||||
}
|
||||
function annual(project) {
|
||||
function annual(project, full = true) {
|
||||
const errors = [],
|
||||
results = [];
|
||||
const names = (project.annual || []).map((year) => String(year.name || ""));
|
||||
const names = (project.annual || []).map((year) => String(year.name || "").trim());
|
||||
if (names.some((name) => !name.trim()) || new Set(names).size !== names.length)
|
||||
return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] };
|
||||
for (const year of project.annual || []) {
|
||||
const next = copy(project);
|
||||
next.name = `${project.name} · ${year.name}`;
|
||||
const next = full ? copy(project) : EstNative.empty();
|
||||
if (!full) {
|
||||
next.tables.EstMainDat = copy(project.tables.EstMainDat);
|
||||
next.tables.Wonga = copy(project.tables.Wonga);
|
||||
next.options = { ...project.options };
|
||||
}
|
||||
const name = String(year.name).trim();
|
||||
next.name = `${project.name} · ${name}`;
|
||||
delete next.id;
|
||||
next.progress = [];
|
||||
next.annual = [];
|
||||
@@ -403,9 +265,37 @@ const EstBusiness = (() => {
|
||||
}
|
||||
const calculated = EstNative.calculate(next);
|
||||
errors.push(...calculated.calculation.errors);
|
||||
results.push({ name: year.name, project: calculated });
|
||||
results.push({ name, project: calculated });
|
||||
}
|
||||
return { results, errors };
|
||||
}
|
||||
return { baseline, changes, bid, original, progress, escalation, annual, rowKey };
|
||||
function annualCopy(project, index) {
|
||||
const result = annual(project, false);
|
||||
if (result.errors.length) throw Error(result.errors.join("\n"));
|
||||
const chosen = result.results[index]?.project;
|
||||
if (!chosen) throw Error("연차 내역이 없습니다.");
|
||||
const next = copy(project);
|
||||
delete next.id;
|
||||
next.name = chosen.name;
|
||||
next.annual = [];
|
||||
next.progress = [];
|
||||
next.options = chosen.options;
|
||||
next.tables.EstMainDat = chosen.tables.EstMainDat;
|
||||
next.tables.Wonga = chosen.tables.Wonga;
|
||||
next.calculation = chosen.calculation;
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
baseline,
|
||||
changes,
|
||||
bid,
|
||||
original,
|
||||
progress,
|
||||
escalation,
|
||||
annual,
|
||||
rowKey,
|
||||
importContract,
|
||||
importBid,
|
||||
annualCopy,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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(
|
||||
"설계변경",
|
||||
button("capture-baseline", "현재 내역을 당초로 확정") +
|
||||
button("load-contract", "당초·계약 내역 불러오기") +
|
||||
button("recalculate-original", "당초 단가 재계산") +
|
||||
button("bid-rates", "낙찰률 적용"),
|
||||
table(
|
||||
["코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
|
||||
["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
|
||||
changes
|
||||
.filter((row) => row.Code)
|
||||
.map((row) => [
|
||||
esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]),
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
number(row.originalGN),
|
||||
@@ -87,7 +89,14 @@ const EstExtra = (() => {
|
||||
if (round) {
|
||||
tools +=
|
||||
`<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>`;
|
||||
let output;
|
||||
try {
|
||||
@@ -114,26 +123,42 @@ const EstExtra = (() => {
|
||||
"누계금액",
|
||||
],
|
||||
output.rows
|
||||
.filter((r) => r.Code && !EstMath.num(r.Level))
|
||||
.map((row, index) => {
|
||||
const originalIndex = project.tables.EstMainDat.findIndex(
|
||||
(r) => r.PkID === row.PkID && r.Code === row.Code,
|
||||
),
|
||||
key = EstBusiness.rowKey(row, originalIndex),
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(
|
||||
({ row }) =>
|
||||
!EstMath.num(row.Level) &&
|
||||
(row.Code ||
|
||||
row.Name ||
|
||||
[54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))),
|
||||
)
|
||||
.map(({ row, index }) => {
|
||||
const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
|
||||
const key = EstBusiness.rowKey(row, index),
|
||||
input = round.inputs?.[key] || {};
|
||||
const edit = (prefix) =>
|
||||
`<input 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) =>
|
||||
`<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
|
||||
["quantity", "수량"],
|
||||
["amount", "금액"],
|
||||
["ratio", "비율 %"],
|
||||
]
|
||||
.map(
|
||||
([v, label]) =>
|
||||
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
|
||||
)
|
||||
.join("")}</select>`;
|
||||
!editable
|
||||
? "자동계산"
|
||||
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
|
||||
["quantity", "수량"],
|
||||
["amount", "금액"],
|
||||
["ratio", "비율 %"],
|
||||
["components", "비목 금액"],
|
||||
]
|
||||
.map(
|
||||
([v, label]) =>
|
||||
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
|
||||
)
|
||||
.join("")}</select>`;
|
||||
return [
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
@@ -151,6 +176,17 @@ const EstExtra = (() => {
|
||||
}),
|
||||
) +
|
||||
`<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(
|
||||
"기성고",
|
||||
@@ -169,19 +205,24 @@ const EstExtra = (() => {
|
||||
)
|
||||
.join("");
|
||||
const rows = project.tables.EstMainDat.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => row.Code && !EstMath.num(row.Level))
|
||||
.filter(
|
||||
({ row }) =>
|
||||
(row.Code || row.Name) &&
|
||||
!EstMath.num(row.Level) &&
|
||||
![54, 55, 58, 59].includes(Number(row.LinkIndex)),
|
||||
)
|
||||
.map(({ row, index }) => {
|
||||
const old =
|
||||
project.baseline?.tables.EstMainDat.find(
|
||||
(r) => r.PkID === row.PkID && r.Code === row.Code,
|
||||
(r, i) => EstBusiness.rowKey(r, i) === EstBusiness.rowKey(row, index),
|
||||
) || row,
|
||||
result = output.rows[index];
|
||||
return [
|
||||
esc(row.Code),
|
||||
esc(row.Name),
|
||||
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
|
||||
number(old[comp]),
|
||||
number(row[comp]),
|
||||
number(project.baseline ? old[comp] : (row[comp + "_"] ?? row[comp])),
|
||||
EstViews.cell("EstMainDat", index, comp, row[comp]),
|
||||
number(result[`D${comp}`]),
|
||||
number(result[`E${comp}`]),
|
||||
number(result[`F${comp}`]),
|
||||
@@ -190,13 +231,22 @@ const EstExtra = (() => {
|
||||
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
|
||||
number(result.EscExcluded),
|
||||
number(result.EscApplied),
|
||||
`<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(
|
||||
"물가변동 품목조정",
|
||||
`<select id="escalation-component">${choices}</select>` +
|
||||
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(
|
||||
[
|
||||
"코드",
|
||||
@@ -212,13 +262,18 @@ const EstExtra = (() => {
|
||||
"기성수량",
|
||||
"적용제외",
|
||||
"적용수량",
|
||||
"제외 자동",
|
||||
"수동 제외수량",
|
||||
"시공중 수량(참고)",
|
||||
"PS 수량(참고)",
|
||||
"선시공 수량(참고)",
|
||||
],
|
||||
rows,
|
||||
),
|
||||
`계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. ${esc(output.errors.join(" / "))}`,
|
||||
`조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`,
|
||||
);
|
||||
} else if (view === "annual") {
|
||||
const output = EstBusiness.annual(project);
|
||||
const output = EstBusiness.annual(project, false);
|
||||
state.business = output;
|
||||
html = box(
|
||||
"연차공사",
|
||||
|
||||
@@ -78,8 +78,24 @@ const EstModel = (() => {
|
||||
"Cost",
|
||||
...Array.from({ length: 6 }, (_, i) => `Cost${i + 1}`),
|
||||
]);
|
||||
for (const key of ["ANU", "AGU", "AJU", "ASU", "EscPlanned", "EscActual", "EscProgress"])
|
||||
for (const key of [
|
||||
"ANU",
|
||||
"AGU",
|
||||
"AJU",
|
||||
"ASU",
|
||||
"EscPlanned",
|
||||
"EscActual",
|
||||
"EscProgress",
|
||||
"EscManualQuantity",
|
||||
"EscConstruction",
|
||||
"EscPS",
|
||||
"EscBefore",
|
||||
])
|
||||
numeric.add(key);
|
||||
for (const prefix of ["Jun", "Gum", "Sa", "Total"])
|
||||
for (const field of ["GN", "TC", "Ratio", "NC", "GC", "JC", "SC"]) numeric.add(prefix + field);
|
||||
for (const field of ["ContractCost", "JunCost", "GumCost", "SaCost", "TotalCost"])
|
||||
numeric.add(field);
|
||||
const definitions = {
|
||||
EstMainDat: [
|
||||
"Level",
|
||||
@@ -282,10 +298,95 @@ const EstModel = (() => {
|
||||
}
|
||||
}
|
||||
project.options ||= {};
|
||||
if (typeof project.options !== "object" || Array.isArray(project.options))
|
||||
throw Error("계산 옵션 형식 오류");
|
||||
const validNumber = (value, label) => {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
Math.abs(value) > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
throw Error(`수치 필드 오류: ${label}`);
|
||||
};
|
||||
if (project.options.EscDesignTotal != null) {
|
||||
validNumber(project.options.EscDesignTotal, "조사 도급액");
|
||||
if (project.options.EscDesignTotal < 0) throw Error("조사 도급액은 0 이상입니다.");
|
||||
}
|
||||
for (const key of ["quantities", "progress", "annual", "drawings"]) {
|
||||
if (project[key] === undefined) project[key] = [];
|
||||
if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`);
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const round of project.progress) {
|
||||
if (
|
||||
!round ||
|
||||
typeof round !== "object" ||
|
||||
typeof round.id !== "string" ||
|
||||
!round.id ||
|
||||
ids.has(round.id)
|
||||
)
|
||||
throw Error("기성 회차 ID가 없거나 중복됩니다.");
|
||||
ids.add(round.id);
|
||||
for (const field of ["locked", "approved", "separateComponents"])
|
||||
if (round[field] != null && typeof round[field] !== "boolean")
|
||||
throw Error("기성 옵션 형식 오류");
|
||||
if (round.name != null && typeof round.name !== "string")
|
||||
throw Error("기성 회차 이름 형식 오류");
|
||||
round.inputs ??= {};
|
||||
if (typeof round.inputs !== "object" || Array.isArray(round.inputs))
|
||||
throw Error("기성 입력 형식 오류");
|
||||
round.inputs = Object.assign(Object.create(null), round.inputs);
|
||||
for (const input of Object.values(round.inputs)) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||
throw Error("기성 입력 행 형식 오류");
|
||||
for (const prefix of ["Gum", "Sa"]) {
|
||||
if (
|
||||
input[prefix + "Mode"] != null &&
|
||||
!["quantity", "amount", "ratio", "components"].includes(input[prefix + "Mode"])
|
||||
)
|
||||
throw Error("기성 입력방법 오류");
|
||||
for (const field of ["", "NC", "GC", "JC", "SC"])
|
||||
if (input[prefix + field] != null)
|
||||
validNumber(input[prefix + field], "기성 " + prefix + field);
|
||||
}
|
||||
for (const field of ["QuantityDigits", "AmountDigits"])
|
||||
if (input[field] != null) {
|
||||
validNumber(input[field], field);
|
||||
if (!Number.isInteger(input[field]) || input[field] < 0 || input[field] > 12)
|
||||
throw Error("기성 자릿수 형식 오류");
|
||||
}
|
||||
}
|
||||
if (
|
||||
round.amountDigits != null &&
|
||||
(!Number.isInteger(round.amountDigits) || round.amountDigits < 0 || round.amountDigits > 4)
|
||||
)
|
||||
throw Error("기성 금액 자릿수는 0~4입니다.");
|
||||
if (round.contract) {
|
||||
validate({
|
||||
schema: "est-web-2",
|
||||
name: "기성 계약",
|
||||
tables: { EstMainDat: round.contract.rows, Wonga: round.contract.wonga },
|
||||
options: round.contract.options,
|
||||
});
|
||||
}
|
||||
if (round.result?.rows)
|
||||
validate({
|
||||
schema: "est-web-2",
|
||||
name: "기성 결과",
|
||||
tables: { EstMainDat: round.result.rows },
|
||||
});
|
||||
if (round.result)
|
||||
for (const field of ["total", "ratio"])
|
||||
if (round.result[field] != null) validNumber(round.result[field], "확정 기성 " + field);
|
||||
}
|
||||
for (const year of project.annual)
|
||||
if (!year || typeof year.name !== "string" || typeof year.formula !== "string")
|
||||
throw Error("연차 이름·수식 형식 오류");
|
||||
if (project.baseline) {
|
||||
if (!project.baseline.tables || !Array.isArray(project.baseline.tables.EstMainDat))
|
||||
throw Error("당초 내역 형식 오류");
|
||||
validate({ schema: "est-web-2", name: "당초", tables: project.baseline.tables });
|
||||
}
|
||||
return project;
|
||||
}
|
||||
function legacy(data) {
|
||||
@@ -416,8 +517,16 @@ const EstModel = (() => {
|
||||
if (rows[at].TitleCode === old) rows[at].TitleCode = code;
|
||||
}
|
||||
}
|
||||
function newRow(table, rows) {
|
||||
const max = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
|
||||
function newRow(table, rows, project) {
|
||||
const historical = [rows, project?.baseline?.tables?.[table] || []];
|
||||
if (table === "EstMainDat")
|
||||
for (const round of project?.progress || [])
|
||||
historical.push(round.contract?.rows || [], round.result?.rows || []);
|
||||
const max = historical.reduce(
|
||||
(maximum, list) =>
|
||||
list.reduce((value, row) => Math.max(value, Number(row.PkID) || 0), maximum),
|
||||
0,
|
||||
);
|
||||
const row = {
|
||||
PkID: max + 1,
|
||||
Index: 0,
|
||||
|
||||
@@ -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-wonga.js",
|
||||
"native-engine.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-business.js",
|
||||
"native-resources.js",
|
||||
"native-model.js",
|
||||
@@ -29,7 +31,7 @@ function calculate(project) {
|
||||
engine.input = project;
|
||||
try {
|
||||
return vm.runInContext(
|
||||
"input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input))",
|
||||
"EstProgress.rebuild(input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input)))",
|
||||
engine,
|
||||
{
|
||||
timeout: 20000,
|
||||
@@ -45,6 +47,7 @@ const allowed = new Set([
|
||||
"native-app.js",
|
||||
"native-views.js",
|
||||
"native-actions.js",
|
||||
"native-business-actions.js",
|
||||
"native-extra.js",
|
||||
"native-settings.js",
|
||||
"native-report.js",
|
||||
|
||||
Reference in New Issue
Block a user