feat(est): complete four business workflows

This commit is contained in:
2026-11-06 05:51:38 +09:00
parent 4e08509700
commit 74ab66b00e
17 changed files with 1262 additions and 286 deletions
+2
View File
@@ -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
+7 -4
View File
@@ -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"]
+8 -2
View File
@@ -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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
+3
View File
@@ -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>
+16 -14
View File
@@ -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;
+3
View File
@@ -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 ||= {
+186
View File
@@ -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
View File
@@ -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,
};
})();
+113
View File
@@ -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 };
})();
+81 -26
View File
@@ -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(
"연차공사",
+112 -3
View File
@@ -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,
+293
View File
@@ -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 };
})();
+4 -1
View File
@@ -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",