Merge remote-tracking branch 'origin/CODEX' into main_laptop_1
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ const EstViews = (() => {
|
||||
[57, "조달수수료"],
|
||||
[58, "비율"],
|
||||
[59, "경계"],
|
||||
[61, "자릿수"],
|
||||
[61, "산근 소계"],
|
||||
[62, "구간비율"],
|
||||
[67, "내역비율"],
|
||||
[68, "묶음끝"],
|
||||
|
||||
Reference in New Issue
Block a user