Merge remote-tracking branch 'origin/CODEX' into main_laptop_1

This commit is contained in:
2026-11-05 15:22:09 +09:00
16 changed files with 1005 additions and 98 deletions
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -23,8 +23,10 @@ for (const table of [
b = table.includes("Title") ? lookup.get(a.Code) : q.tables[table][i];
if (a.Index > 0) group = a.Code;
if (table === "CostMainDat") {
const before = [...String(a.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)],
after = [...String(b?.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
const markers =
a.LinkIndex === 61 ? /[\x12\x0c]([-\d.,]+)[\x12\x0c]/g : /\x12([-\d.,]+)\x12/g;
const before = [...String(a.LineData || "").matchAll(markers)],
after = [...String(b?.LineData || "").matchAll(markers)];
for (let part = 0; part < before.length; part++) {
const x = Number(before[part][1].replaceAll(",", "")),
y = after[part] ? Number(after[part][1].replaceAll(",", "")) : null;
+10 -1
View File
@@ -64,7 +64,16 @@ else if (command === "repeat")
);
else if (command === "import")
result = require(path.join(base, "import-estx.cjs")).importEstx(fs.readFileSync(process.argv[3]));
else if (command === "blob-tail") {
else if (command === "baseline-size") {
context.input = require(path.join(base, "import-estx.cjs")).importEstx(
fs.readFileSync(process.argv[3]),
);
const baseline = vm.runInContext("EstBusiness.baseline(input)", context);
result = {
bytes: Buffer.byteLength(JSON.stringify(baseline), "utf8"),
archivePreserved: baseline.originalArchive === context.input.originalArchive,
};
} else if (command === "blob-tail") {
const { blob } = require(path.join(base, "import-estx.cjs")),
bytes = Buffer.alloc(1536);
bytes.writeUInt32LE(2, 516);
+247
View File
@@ -0,0 +1,247 @@
# Annual allocation and resource expansion regression cases.
from test_est_rea_parity import run
def test_annual_formulas_split_component_amounts_and_preserve_remainder():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'item',GN:1,NU:5,GU:3},{LinkIndex:54}];
p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}];
const q=EstNative.calculate(p),before=JSON.stringify(q);
return {output:EstBusiness.annual(q),preserved:before===JSON.stringify(q)};
})()""",
)
assert result["preserved"]
output = result["output"]
assert output["errors"] == []
first, second = [item["project"] for item in output["results"]]
assert first["tables"]["EstMainDat"][0]["GN"] == 0.5
assert second["tables"]["EstMainDat"][0]["GN"] == 0.5
assert [item["calculation"]["direct"]["TC"] for item in [first, second]] == [3, 5]
assert first["tables"]["EstMainDat"][0]["NC"] == 2
assert second["tables"]["EstMainDat"][0]["NC"] == 3
assert second["tables"]["EstMainDat"][0]["GC"] == 2
assert all(first["tables"]["EstMainDat"][0][key] == 0 for key in ["TU", "NU", "GU", "JU"])
def test_annual_fixed_amount_with_zero_quantity_and_duplicate_names():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:0,NC:3,GC:7,TC:10,CalcIndex:1},{LinkIndex:54}];
p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}];
const output=EstBusiness.annual(EstNative.calculate(p));
p.annual[1].name='1차';
return {output,invalid:EstBusiness.annual(p)};
})()""",
)
assert result["output"]["errors"] == []
assert [
item["project"]["calculation"]["direct"]["TC"] for item in result["output"]["results"]
] == [4, 6]
assert result["invalid"]["results"] == []
assert len(result["invalid"]["errors"]) == 1
def test_resource_expansion_preserves_different_labor_and_material_bid_rates():
result = run(
"math",
r"""(()=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:100}];
p.tables.LaborcDat=[{Code:'L',Name:'labor',ValueIndex:4,InputTC:100}];
p.tables.CostMainDat=[{Index:1,Code:'C'},
{Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x12100\x12'},
{Code:'L',LinkIndex:6,SCheck:-1,LineData:' \x0elabor\x0e: \x03100\x03 = \x12100\x12'}];
p.tables.CostTitleDat=[{Code:'C'}];
p.tables.EstMainDat=[{PkID:1,Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}];
const bid=EstBusiness.bid(EstNative.calculate(p),[80,90,100,100]);
const direct=EstResources.calculate(bid);
p.tables.HeadMainDat=[{Index:1,Code:'H'},{Code:'C',LinkIndex:104,GN:1}];
p.tables.HeadTitleDat=[{Code:'H'}];p.tables.EstMainDat[0].LinkIndex=103;p.tables.EstMainDat[0].Code='H';
const nested=EstResources.calculate(EstBusiness.bid(EstNative.calculate(p),[80,90,100,100]));
return {direct,nested};
})()""",
)
for output in result.values():
assert output["errors"] == []
resources = {row["Code"]: row for row in output["rows"]}
assert resources["M"]["quantity"] == 9
assert resources["M"]["amount"] == 900
assert resources["L"]["quantity"] == 8
assert resources["L"]["amount"] == 800
def test_resource_expansion_selects_nested_head_component_and_all_conversions():
result = run(
"math",
r"""(()=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:100}];
p.tables.LaborcDat=[{Code:'L',Name:'labor',ValueIndex:4,InputTC:100}];
p.tables.HeadMainDat=[{Index:1,Code:'H',Name:'head'},
{Code:'M',LinkIndex:5,GN:1},{Code:'L',LinkIndex:6,GN:1}];
p.tables.HeadTitleDat=[{Code:'H',Name:'head'}];
p.tables.CostMainDat=[{Index:1,Code:'C'},
{Code:'H',LinkIndex:103,LineData:' \x0ehead\x0e'},
{Code:'H',LinkIndex:103,SCheck:-1,LineData:' 노무비: \x03100\x03 = \x12100\x12'},
{Code:'H',LinkIndex:103,SCheck:-2,LineData:' 재료비: \x03100\x03 = \x12100\x12'}];
p.tables.CostTitleDat=[{Code:'C'}];
p.tables.EstMainDat=[{PkID:1,Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}];
const selected=EstResources.calculate(p);
p.tables.HeadMainDat.push({LinkIndex:68,GN:0.5},{LinkIndex:68,GN:0.1});
p.tables.EstMainDat[0].Code='H';p.tables.EstMainDat[0].LinkIndex=103;
return {selected,converted:EstResources.calculate(p)};
})()""",
)
assert all(output["errors"] == [] for output in result.values())
assert len(result["selected"]["rows"]) == 2
assert all(
row["quantity"] == 10 and row["amount"] == 1000 for row in result["selected"]["rows"]
)
assert all(
row["quantity"] == 0.5 and row["amount"] == 50 for row in result["converted"]["rows"]
)
def test_resource_expansion_includes_money_only_rows_and_percentage_costs():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.EstMainDat=[{PkID:1,Name:'fixed',GN:0,NC:3,GC:7,JC:2,TC:12,CalcIndex:1},{LinkIndex:54}];
const fixed=EstResources.calculate(p);
const q=EstNative.empty();q.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:100}];
q.tables.HeadMainDat=[{Index:1,Code:'H'},{Code:'M',LinkIndex:5,GN:1},
{LinkIndex:58,Size:'합계',SCheck:3,GN:10}];q.tables.HeadTitleDat=[{Code:'H'}];
q.tables.EstMainDat=[{Code:'H',LinkIndex:103,GN:2},{LinkIndex:54}];
return {fixed,percentage:EstResources.calculate(q)};
})()""",
)
assert all(output["errors"] == [] for output in result.values())
fixed = {row["Code"]: row for row in result["fixed"]["rows"]}
assert fixed["LZZZZZZZZZZ"]["amount"] == fixed["LZZZZZZZZZZ"]["quantity"] == 3
assert fixed["MZZZZZZZZZZ"]["amount"] == fixed["MZZZZZZZZZZ"]["quantity"] == 7
assert fixed["EZZZZZZZZZZ"]["amount"] == 2
assert fixed["EZZZZZZZZZZ"]["Unite"] == "제외금액"
assert sum(row["amount"] for row in result["percentage"]["rows"]) == 220
def test_cost_code_refresh_without_name_marker_still_tracks_the_actual_resource():
result = run(
"math",
r"""(()=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:150}];
p.tables.CostMainDat=[{Index:1,Code:'C'},
{Code:'M',LinkIndex:5,SCheck:-2,LineData:' 재료비: \x03100\x03 ×2 = \x12200\x12'}];
p.tables.CostTitleDat=[{Code:'C'}];
p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:1},{LinkIndex:54}];
return {calculated:EstNative.calculate(p),resources:EstResources.calculate(p)};
})()""",
)
assert result["calculated"]["calculation"]["errors"] == []
assert result["calculated"]["calculation"]["direct"]["TC"] == 300
assert result["resources"]["errors"] == []
assert result["resources"]["rows"][0]["Code"] == "M"
assert result["resources"]["rows"][0]["quantity"] == 2
assert result["resources"]["rows"][0]["amount"] == 300
def test_unlinked_text_price_does_not_inherit_the_previous_rows_source():
result = run(
"math",
r"""(()=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:150}];
p.tables.CostMainDat=[{Index:1,Code:'C'},
{Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x12100\x12'},
{Code:'M',LinkIndex:0,SCheck:-2,LineData:' 직접입력: \x0350\x03 ×2 = \x12100\x12'}];
p.tables.CostTitleDat=[{Code:'C'}];
p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:1},{LinkIndex:54}];
return {calculated:EstNative.calculate(p),resources:EstResources.calculate(p)};
})()""",
)
assert result["calculated"]["calculation"]["errors"] == []
assert result["calculated"]["calculation"]["direct"]["TC"] == 250
resources = {row["Code"]: row for row in result["resources"]["rows"]}
assert resources["M"]["quantity"] == 1
assert resources["M"]["amount"] == 150
assert resources["MZZZZZZZZZZ"]["amount"] == 100
def test_change_comparison_uses_original_file_values_without_overwriting_a_baseline():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'R',GN:3,TC:150,GN_:2,TC_:100},
{PkID:2,Code:'Z',GN:1,TC:10,GN_:0,TC_:0},
{PkID:3,Code:'N',GN:1,TC:20}];
const stored=EstBusiness.changes(p);
p.baseline={name:'explicit',tables:{EstMainDat:[{PkID:1,Code:'R',GN:4,TC:200}]}};
return {stored,explicit:EstBusiness.changes(p),unchanged:p.tables.EstMainDat[0].TC_};
})()""",
)
assert result["stored"][0]["originalGN"] == 2
assert result["stored"][0]["originalTC"] == 100
assert result["stored"][0]["difference"] == 50
assert result["stored"][0]["originalSource"] == "file"
assert not result["stored"][1]["NewItem"]
assert result["stored"][2]["NewItem"]
assert result["explicit"][0]["originalTC"] == 200
assert result["explicit"][0]["difference"] == -50
assert result["explicit"][1]["NewItem"]
assert result["unchanged"] == 100
def test_original_numeric_fields_reject_invalid_saved_values():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{GN:1,GN_:'invalid'}];
try {EstModel.validate(p);return false;}catch(error){return error.message;}
})()""",
)
assert "GN_" in result
def test_baseline_keeps_current_inputs_once_and_preserves_working_binaries():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.CostMainDat=[{Index:1,Code:'C',LineData:'current',LineData_:'previous'}];
p.tables.CadMainDat=[{Code:'D',Data:{binary:'QUJD'}}];
p.tables.StuffcDat=[{Code:'M',InputTC:100}];p.originalArchive='original bytes';
const q=EstBusiness.baseline(p),again=EstBusiness.baseline(q);
return {p,q,again};
})()""",
)
for item in [result["q"], result["again"]]:
assert item["originalArchive"] == "original bytes"
assert item["tables"]["CadMainDat"][0]["Data"]["binary"] == "QUJD"
assert "CadMainDat" not in item["baseline"]["tables"]
assert "StuffcDat" not in item["baseline"]["tables"]
assert item["baseline"]["tables"]["CostMainDat"][0]["LineData"] == "current"
assert "LineData_" not in item["baseline"]["tables"]["CostMainDat"][0]
assert result["p"]["tables"]["CostMainDat"][0]["LineData_"] == "previous"
def test_large_original_baseline_stays_within_the_save_request_limit():
import os
from pathlib import Path
import pytest
archive = Path(
os.environ.get("EST_REA_MAIN", "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
)
if not archive.is_file():
pytest.skip("Original main.estx is not installed")
result = run("baseline-size", str(archive))
assert result["archivePreserved"]
assert result["bytes"] < 256 * 1024 * 1024
+48
View File
@@ -0,0 +1,48 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_large_edit_history_is_compressed_and_restores_independent_snapshots():
script = r"""
const fs=require('node:fs'),vm=require('node:vm');
const context=vm.createContext({Response,Blob,CompressionStream,DecompressionStream,
document:{addEventListener(){}},console});
vm.runInContext(fs.readFileSync(process.argv[1],'utf8')+';globalThis.app=EstApp',context);
(async()=>{
const {app}=context;
app.state.project={name:'before',binary:'x'.repeat(11*1024*1024)};
app.remember();
app.state.project.name='after';
const entry=await app.state.history[0],restored=await app.restore(entry);
if(!(entry instanceof Blob)||entry.size>=100000)throw Error('History is not compressed');
if(restored.name!=='before'||restored.binary.length!==11*1024*1024)
throw Error('Snapshot was changed or truncated');
restored.name='independent';
if((await app.restore(entry)).name!=='before')throw Error('Snapshot shares mutable data');
app.state.project={name:'small'};
app.state.future=['discard'];
for(let i=0;i<10;i++){app.state.project.name=String(i);app.remember();}
if(app.state.history.length!==8||app.state.future.length)throw Error('History limits');
if((await app.restore(app.state.history[0])).name!=='2')throw Error('History ordering');
context.CompressionStream=undefined;
app.state.project={binary:'y'.repeat(11*1024*1024)};
const fallback=app.snapshot();
if(typeof fallback!=='string')throw Error('Missing platform fallback');
if((await app.restore(fallback)).binary.length!==11*1024*1024)
throw Error('Fallback truncated');
console.log(JSON.stringify({compressedBytes:entry.size,revision:app.state.revision}));
})().catch(error=>{console.error(error);process.exitCode=1});
"""
result = json.loads(
subprocess.check_output(
["node", "-e", script, str(ROOT / "tools/est-test/native-app.js")],
text=True,
encoding="utf-8",
)
)
assert result["compressedBytes"] < 100_000
assert result["revision"] == 11
+114 -2
View File
@@ -432,9 +432,9 @@ def test_text_material_only_subtotal_does_not_reuse_prior_labor():
assert result["rows"][6]["TC"] == 30
def test_original_sea_forest_and_temporary_works_text_layouts():
def test_original_text_layouts_and_selected_group_subtotals():
results = run("text-layout")
assert len(results) == 3
assert len(results) == 4
checked = 0
for result in results:
assert result["errors"] == [], result["code"]
@@ -536,3 +536,115 @@ def test_original_cost_truncation_and_intermediate_precision_options():
assert result["override"]["rows"][1]["TC"] == 100.19
assert result["mid1"]["checks"][-1]["calculated"] == 721.5
assert result["mid2"]["checks"][-1]["calculated"] == 721.51
def test_source_price_refresh_recalculates_text_subtotal_and_percentage_reference():
result = run(
"math",
r"""(()=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:150}];
p.tables.CostMainDat=[{Index:1,Code:'C'},
{Level:1,LineData:' material section'},
{Index:0,Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 ×2 = \x12200\x12'},
{Index:-20,LinkIndex:61,SCheck:-2,LineData:' material subtotal: \x0c200\x0c'},
{LinkIndex:58,SCheck:-3,OCheck:-2,LineData:' \x0c200\x0c ×5% = \x1210\x12'},
{Level:1,LineData:' total'},
{Level:-1,SCheck:-2,LineData:' \x1a재료비:200\x1a'},
{Level:-1,SCheck:-3,LineData:' \x1a경비:10\x1a'},
{Level:-1,SCheck:-5,LineData:' \x1a계:\x1a \x12210\x12'}];
p.tables.CostTitleDat=[{Code:'C'}];
return EstNative.calculate(p);
})()""",
)
assert result["calculation"]["errors"] == []
rows = result["tables"]["CostMainDat"]
assert rows[2]["TC"] == 300
assert rows[3]["TC"] == 300
assert "\x0c300\x0c" in rows[3]["LineData"]
assert rows[4]["TC"] == 15
assert rows[0]["TC"] == 315
def test_selected_text_group_uses_distinct_rows_with_equal_original_amounts():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'C'},
{Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x06A\x06\x12100\x12'},
{Code:'L',LinkIndex:6,SCheck:-1,LineData:' \x0elabor\x0e: \x03100\x03 = \x06A\x06\x12100\x12'},
{LinkIndex:62,SCheck:-3,LineData:' \x07A\x07\x1a100 + 100\x1a = \x12200\x12 ×10% = \x1220\x12'}
],{},row=>row.Code==='M'?{GC:150,TC:150}:{NC:200,TC:200})""",
)
assert result["errors"] == []
assert result["rows"][3]["TC"] == 35
assert "150 + 200" in result["rows"][3]["LineData"]
def test_text_bid_rate_row_preserves_native_component_walk_and_currency_precision():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'C'},
{Level:1,LineData:' bid section'},
{SCheck:-1,LineData:' 노무비: 100 = \x12100\x12'},
{SCheck:-1,LineData:' 노무비: 50 = \x1250\x12'},
{SCheck:-2,LineData:' 재료비: 20 = \x1220\x12'},
{SCheck:-2,LineData:' 재료비: 30 = \x1230\x12'},
{SCheck:-3,LineData:' 경비: 12.3456 = \x1212.3456\x12',Precision:4},
{LinkIndex:52,LineData:' 낙찰률 \x1590\x15 %'}
])""",
)
assert result["errors"] == []
assert result["rows"][-1]["NC"] == result["rows"][0]["NC"] == 90
assert result["rows"][0]["GC"] == 45
assert result["rows"][0]["JC"] == 11.111
assert result["rows"][0]["TC"] == 146.111
def test_text_group_markers_are_editable_and_missing_group_is_reported():
result = run(
"math",
r"""(()=>{
const line='\x06A\x06\x12100\x12 + \x07A\x07';
return {shown:EstText.display(line),roundtrip:EstText.encode(EstText.display(line))===line,
missing:EstText.calculate([{Index:1,Code:'C'},
{LinkIndex:62,SCheck:-3,LineData:'\x07A\x07\x1a100\x1a = \x12100\x12 ×10% = \x1210\x12'}])};
})()""",
)
assert result["roundtrip"]
assert "⟦구간지정⟧A⟦구간지정⟧" in result["shown"]
assert "⟦구간합계⟧A⟦구간합계⟧" in result["shown"]
assert len(result["missing"]["errors"]) == 1
assert "A" in result["missing"]["rows"][1]["Error"]
def test_fixed_text_formula_clears_the_previous_row_error():
result = run(
"math",
r"""(()=>{
const bad=EstText.calculate([{Index:1,Code:'C'},
{LineData:' Q=(1+2 = \x123\x12'}]);
bad.rows[1].LineData=' Q=1+2 = \x123\x12';
return {bad:bad.errors,good:EstText.calculate(bad.rows)};
})()""",
)
assert len(result["bad"]) == 1
assert result["good"]["errors"] == []
assert "Error" not in result["good"]["rows"][1]
def test_text_total_without_result_marker_evaluates_its_expression():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'C'},
{Level:-1,SCheck:-1,LineData:' \x1a노무비:10 + 10\x1a'},
{Level:-1,SCheck:-2,LineData:' \x1a재료비:\x1a'},
{Level:-1,SCheck:-5,LineData:' \x1a계:\x1a \x1220\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][0]["TC"] == 20
assert result["rows"][1]["TC"] == 20
assert result["rows"][2]["TC"] == 0
+137
View File
@@ -0,0 +1,137 @@
"""원본 가시설·측량설계 산근에서 발췌한 단위와 설명 열의 계산 시험."""
from test_est_rea_parity import run
def test_text_units_and_description_columns_preserve_numeric_assignments():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'units',LineData:'units'},
{LineData:' G2 = 3.14 / 4 ×0.105^2 ×1.50M × 2배 = \x120.026\x12 ㎥',Precision:3},
{LineData:' N = 2 set'},
{LineData:' 1. 재료비 : D=16mm, 1본당 4.0m'},
{LineData:' Q = D × N = \x1232\x12'},
{LineData:' V = G2 × 1000 = \x1226\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][1]["TC"] == 0.026
assert result["rows"][4]["TC"] == 32
assert result["rows"][5]["TC"] == 26
def test_text_unit_changes_propagate_without_reusing_stored_results():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'changed',LineData:'changed'},
{LineData:' N = 3 set'},
{LineData:' 1. 재료비 : D=25mm, 1본당 4.0m'},
{LineData:' Q = D × N = \x1232\x12'},
{LineData:' V = 1.5M × 3배 = \x123\x12',Precision:2}
])""",
)
assert result["errors"] == []
assert result["rows"][3]["TC"] == 75
assert result["rows"][4]["TC"] == 4.5
def test_per_set_unicode_millimetres_and_starred_weight_declarations():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'source_units',LineData:'source_units'},
{LineData:' * 본당 중량 W = 0.553톤/본*'},
{LineData:' T = 20㎜ A = 0.95 × 0.95 = \x120.9\x12㎡',Precision:1},
{LineData:' 재료비 : \x031,690\x03 × 0.005kg/set = \x128.4\x12',SCheck:-2},
{LineData:' Q = W × T = \x1211.06\x12',Precision:2}
])""",
)
assert result["errors"] == []
assert result["rows"][2]["TC"] == 0.9
assert result["rows"][3]["TC"] == 8.4
assert result["rows"][4]["TC"] == 11.06
def test_parenthesized_volume_and_count_units_recalculate_from_variables():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'volume',LineData:'volume'},
{LineData:' A = 80㎡ B = 16㎡'},
{LineData:' 새끼 : L =0.05m × 4배 × 4번 × 1줄 = \x120.8\x12m',Precision:1},
{LineData:' 노무비 : \x0358,122\x03 × 0.1 × (A+B)㎥ = 58,122 × 0.1 × (80+16) = \x12557,971.2\x12',SCheck:-1},
{LineData:' Q = L × (A+B)㎡ = \x1276.8\x12',Precision:1}
])""",
)
assert result["errors"] == []
assert result["rows"][2]["TC"] == 0.8
assert result["rows"][3]["TC"] == 557971.2
assert result["rows"][4]["TC"] == 76.8
def test_quoted_source_conversion_declaration_propagates_changed_value():
result = run(
"math",
r"""[2,3].map(quantity=>EstText.calculate([
{Index:1,Code:'quote',LineData:'quote'},
{LineData:` * 중량환산 : 사석기준 'Q1 = ${quantity}.0 톤/㎥'`},
{LineData:' 노무비 : \x03235,204\x03 ×1.04인×Q1 = 235,204 ×1.04×2 = \x12489,224.3\x12',SCheck:-1}
]))""",
)
assert all(item["errors"] == [] for item in result)
assert [item["rows"][2]["TC"] for item in result] == [489224.3, 733836.4]
def test_group_sum_accepts_assigned_price_and_refreshes_from_source():
result = run(
"math",
r"""[550,600].map(price=>{
const p=EstNative.empty();p.options.SourceRefresh=true;
p.tables.StuffcDat=[{Code:'M',ValueIndex:4,InputTC:price}];
p.tables.CostMainDat=[
{Index:1,Code:'C',LineData:'C'},
{LinkIndex:5,Code:'M',LineData:' \x0ebrick\x0e \x06A\x06\x03550\x03 : 별산'},
{LinkIndex:62,SCheck:-1,LineData:' \x07A\x07\x1a550\x1a × 5% = \x1227.5\x12'},
{SCheck:-1,LineData:' \x1a노무비:27\x1a'},
{SCheck:-5,LineData:' \x1a계:\x1a \x1227\x12'}];
p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}];
return EstNative.calculate(p);
})""",
)
assert all(item["calculation"]["errors"] == [] for item in result)
assert [item["tables"]["CostMainDat"][2]["TC"] for item in result] == [27.5, 30]
assert [item["tables"]["EstMainDat"][0]["TC"] for item in result] == [270, 300]
def test_subtotal_includes_price_only_rows_and_updates_truncated_final_amount():
result = run(
"math",
r"""[1094,2000].map(price=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'T',ValueIndex:4,InputTC:110754},{Code:'H',ValueIndex:4,InputTC:price}];
p.tables.CostMainDat=[
{Index:1,Code:'C',LineData:'transport'},
{Level:2,LineData:'1. 운반 및 하역'},
{LinkIndex:5,Code:'T',LineData:' \x0etruck\x0e (\x03110,754\x03 ÷12) ×1.5 = \x1213,844.2\x12'},
{LinkIndex:5,Code:'H',LineData:' \x0eunloading\x0e \x031,094\x03'},
{LinkIndex:61,SCheck:-3,TC:14938.2,LineData:' 경비소계 : \x0c14,938.2\x0c'},
{Level:2,LineData:'2. 합계'},
{Level:-2,SCheck:-3,LineData:' \x1a경비:14,938\x1a'},
{Level:-2,SCheck:-5,LineData:' \x1a계:\x1a \x1214,938\x12'}];
p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}];
const first=EstNative.calculate(p),again=EstNative.calculate(first);
return {first,again};
})""",
)
assert all(item["first"]["calculation"]["errors"] == [] for item in result)
assert [item["first"]["tables"]["CostMainDat"][4]["TC"] for item in result] == [
14938.2,
15844.2,
]
for key in ["first", "again"]:
assert [item[key]["tables"]["EstMainDat"][0]["TC"] for item in result] == [
149380,
158440,
]
+21 -1
View File
@@ -8,6 +8,12 @@
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. ESTX는 읽기 전용으로 가져오며 원본 압축 파일과 바이너리를 보존합니다. 편집 결과를 원본 ESTX로 다시 쓰는 기능은 없습니다.
Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다.
큰 문서의 실행 취소도 최근 8단계를 보관합니다. 10MB 이상 이력은 브라우저 기본 압축 기능을 사용하며, 복원 중 추가 입력이나 문서 전환이 생기면 이전 내용을 덮어쓰지 않습니다.
당초 확정 사본에는 재계산 입력을 한 번만 보관하며, 작업본의 도면·원본 압축 자료는 보존합니다. 설치된 `main.estx`의 당초 확정 후 실제 저장 API를 통과했고, 산근 77,395행과 원본 압축 해시 보존·계산 오류 0을 확인했습니다(요청 약 215MB, 저장 약 221MB).
초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다.
## 검증과 남은 범위
@@ -22,12 +28,26 @@
계산 환경에서 원본 `CostValueTrunc`(성분 금액 원 미만 절사)와 `MidCostDiget`(중간금액 소수자리)을 제어합니다. 원천·호표 코드를 바꾸면 작업본 연결도 함께 바뀌며, 당초 계약 자료는 보존됩니다. 해양·산림·가시설의 분수 배치와 구획 소계 원본 발췌를 회귀 시험에 포함합니다. 전체 해양 자료는 133,710개 저장값 중 133,635개 일치, 수식 해석 오류 99건이 남아 있습니다. 저장 결과 대조만으로 원본 실행 동등성을 판정하지 않습니다.
단가 변경은 산근 소계(61), 비목 참조 비율(58), 선택 구간 합계(62)를 거쳐 내역으로 전달됩니다. 산출근거 편집기의 `구간지정`·`구간합계` 표지로 A~Z 묶음을 제어합니다. 소계 표지 1,433개를 추가한 대용량 원본 대조는 115,145개 일치·해석 오류 0이며, 원본 산근 낙찰 행(52)의 성분 계산과 연결 편집도 회귀 시험합니다.
연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다.
동원 집계는 비목별 낙찰률과 단위환산을 하위 산근·일위대가에도 전달합니다. 연결 단가가 없는 금액 입력분은 원본 전용 코드로 따로 합산하고, 장비 시간으로 바꿀 수 없는 경비는 `제외금액` 단위로 보존합니다. 산근에서 연결을 해제한 단가는 앞 행의 원천을 가져오지 않습니다.
설계변경 비교는 웹에서 확정한 당초 사본이 없을 때 원본 파일의 당초 수량·금액을 사용합니다. 별도 당초 확정 사본이 있으면 그 사본이 우선합니다. 원본 당초 전체 자료와 원가 서식의 활성 선택을 자동 추정하지는 않습니다.
원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표·따옴표로 감싼 변수 선언도 계산합니다. 측량설계 저장값 대조는 80,659/80,682 일치·해석 오류 5건, 가시설은 13,978/13,979 일치·해석 오류 4건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다.
구간 합계와 소계는 결과 금액뿐 아니라 단가만 적힌 지정 행도 포함합니다. 구간 합계의 표지와 최종 합계 표지를 구분하며, 이미 정수로 절사된 최종 합계도 앞 성분 금액과의 합산 관계가 확인되면 변경액을 다시 집계합니다. 단가 550→600 변경이 구간 비율 27.5→30, 최종 합계 27→30, 내역 270→300으로 전달되는 것을 저장·취소까지 확인했습니다.
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 99건입니다. 계산·이력 전용 시험 64개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
```powershell
./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_standalone.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 -q
```
`native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
+28 -13
View File
@@ -1,6 +1,7 @@
const EstActions = (() => {
let pending = null,
clipboard = [];
clipboard = [],
restoring = false;
const uid = () => globalThis.crypto.randomUUID();
function form(title, fields, callback) {
const { esc, showDialog } = EstApp;
@@ -27,16 +28,31 @@ const EstActions = (() => {
if (!rows) throw Error("작업 표를 먼저 선택하세요.");
return rows;
}
function undo(redo = false) {
async function undo(redo = false) {
const { state } = EstApp,
source = redo ? state.future : state.history,
target = redo ? state.history : state.future;
if (!source.length) return;
target.push(JSON.stringify(state.project));
state.project = JSON.parse(source.pop());
state.selection.clear();
EstApp.cache();
EstApp.render();
if (!source.length || restoring) return;
const entry = source.at(-1),
revision = state.revision,
documentKey = state.documentKey;
restoring = true;
try {
const project = typeof entry === "string" ? JSON.parse(entry) : await EstApp.restore(entry);
if (revision !== state.revision || documentKey !== state.documentKey) return;
target.push(EstApp.snapshot());
if (target.length > 8) target.shift();
source.pop();
const id = state.project.id;
state.project = project;
if (id) state.project.id = id;
state.revision++;
state.selection.clear();
EstApp.cache();
EstApp.render();
} finally {
restoring = false;
}
}
function insertRow() {
const rows = activeRows(),
@@ -128,8 +144,8 @@ const EstActions = (() => {
EstApp.setProject(await api("/api/sample"));
} else if (name === "open") EstApp.byId("open-file").click();
else if (name === "save" || name === "save-as") await EstApp.save(name === "save-as");
else if (name === "undo") undo();
else if (name === "redo") undo(true);
else if (name === "undo") await undo();
else if (name === "redo") await undo(true);
else if (name === "add-row") insertRow();
else if (name === "add-group") insertGroup();
else if (name === "delete-row") deleteRows();
@@ -247,7 +263,7 @@ const EstActions = (() => {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
"산출근거 편집",
`<p>품명·규격·단가·변수·결과·합계 표지를 짝으로 지정합니다.</p><div class="marker-tools">${["품명", "규격", "단위", "단가", "변수", "다른줄", "결과", "합계"].map((m) => `<button data-marker="${m}">${m}</button>`).join("")}</div><textarea id="line-editor" rows="10">${esc(EstText.display(row.LineData))}</textarea><button data-action="line-apply" data-row="${node.dataset.row}">적용</button>`,
`<p>표지는 짝으로 지정합니다. 구간지정 A~Z는 결과 앞에, 구간합계는 합산할 같은 문자를 감쌉니다.</p><div class="marker-tools">${["품명", "규격", "단위", "단가", "변수", "다른줄", "결과", "합계", "구간지정", "구간합계"].map((m) => `<button data-marker="${m}">${m}</button>`).join("")}</div><textarea id="line-editor" rows="10">${esc(EstText.display(row.LineData))}</textarea><button data-action="line-apply" data-row="${node.dataset.row}">적용</button>`,
);
} else if (name === "line-apply") {
const text = EstApp.byId("line-editor").value;
@@ -393,8 +409,7 @@ const EstActions = (() => {
}, false);
} else if (name === "add-annual")
EstApp.change(
() =>
project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[계약수량]" }),
() => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }),
false,
);
else if (name === "open-annual") {
+62 -19
View File
@@ -11,7 +11,10 @@ const EstApp = (() => {
future: [],
notice: "",
business: null,
revision: 0,
documentKey: 0,
};
let saveQueue = Promise.resolve();
const titles = {
EstMainDat: "내역서",
HeadMainDat: "일위대가",
@@ -45,22 +48,36 @@ const EstApp = (() => {
if (!response.ok) throw Error(data.error || `요청 실패: ${response.status}`);
return data;
}
function cache() {
function cache(serialized) {
try {
sessionStorage.setItem(CACHE, JSON.stringify(state.project));
sessionStorage.setItem(CACHE, serialized ?? JSON.stringify(state.project));
} catch {
state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요.";
}
}
function remember() {
function snapshot() {
const serialized = JSON.stringify(state.project);
if (serialized.length < 10 * 1024 * 1024) {
state.history.push(serialized);
if (state.history.length > 8) state.history.shift();
}
if (serialized.length < 10 * 1024 * 1024 || !globalThis.CompressionStream) return serialized;
return new Response(new Blob([serialized]).stream().pipeThrough(new CompressionStream("gzip")))
.blob()
.catch(() => serialized);
}
async function restore(snapshot) {
const saved = await snapshot;
return JSON.parse(
typeof saved === "string"
? saved
: await new Response(saved.stream().pipeThrough(new DecompressionStream("gzip"))).text(),
);
}
function remember() {
state.revision++;
state.history.push(snapshot());
if (state.history.length > 8) state.history.shift();
state.future = [];
}
function calculate() {
state.revision++;
try {
state.project = EstNative.calculate(EstModel.validate(state.project));
} catch (error) {
@@ -76,6 +93,8 @@ const EstApp = (() => {
}
function setProject(project) {
state.project = EstModel.legacy(project);
state.documentKey++;
state.revision++;
state.history = [];
state.future = [];
state.selection.clear();
@@ -180,18 +199,40 @@ const EstApp = (() => {
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
async function save(asNew = false) {
if (asNew) delete state.project.id;
const result = await api("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state.project),
});
state.project = result;
cache();
state.notice = "저장했습니다. 이 앱 폴더의 data에도 작업이 보관됩니다.";
render();
download(`${state.project.name || "내역서"}.estweb.json`, JSON.stringify(result, null, 2));
function save(asNew = false) {
document.activeElement?.blur?.();
if (asNew) {
delete state.project.id;
state.documentKey++;
}
state.project.id ||= [...crypto.getRandomValues(new Uint8Array(12))]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
const documentKey = state.documentKey,
revision = state.revision,
body = JSON.stringify(state.project);
cache(body);
const operation = saveQueue
.catch(() => {})
.then(async () => {
const result = await api("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (state.documentKey === documentKey) {
if (state.revision === revision) state.project = result;
cache();
state.notice =
state.revision === revision
? "저장했습니다. 이 앱 폴더의 data에도 작업이 보관됩니다."
: "요청 시점의 작업을 저장했습니다. 이후 입력은 다시 저장하세요.";
render();
}
download(`${result.name || "내역서"}.estweb.json`, JSON.stringify(result, null, 2));
});
saveQueue = operation;
return operation;
}
async function open(file) {
if (!file) return;
@@ -238,6 +279,8 @@ const EstApp = (() => {
byId,
api,
cache,
snapshot,
restore,
remember,
calculate,
change,
+49 -8
View File
@@ -5,8 +5,29 @@ const EstBusiness = (() => {
const rowKey = (row, index) => String(row.PkID ?? row.Code ?? index);
const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex));
function baseline(project) {
const next = copy(project);
next.baseline = { tables: copy(project.tables), name: project.name };
const working = { ...project };
delete working.baseline;
const next = copy(working);
// Original recalculation replaces source catalogs from the working project.
// Keep its contract inputs once; drawings and pre-existing '_' copies stay in the working file.
const tables = {};
for (const table of [
"EstMainDat",
"HeadMainDat",
"HeadTitleDat",
"CostMainDat",
"CostTitleDat",
"MachTitleDat",
"Wonga",
]) {
tables[table] = (project.tables[table] || []).map((row) => {
const values = Object.fromEntries(
Object.entries(row).filter(([key]) => !key.endsWith("_")),
);
return copy(values);
});
}
next.baseline = { tables, name: project.name };
for (const rows of Object.values(next.baseline.tables))
for (const row of rows) row.NewItem = false;
for (const rows of Object.values(next.tables))
@@ -34,7 +55,12 @@ const EstBusiness = (() => {
function changes(project) {
const old = project.baseline?.tables.EstMainDat || [];
return project.tables.EstMainDat.map((row, index) => {
const original = old.find((r, i) => rowKey(r, i) === 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_ }
: null;
const original = saved || stored;
return {
key: rowKey(row, index),
Code: row.Code,
@@ -45,6 +71,7 @@ const EstBusiness = (() => {
originalTC: num(original?.TC),
difference: num(row.TC) - num(original?.TC),
NewItem: !original,
originalSource: saved ? "baseline" : stored ? "file" : null,
};
});
}
@@ -336,11 +363,16 @@ const EstBusiness = (() => {
function annual(project) {
const errors = [],
results = [];
const names = (project.annual || []).map((year) => String(year.name || ""));
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}`;
next.progress = [];
next.annual = [];
next.options.TotalFix = false;
next.options.SourceRefresh = false;
for (let index = 0; index < next.tables.EstMainDat.length; index++) {
const row = next.tables.EstMainDat[index];
if (num(row.LinkIndex) === 57) {
@@ -351,11 +383,20 @@ const EstBusiness = (() => {
}
if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue;
try {
row.GN = EstExpression.evaluate(year.formula || "[계약수량]", (name) => {
if (name === "계약수량") return num(project.tables.EstMainDat[index].GN);
const previous = results.find((r) => r.name === name);
return previous ? num(previous.project.tables.EstMainDat[index]?.GN) : undefined;
});
// f0f9b8/f1098c resolve the same column formula against each current field.
const value = (field) =>
EstExpression.evaluate(year.formula || "[기준]", (name) => {
if (["기준", "계약", "계약수량"].includes(name))
return num(project.tables.EstMainDat[index][field]);
const previous = results.find((result) => result.name === name);
return previous ? num(previous.project.tables.EstMainDat[index]?.[field]) : undefined;
});
row.GN = value("GN");
components.forEach((key) => (row[key] = Math.trunc(value(key))));
row.TC = sum(row);
units.forEach((key) => (row[key] = 0));
row.TU = 0;
row.CalcIndex = num(row.CalcIndex) >= 2 ? 3 : 1;
} catch (error) {
errors.push(`${year.name}: ${error.message}`);
}
+5 -3
View File
@@ -75,7 +75,9 @@ const EstExtra = (() => {
),
project.baseline
? `당초: ${esc(project.baseline.name)}. 당초 자료는 별도 사본으로 보존합니다.`
: "현재 작업을 저장한 뒤 당초 내역을 확정하세요.",
: changes.some((row) => row.originalSource === "file")
? "원본 파일에 저장된 당초 수량·금액과 비교합니다."
: "현재 작업을 저장한 뒤 당초 내역을 확정하세요.",
);
} else if (view === "progress") {
state.roundId ||= project.progress[0]?.id;
@@ -222,7 +224,7 @@ const EstExtra = (() => {
"연차공사",
button("add-annual", "차수 추가"),
table(
["차수", "수량 배분 수식", "순공사비", "도급액", "작업", ""],
["차수", "수량·금액 배분 수식", "순공사비", "도급액", "작업", ""],
project.annual.map((year, index) => [
field("annual", index, "name", year.name, "text"),
field("annual", index, "formula", year.formula, "text"),
@@ -232,7 +234,7 @@ const EstExtra = (() => {
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
]),
),
`수식 예: [계약수량]*0.5, [계약수량]-[1차]. ${esc(output.errors.join(" / "))}`,
`수식 예: [기준]*0.5, [기준]-[1차]. 같은 수식을 수량과 각 성분 금액에 따로 적용하고 금액은 원 미만 절사합니다. ${esc(output.errors.join(" / "))}`,
);
} else if (view === "resources") {
const output = EstResources.calculate(project);
+1 -1
View File
@@ -269,7 +269,7 @@ const EstModel = (() => {
if (["__proto__", "constructor", "prototype"].includes(key))
throw Error("허용하지 않는 필드 이름");
if (
numeric.has(key) &&
(numeric.has(key) || (key.endsWith("_") && numeric.has(key.slice(0, -1)))) &&
value != null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
+111 -26
View File
@@ -32,7 +32,55 @@ const EstResources = (() => {
if (kind === 105 && quantity === 0 && amount !== null) row.amountForHours += amount;
output.set(id, row);
}
function expand(kind, code, multiplier, path) {
function bidComponents(row, source) {
const classified = [1, 2, 3].includes(num(row.SCheck));
const whole = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
return Object.fromEntries(
components.map((key, index) => [
key,
classified
? whole
: row.ApplyNacChal && num(source?.[key])
? num(row[EstMath.units[index]]) / num(source[key])
: 1,
]),
);
}
function weightedRate(source, rates) {
return num(source?.TC)
? components.reduce((amount, key) => amount + num(source[key]) * rates[key], 0) /
num(source.TC)
: 1;
}
function money(row, multiplier, rates) {
const labels = {
NC: [6, "LZZZZZZZZZZ", "노무비"],
GC: [5, "MZZZZZZZZZZ", "재료비"],
JC: [105, "EZZZZZZZZZZ", "경비"],
SC: [21, "SZZZZZZZZZZ", "표준시장단가"],
};
for (const key of components) {
const amount = num(row[key]) * multiplier * rates[key];
if (!amount) continue;
const [kind, Code, Name] = labels[key],
id = `${kind}:${Code}`;
const item = output.get(id) || {
Code,
Name,
Size: "금액 입력분",
Unite: key === "JC" ? "제외금액" : "원",
kind,
quantity: 0,
amount: 0,
amountForHours: 0,
};
item.amount += amount;
if (key !== "JC") item.quantity += amount;
output.set(id, item);
}
}
function expand(kind, code, multiplier, path, componentRates = null) {
const rates = componentRates || Object.fromEntries(components.map((key) => [key, 1]));
const id = `${kind}:${code}`;
if (path.includes(id)) {
errors.push(`동원 순환 참조: ${id}`);
@@ -45,20 +93,39 @@ const EstResources = (() => {
return;
}
for (const row of group.rows) {
if (!row.ResourceCode || row.ResourceFactor == null) continue;
if (!row.ResourceCode || row.ResourceFactor == null) {
const key = { [-1]: "NC", [-2]: "GC", [-3]: "JC", [-4]: "SC" }[num(row.SCheck)];
if (key && !String(row.LineData || "").includes("\x1a") && num(row.LinkIndex) !== 61)
money({ [key]: num(row.TC) }, multiplier, rates);
continue;
}
const source = tables[EstNative.sourceTables[num(row.ResourceKind)]]?.find(
(item) => item.Code === row.ResourceCode,
);
const directKey = row.ResourceComponent;
const rate = directKey ? rates[directKey] : weightedRate(source, rates);
if (!rate) continue;
if ([103, 104].includes(num(row.ResourceKind)))
expand(num(row.ResourceKind), row.ResourceCode, multiplier * num(row.ResourceFactor), [
...path,
id,
]);
expand(
num(row.ResourceKind),
row.ResourceCode,
multiplier * num(row.ResourceFactor),
[...path, id],
Object.fromEntries(
components.map((key) => [
key,
!row.ResourceComponent || row.ResourceComponent === key ? rates[key] : 0,
]),
),
);
else if (num(row.ResourceKind) === 105)
record(105, row.ResourceCode, 0, multiplier * num(row.TC));
record(105, row.ResourceCode, 0, multiplier * num(row.TC) * rate);
else
record(
num(row.ResourceKind),
row.ResourceCode,
multiplier * num(row.ResourceFactor),
multiplier * num(row.TC),
multiplier * num(row.ResourceFactor) * rate,
multiplier * num(row.TC) * rate,
);
}
return;
@@ -70,43 +137,61 @@ const EstResources = (() => {
return;
}
let rate = 1;
const bid = group.rows.find((row) => num(row.LinkIndex) === 52);
if (bid) rate = num(bid.GN) / 100;
const conversion = group.rows.find((row) => num(row.LinkIndex) === 68);
if (conversion)
rate *= num(conversion.GN) / (["%", "%"].includes(conversion.Unite) ? 100 : 1);
for (const row of group.rows.slice(1)) {
if (num(row.CalcIndex) >= 2 || [51, 52, 58, 62, 68].includes(num(row.LinkIndex)))
continue;
if (num(row.LinkIndex) === 52) {
rate *= num(row.GN) / 100;
break;
}
if (num(row.LinkIndex) === 68)
rate *= num(row.GN) / (["%", "%"].includes(row.Unite) ? 100 : 1);
}
for (const row of group.rows.slice(1)) {
if (num(row.CalcIndex) >= 2 || [51, 52, 68].includes(num(row.LinkIndex))) continue;
const source = tables[EstNative.sourceTables[num(row.LinkIndex)]]?.find(
(r) => r.Code === row.Code,
);
const unitRatio = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
const bid = bidComponents(row, source);
const classified = { 1: "GC", 2: "NC", 3: "JC" }[num(row.SCheck)];
const childRates = Object.fromEntries(
components.map((key) => [
key,
bid[key] * (classified ? rates[classified] : rates[key]),
]),
);
const quantity =
(multiplier * num(row.GN) * rate * unitRatio) /
(["%", "%"].includes(row.Unite) ? 100 : 1);
(multiplier * num(row.GN) * rate) / (["%", "%"].includes(row.Unite) ? 100 : 1);
if ([103, 104].includes(num(row.LinkIndex)))
expand(num(row.LinkIndex), row.Code, quantity, [...path, id]);
else if (EstNative.sourceTables[num(row.LinkIndex)])
record(num(row.LinkIndex), row.Code, quantity, multiplier * num(row.TC) * rate);
expand(num(row.LinkIndex), row.Code, quantity, [...path, id], childRates);
else if (EstNative.sourceTables[num(row.LinkIndex)]) {
const count = quantity * weightedRate(source, childRates);
const amount =
multiplier *
rate *
components.reduce((amount, key) => amount + num(row[key]) * rates[key], 0);
if (count || amount) record(num(row.LinkIndex), row.Code, count, amount);
} else money(row, multiplier * rate, rates);
}
return;
}
record(kind, code, multiplier);
const source = tables[EstNative.sourceTables[kind]]?.find((row) => row.Code === code);
record(kind, code, multiplier * weightedRate(source, rates));
}
for (const row of tables.EstMainDat) {
if (num(row.LinkIndex) === 54) break;
if (
num(row.Level) > 0 ||
num(row.CalcIndex) >= 2 ||
!EstNative.sourceTables[num(row.LinkIndex)]
[51, 52, 68].includes(num(row.LinkIndex))
)
continue;
if (!EstNative.sourceTables[num(row.LinkIndex)]) {
money(row, 1, { NC: 1, GC: 1, JC: 1, SC: 1 });
continue;
}
const source = tables[EstNative.sourceTables[num(row.LinkIndex)]]?.find(
(r) => r.Code === row.Code,
);
const unitRatio = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
expand(num(row.LinkIndex), row.Code, num(row.GN) * unitRatio, []);
expand(num(row.LinkIndex), row.Code, num(row.GN), [], bidComponents(row, source));
}
const rows = [...output.values()];
for (const row of rows)
+166 -20
View File
@@ -10,6 +10,8 @@ const EstText = (() => {
const numeric = (s) => Number(String(s).replaceAll(",", ""));
const sumCurrency = (values) =>
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
const autoTotal = (row) =>
num(row.LinkIndex) !== 62 && String(row.LineData || "").includes("\x1a");
const stripLabels = (text) =>
String(text)
.replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\([가-힣][^()]*\)/g, "")
@@ -22,12 +24,12 @@ const EstText = (() => {
),
)
.replace(
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|μ|₩|EA|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|공|회|조|span|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|set|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|배|번|줄|μ|₩|EA|㎜|㎝|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|공|회|조|span|set|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/\s*(?:일|㎥|㎡|hr)(?=\s|$|[×*+)])/gi, "")
.replace(/[】]〕]\s*$/, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단|회)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단|회|㎥|㎡)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/^\s*[-*•]?\s*[가-힣][가-힣\s]*\s+(?=[\d.(])/, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
@@ -41,6 +43,7 @@ const EstText = (() => {
variables = Object.create(null),
changed = new Map(),
currentSource = null,
finalOverride = null,
totals = {},
finalLevel = -1,
plainTotals = {},
@@ -48,6 +51,12 @@ const EstText = (() => {
hasAutoTotals = false;
function finish() {
if (!head) return;
if (finalOverride) {
components.forEach((key) => (head[key] = num(finalOverride[key])));
head.TC = sum(head);
titles.push({ ...head });
return;
}
components.forEach(
(key) => (head[key] = !hasAutoTotals ? floating(num(plainTotals[key])) : num(totals[key])),
);
@@ -56,12 +65,17 @@ const EstText = (() => {
}
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
currentSource = null;
delete row.Error;
for (const key of ["ResourceFactor", "ResourceCode", "ResourceKind", "ResourceComponent"])
delete row[key];
if (num(row.Index) > 0) {
finish();
head = row;
variables = Object.create(null);
changed = new Map();
currentSource = null;
finalOverride = null;
totals = {};
plainTotals = {};
for (const [key, tag] of [
@@ -75,7 +89,7 @@ const EstText = (() => {
const levels = [],
componentRows = [];
for (let j = index + 1; j < rows.length && num(rows[j].Index) <= 0; j++)
if (String(rows[j].LineData || "").includes("\x1a")) levels.push(num(rows[j].Level));
if (autoTotal(rows[j])) levels.push(num(rows[j].Level));
else if (component[num(rows[j].SCheck)] && marker.test(String(rows[j].LineData || "")))
componentRows.push(j);
finalLevel = levels.length ? Math.min(...levels) : -1;
@@ -87,20 +101,119 @@ const EstText = (() => {
continue;
}
if (!head || !row.LineData) continue;
if (num(row.Level) > 0) finalOverride = null;
if ([51, 52].includes(num(row.LinkIndex))) {
if (num(row.LinkIndex) === 52) {
const rate = String(row.LineData).match(/\x15([-\d.,]+)\x15/);
const multiplier = rate ? numeric(rate[1]) / 100 : 0;
const base = Object.fromEntries(components.map((key) => [key, 0]));
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j],
key = component[num(previous.SCheck)];
if (num(previous.Level) > 0 || num(previous.Index) > 0) break;
if (!key) continue;
const values = [
...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g),
];
const value = values.length ? numeric(values.at(-1)[2]) : num(previous.TC);
// f2c680/f2c74e overwrites NC while walking backward; the other components add.
base[key] = key === "NC" ? value : base[key] + value;
}
components.forEach(
(key) => (row[key] = EstMath.even(base[key] * multiplier * 10000) / 10000),
);
row.TC = sum(row);
}
finalOverride = row;
continue;
}
const original = String(row.LineData),
matched = original.match(marker),
sc = num(row.SCheck);
const oldValue = matched ? numeric(matched[1]) : null;
if (num(row.LinkIndex) > 0 && /\x0e/.test(original)) {
if (
[5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) &&
(row.Code || /[\x0e\x03]/.test(original))
) {
const name = original.match(/\x0e([^\x0e]+)\x0e/),
size = original.match(/\x13([^\x13]+)\x13/);
try {
currentSource = resolve({ ...row, Name: name?.[1], Size: size?.[1] });
currentSource = null;
currentSource = resolve({
...row,
Name: name?.[1] ?? row.Name,
Size: size?.[1] ?? row.Size,
});
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
}
}
let text = original.replace(/\{([+-])\}/g, "$1");
let selectionError = "";
if (num(row.LinkIndex) === 62) {
text = text.replace(/(\x07([A-Z])\x07\s*)\x1a[^\x1a]*\x1a/g, (whole, reference, group) => {
const values = [];
for (let j = index - 1; j >= 0 && !(num(rows[j].Index) > 0); j--)
for (const match of String(rows[j].LineData || "").matchAll(
/\x06([A-Z])\x06\s*([\x03\x12\x0c])([-\d.,]+)\2/g,
))
if (match[1] === group) values.unshift(numeric(match[3]));
if (!values.length) selectionError = `합계 구간 ${group}에 지정된 결과가 없습니다.`;
return values.length ? `${reference}\x1a${values.join(" + ")}\x1a` : whole;
});
}
if (num(row.LinkIndex) === 61 && text.includes("\x0c")) {
const values = [];
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j];
if (num(previous.Index) !== -1) {
const results = [
...String(previous.LineData || "").matchAll(/([\x03\x12\x0c])([-\d.,]+)\1/g),
];
if (results.length) values.push(numeric(results.at(-1)[2]));
}
if (num(previous.Level) > 0 || num(previous.Index) > 0 || num(previous.Index) === -20)
break;
}
const precision =
num(row.DigetIndex) && num(row.Diget) > 0
? EstMath.precision(row)
: { digits: 1, method: 2 };
const subtotal = floating(
sumCurrency(values),
options.CostValueTrunc && sc < 0 ? 0 : precision.digits,
precision.method,
);
text = text.replace(/\x0c([-\d.,]+)\x0c/g, (whole, value) => {
changed.set(numeric(value), subtotal);
return `\x0c${subtotal}\x0c`;
});
row.TC = subtotal;
row.LineData = text;
}
if (num(row.LinkIndex) === 58 && text.includes("\x0c")) {
let sourceKind = num(row.OCheck);
if (/노무|인건|품/.test(clean(text))) sourceKind = -1;
else if (/재료|자재/.test(clean(text))) sourceKind = -2;
else if (/경비/.test(clean(text))) sourceKind = -3;
let value;
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j],
kind = num(previous.SCheck);
if ((!sourceKind && kind < 0 && kind > -4) || (sourceKind && kind === sourceKind)) {
const results = [
...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g),
];
if (results.length) {
value = numeric(results.at(-1)[2]);
row.OCheck = kind;
break;
}
}
if (num(previous.Level) > 0 || num(previous.Index) > 0) break;
}
if (value !== undefined) text = text.replace(/\x0c[-\d.,]+\x0c/g, `\x0c${value}\x0c`);
}
if (currentSource && sc < 0 && component[sc])
text = text.replace(
/\x03([-\d.,]+)\x03/g,
@@ -108,14 +221,16 @@ const EstText = (() => {
);
else if (currentSource && sc === 0 && /\x0e/.test(original))
text = text.replace(/\x03([-\d.,]+)\x03/g, () => `\x03${num(currentSource.TC)}\x03`);
row.LineData = text;
text = text.replace(
/\x11([-\d.,]+)\x11/g,
(whole, value) => `\x11${changed.get(numeric(value)) ?? numeric(value)}\x11`,
);
text = text.replace(
/\x0c([-\d.,]+)\x0c/g,
(whole, value) => `\x0c${changed.get(numeric(value)) ?? numeric(value)}\x0c`,
);
if (![58, 61].includes(num(row.LinkIndex)))
text = text.replace(
/\x0c([-\d.,]+)\x0c/g,
(whole, value) => `\x0c${changed.get(numeric(value)) ?? numeric(value)}\x0c`,
);
if (text.includes("\x1a") && text.includes(":") && sc !== -5) {
const start = text.indexOf(":") + 1,
end = text.includes("\x12") ? text.indexOf("\x12") : text.length;
@@ -135,6 +250,7 @@ const EstText = (() => {
const assignedNames = new Map();
let componentDeclaration;
try {
if (selectionError) throw Error(selectionError);
if ((matched || /^\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(text)) && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
@@ -160,8 +276,7 @@ const EstText = (() => {
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j];
if (!previous.LineData) continue;
if (!String(previous.LineData).includes("\x1a") || !component[num(previous.SCheck)])
break;
if (!autoTotal(previous) || !component[num(previous.SCheck)]) break;
section.push(num(previous.TC));
}
calculated = sumCurrency(section);
@@ -203,15 +318,17 @@ const EstText = (() => {
? []
: [
...assignmentText.matchAll(
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*|['"]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
),
];
for (let j = 0; j < assignments.length; j++) {
const assignment = assignments[j],
end = assignments[j + 1]?.index ?? text.length;
let body = text.slice(assignment.index + assignment[0].length, end);
if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\s+\*\s*$/, "");
if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\*\s*$/, "");
body = body.replace(/\{[^}]*\}/g, "");
if (/['"]\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(assignment[0]))
body = body.replace(/['"]\s*$/, "");
if (!matched) body = body.split("{")[0];
body = body.split(/\s+[::]\s*/)[0].replace(/[,,]\s*$/, "");
if (body.includes("─") && calculated !== null) {
@@ -224,7 +341,8 @@ const EstText = (() => {
body = expression(body)
.trim()
.replace(/\s+(?:min|hr|sec)\s*$/i, "")
.split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0];
.split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0]
.replace(/[,,]\s*$/, "");
if (!result)
body = body
.replace(/\s*,\s+[가-힣].*$/, "")
@@ -280,7 +398,7 @@ const EstText = (() => {
row.Precision ??
explicit?.digits ??
(sc < 0 || num(row.LinkIndex) !== 0
? (num(row.Level) === finalLevel && text.includes("\x1a")) ||
? (num(row.Level) === finalLevel && autoTotal(row)) ||
index === singleComponent ||
options.CostValueTrunc
? 0
@@ -361,7 +479,7 @@ const EstText = (() => {
row.LineData = rendered;
changed.set(oldValue, firstValue);
checks.push({ slot: index, stored: oldValue, calculated: firstValue });
if (text.includes("\x1a") && component[sc]) {
if (autoTotal(row) && component[sc]) {
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) {
@@ -371,13 +489,36 @@ const EstText = (() => {
1,
);
row.LineData = text;
} else if (!matched && text.includes("\x1a") && component[sc]) {
const value = clean(text).split(":").slice(1).join(":").trim();
} else if (!matched && autoTotal(row) && component[sc]) {
let value = clean(text).split(":").slice(1).join(":").trim();
// Bare final totals can contain an already truncated amount rather than an equation.
// Rebuild only when the original component rows establish that subtotal relationship.
const savedValue = clean(original).split(":").slice(1).join(":").trim();
if (num(row.Level) === finalLevel && /^[-\d.,]+$/.test(savedValue)) {
const before = [],
after = [];
for (let j = index - 1; j >= 0 && num(rows[j].Index) <= 0; j--) {
if (num(rows[j].SCheck) !== sc) continue;
if (autoTotal(rows[j])) break;
const results = [...String(input[j].LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
if (!results.length && input[j].TC == null) continue;
before.push(results.length ? numeric(results.at(-1)[1]) : num(input[j].TC));
after.push(num(rows[j].TC));
if (num(rows[j].LinkIndex) === 61) break;
}
if (before.length && floating(sumCurrency(before), 0, 2) === numeric(savedValue))
value = String(sumCurrency(after));
}
const calculated = floating(
changed.get(numeric(value)) ?? numeric(value),
EstExpression.evaluate(expression(value || "0"), (key) => variables[key]),
num(row.Level) === finalLevel ? 0 : 1,
);
row.TC = calculated;
if (value !== clean(text).split(":").slice(1).join(":").trim()) {
const start = text.indexOf(":") + 1,
end = text.lastIndexOf("\x1a");
row.LineData = text.slice(0, start) + calculated + text.slice(end);
}
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} catch (error) {
@@ -391,6 +532,8 @@ const EstText = (() => {
return { rows, titles, errors, checks };
}
const names = {
"\x06": "구간지정",
"\x07": "구간합계",
"\x03": "단가",
"\x0c": "다른줄",
"\x0e": "품명",
@@ -401,7 +544,10 @@ const EstText = (() => {
"\x1a": "합계",
};
const display = (text) =>
String(text || "").replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, (ch) => `⟦${names[ch]}⟧`);
String(text || "").replace(
/[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x1a]/g,
(ch) => `⟦${names[ch]}⟧`,
);
const encode = (text) =>
String(text).replace(
/⟦([^⟦⟧]+)⟧/g,
+1 -1
View File
@@ -16,7 +16,7 @@ const EstViews = (() => {
[57, "조달수수료"],
[58, "비율"],
[59, "경계"],
[61, "자릿수"],
[61, "산근 소계"],
[62, "구간비율"],
[67, "내역비율"],
[68, "묶음끝"],