feat(est-test): expand native file editing
This commit is contained in:
@@ -1,6 +1,28 @@
|
||||
from test_est_rea_parity import run
|
||||
|
||||
|
||||
def test_machine_fuel_role_name_is_not_a_source_name_mismatch():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const p=EstNative.empty();
|
||||
p.tables.StuffcDat=[{Code:'M1',Name:'경유',Unite:'L'}];
|
||||
p.tables.MachMainDat=[
|
||||
{Index:1,Code:'E1'},
|
||||
{Index:-2,Name:'주연료',Code:'M1',LinkIndex:5,Unite:'L'},
|
||||
{Index:-2,Name:'주연료',Code:'MISSING',LinkIndex:5},
|
||||
{Index:-2,Name:'주연료',Code:'M1',LinkIndex:5,Unite:'kg'},
|
||||
{Index:-2,Name:'다른재료',Code:'M1',LinkIndex:5,Unite:'L'}
|
||||
];
|
||||
return EstIssues.scan(p).map(x=>[x.index,x.message]);
|
||||
})()""",
|
||||
)
|
||||
assert [1, "연결 품명 차이"] not in result
|
||||
assert [2, "연결 자료 없음"] in result
|
||||
assert [3, "연결 단위 차이"] in result
|
||||
assert [4, "연결 품명 차이"] in result
|
||||
|
||||
|
||||
def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modifying_data():
|
||||
result = run(
|
||||
"math",
|
||||
@@ -533,4 +555,3 @@ def test_data_issues_detect_percentage_unit_mismatch():
|
||||
assert ("HeadMainDat", "R1", "식") in items
|
||||
assert ("HeadMainDat", "R2", "M") in items
|
||||
assert ("EstMainDat", "R5", "원") in items
|
||||
|
||||
|
||||
@@ -6,6 +6,55 @@ from test_est_rea_parity import run
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_jdl_duplicate_sources_and_internal_codes_do_not_change_estimate_amounts():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const duplicate=structuredClone(empty);duplicate.name='duplicate';
|
||||
duplicate.tables.StuffcDat=[{Code:'M1',Name:'first',TC:100},{Code:'M1',Name:'second',TC:200},{Code:'M1',Name:'third',TC:300}];
|
||||
duplicate.tables.EstMainDat=[{Code:'M1',Name:'item',LinkIndex:5,GN:2,GU:200,GC:400,TC:400},{LinkIndex:54}];
|
||||
const first=j.output(duplicate),loaded=j.read(first.bytes,empty);
|
||||
const collision=structuredClone(empty);collision.name='collision';
|
||||
collision.tables.EstiMateDat=[{Code:'LITERAL2',Name:'existing',GC:999,TC:999}];
|
||||
collision.tables.EstMainDat=[{Code:'fixed',Name:'fixed',CalcIndex:1,GN:0,GC:80,TC:80},{LinkIndex:54}];
|
||||
const second=j.output(collision),files=a.unpack(second.bytes),parts=j.parse('mainpart.txt',files.get('mainpart.txt'));
|
||||
const sources=j.parse('w.txt',files.get('w.txt')),target=sources.find(r=>r.F_CODE===parts[0].F_CODE1);
|
||||
process.stdout.write(JSON.stringify({duplicateTotal:loaded.tables.EstMainDat.at(-1).TC,
|
||||
duplicateSources:j.parse('m.txt',a.unpack(first.bytes).get('m.txt')).length,
|
||||
warning:first.warnings.some(x=>x.includes('중복')),fixedPrice:target.F_MPRICE,sourceCount:sources.length}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {
|
||||
"duplicateTotal": 400,
|
||||
"duplicateSources": 3,
|
||||
"warning": True,
|
||||
"fixedPrice": 80,
|
||||
"sourceCount": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_jdl_duplicate_unit_codes_do_not_select_a_wrong_unit_group():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8')),p=structuredClone(empty);p.name='units';
|
||||
p.tables.StuffcDat=[{Code:'M',Name:'material',TC:100}];
|
||||
p.tables.HeadTitleDat=[{Code:'U',Name:'first',GC:200},{Code:'U',Name:'second',GC:300}];
|
||||
p.tables.HeadMainDat=[{Index:1,Code:'U'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200}];
|
||||
p.tables.EstMainDat=[{Code:'U',Name:'item',LinkIndex:103,GN:1,GU:300,GC:300,TC:300},{LinkIndex:54}];
|
||||
const out=j.output(p),files=a.unpack(out.bytes),loaded=j.read(out.bytes,empty);
|
||||
process.stdout.write(JSON.stringify({units:j.parse('unit.txt',files.get('unit.txt')).length,
|
||||
total:loaded.tables.EstMainDat.at(-1).TC,warning:out.warnings.some(x=>x.includes('중복'))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT
|
||||
)
|
||||
)
|
||||
assert result == {"units": 0, "total": 300, "warning": True}
|
||||
|
||||
|
||||
def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_money():
|
||||
p = run(
|
||||
"math",
|
||||
@@ -68,9 +117,10 @@ def test_basic_jdl_unit_price_parts_import_with_recorded_costs_and_warn_for_spec
|
||||
const common={F_CODE:'U1',F_CODE1:'M1',F_DES:'material',F_QQTY:2,F_MPRICE:100,F_MCOST:200,F_LOADBM:'T',F_SAVEBM:'T',F_SPROPER:'0',F_KWAN:'0',F_CLASS:'0'};
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_DES:'material',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:200}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:300}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{...common,F_SEQ:'1'},{...common,F_SEQ:'2',F_SPROPER:'1'}])]]);
|
||||
const out=s.calculate(j.read(a.pack(files),empty));process.stdout.write(JSON.stringify({rows:out.tables.HeadMainDat,warnings:out.importWarnings}));"""
|
||||
const out=s.calculate(j.read(a.pack(files),empty));out.options.AutoCalc=true;out.options.SourceRefresh=true;
|
||||
const recalculated=s.calculate(structuredClone(out));process.stdout.write(JSON.stringify({rows:out.tables.HeadMainDat,warnings:out.importWarnings,title:recalculated.tables.HeadTitleDat[0]}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
@@ -78,9 +128,11 @@ def test_basic_jdl_unit_price_parts_import_with_recorded_costs_and_warn_for_spec
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
header, detail = result["rows"]
|
||||
assert header["Index"] == 1 and header["TC"] == 200
|
||||
header, detail, remainder = result["rows"]
|
||||
assert header["Index"] == 1 and header["TC"] == 300
|
||||
assert detail["LinkIndex"] == 5 and detail["GN"] == 2 and detail["GC"] == 200
|
||||
assert remainder["CalcIndex"] == 1 and remainder["GC"] == 100
|
||||
assert result["title"]["TC"] == 300
|
||||
assert any("특수 구성" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
@@ -111,3 +163,180 @@ def test_basic_jdl_unit_price_parts_export_and_reimport():
|
||||
assert result["rows"][1]["LinkIndex"] == 5 and result["rows"][1]["GC"] == 200
|
||||
assert result["main"][1]["LinkIndex"] == 103
|
||||
assert result["specialUnits"] == 0 and result["specialWarnings"] > 0
|
||||
|
||||
|
||||
def test_jdl_machine_saved_amount_is_editable_and_survives_recalculation():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100.25,F_LPRICE:50.12,F_EPRICE:25.03}])],
|
||||
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_CODE1:'M1'}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'J1',F_DES:'machine',F_QQTY:2,F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]]);
|
||||
const original=a.pack(files),p=j.markOriginal(s.calculate(j.read(original,empty)));
|
||||
const same=original.equals(j.output(s.calculate(structuredClone(p))).bytes);
|
||||
p.tables.MachMainDat[1].GC=120.25;
|
||||
p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
const q=s.calculate(JSON.parse(JSON.stringify(p)));
|
||||
process.stdout.write(JSON.stringify({same,rows:q.tables.MachMainDat,title:q.tables.MachTitleDat[0],
|
||||
estimate:q.tables.EstMainDat[1],warnings:p.importWarnings,errors:q.calculation.errors}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result["same"]
|
||||
assert result["rows"][1]["CalcIndex"] == 1
|
||||
assert abs(result["rows"][1]["TC"] - 195.4) < 1e-8
|
||||
assert abs(result["title"]["TC"] - 195.4) < 1e-8
|
||||
assert result["estimate"]["TC"] == 390
|
||||
assert result["warnings"] and not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_machine_simple_detail_is_editable_and_mismatches_remain_summary():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:20,F_EPRICE:10},
|
||||
{F_CODE:'J2',F_DES:'mismatch',F_MPRICE:50},{F_CODE:'J3',F_DES:'quantity',F_MPRICE:50}])],
|
||||
['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_CODE1:'M1',F_DES:'fuel',F_QQTY:1,F_MPRICE:60,F_EPRICE:10},
|
||||
{F_CODE:'J1',F_SEQ:'2',F_CODE1:'L1',F_DES:'operator',F_QQTY:1,F_MPRICE:40,F_LPRICE:20},
|
||||
{F_CODE:'J2',F_SEQ:'1',F_DES:'incomplete',F_QQTY:1,F_MPRICE:10},
|
||||
{F_CODE:'J3',F_SEQ:'1',F_DES:'unknown quantity semantics',F_QQTY:2,F_MPRICE:50}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
p.tables.MachMainDat[1].GC=70;const q=s.calculate(p);
|
||||
process.stdout.write(JSON.stringify({rows:q.tables.MachMainDat,titles:q.tables.MachTitleDat,errors:q.calculation.errors,warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert [row["Name"] for row in result["rows"][:3]] == ["machine", "fuel", "operator"]
|
||||
assert result["titles"][0]["TC"] == 140
|
||||
assert result["titles"][1]["TC"] == 50
|
||||
assert result["titles"][2]["TC"] == 50
|
||||
assert "상세 산식 미변환" in result["rows"][-1]["Name"]
|
||||
assert result["warnings"] and not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_cost_saved_amount_calculates_and_can_be_edited():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['danga.txt',table('danga.txt',[{F_CODE:'D1',F_DES:'cost',F_MPRICE:100.25,F_LPRICE:50.12,F_EPRICE:25.03,F_MEMO:'source'},{F_CODE:'D2',F_EPRICE:0.000000000001}])],
|
||||
['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])],
|
||||
['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'D1',F_DES:'cost',F_QQTY:2,F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.tables.CostMainDat[1].LineData='120.25 = \\x12100.25\\x12';
|
||||
p.options.AutoCalc=true;p.options.SourceRefresh=true;const q=s.calculate(JSON.parse(JSON.stringify(p)));
|
||||
process.stdout.write(JSON.stringify({title:q.tables.CostTitleDat[0],tiny:q.tables.CostTitleDat[1],estimate:q.tables.EstMainDat[1],errors:q.calculation.errors,warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert abs(result["title"]["TC"] - 195.4) < 1e-8
|
||||
assert result["tiny"]["JC"] == 0.000000000001
|
||||
assert result["estimate"]["TC"] == 390
|
||||
assert result["warnings"] and not result["errors"]
|
||||
|
||||
|
||||
def test_jdl_complete_unit_preserves_difference_from_saved_title():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_MPRICE:205.25},{F_CODE:'U2',F_LPRICE:30.12}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_CODE1:'M1',F_QQTY:2,F_MPRICE:100,F_MCOST:200}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
const q=s.calculate(p);process.stdout.write(JSON.stringify({rows:q.tables.HeadMainDat,titles:q.tables.HeadTitleDat}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result["rows"][2]["GC"] == 5.25
|
||||
assert [row["TC"] for row in result["titles"]] == [205.25, 30.12]
|
||||
|
||||
|
||||
def test_jdl_unit_inconsistent_detail_keeps_recorded_amount_without_double_counting():
|
||||
project = run("math", "EstNative.empty()")
|
||||
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json'),s=require('./tools/est-test/server.cjs');
|
||||
const empty=JSON.parse(fs.readFileSync(0,'utf8'));
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
const files=new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['m.txt',table('m.txt',[{F_CODE:'M1',F_MPRICE:100}])],
|
||||
['unit.txt',table('unit.txt',[{F_CODE:'U1',F_MPRICE:200}])],
|
||||
['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_CODE1:'M1',F_QQTY:2,F_MPRICE:100,F_MCOST:0}])]]);
|
||||
const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true;
|
||||
const q=s.calculate(p);process.stdout.write(JSON.stringify({rows:q.tables.HeadMainDat,title:q.tables.HeadTitleDat[0],warnings:p.importWarnings}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script],
|
||||
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
|
||||
cwd=ROOT,
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result["rows"][1]["CalcIndex"] == 1
|
||||
assert result["rows"][2]["GC"] == 200
|
||||
assert result["title"]["TC"] == 200
|
||||
assert any("고정" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
def test_edge_jdl_machine_amount_edit():
|
||||
from playwright.sync_api import sync_playwright
|
||||
import pytest
|
||||
|
||||
script = """const a=require('./tools/est-test/archive.cjs'),schema=require('./tools/est-test/jdl-schema.json');
|
||||
const table=(name,rows)=>Buffer.from(rows.map(r=>schema[name].map(f=>r[f.name]??(f.kind===6?'0':'')).join('\\t')+'\\x05\\r\\n').join(''));
|
||||
process.stdout.write(a.pack(new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')],
|
||||
['junit.txt',table('junit.txt',[{F_CODE:'J1',F_DES:'machine',F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])],
|
||||
['danga.txt',table('danga.txt',[{F_CODE:'D1',F_DES:'cost',F_MPRICE:100,F_LPRICE:50,F_EPRICE:25}])]])));"""
|
||||
data = subprocess.check_output(["node", "-e", script], cwd=ROOT)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
page = browser.new_page()
|
||||
try:
|
||||
page.goto("http://100.65.141.15:5175", timeout=5000)
|
||||
except Exception:
|
||||
browser.close()
|
||||
pytest.skip("EST server unavailable")
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.wait_for_function("() => Boolean(EstApp.state.project)")
|
||||
page.locator("#jdl-file").set_input_files(
|
||||
{"name": "sample.jdl", "mimeType": "application/zip", "buffer": data}
|
||||
)
|
||||
page.wait_for_function("() => EstApp.state.project.tables.MachMainDat.length === 2")
|
||||
assert page.locator("#dialog").is_visible()
|
||||
assert "상세 산식" in page.locator("#dialog-content").inner_text()
|
||||
page.locator("#dialog").evaluate("(dialog) => dialog.close()")
|
||||
page.evaluate("""() => {
|
||||
EstApp.state.project.options.AutoCalc=true;
|
||||
EstApp.navigate('MachMainDat','J1');
|
||||
}""")
|
||||
amount = page.locator('[data-table="MachMainDat"][data-row="1"][data-field="GC"]')
|
||||
amount.fill("120")
|
||||
amount.press("Tab")
|
||||
page.wait_for_function("() => EstApp.state.project.tables.MachTitleDat[0].TC === 195")
|
||||
assert page.evaluate("EstApp.state.project.tables.MachMainDat[1].TC") == 195
|
||||
page.evaluate("EstApp.navigate('CostMainDat','D1')")
|
||||
formula = page.locator('[data-table="CostMainDat"][data-row="1"][data-field="LineData"]')
|
||||
formula.fill(formula.input_value().replace("100", "130.25", 1))
|
||||
formula.press("Tab")
|
||||
page.wait_for_function("() => EstApp.state.project.tables.CostTitleDat[0].TC === 205.25")
|
||||
browser.close()
|
||||
|
||||
@@ -8,6 +8,265 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_quantity_list_append_roundtrips_without_changing_indexes_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets[0];
|
||||
const before=structuredClone(sheet),codes=new Set(sheet.items.map(x=>x.Code));let n=0;
|
||||
while(codes.has('r'+String(n).padStart(6,'0')))n++;
|
||||
const item={Code:'r'+String(n).padStart(6,'0'),Name:'추가',Size:'',Unite:'m',GN:0,TargetCode:''};
|
||||
sheet.items.push(item);sheet.Name='목록 시험';const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
|
||||
const indexes=[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)));
|
||||
const added=q.quantitySheets[0].items.at(-1),sameRows=JSON.stringify(before.rows)===JSON.stringify(q.quantitySheets[0].rows);
|
||||
const existing=JSON.stringify(before.items)===JSON.stringify(q.quantitySheets[0].items.slice(0,-1));
|
||||
sheet.items.push({...item});let duplicate=false;try{e.exportEstx(p)}catch{duplicate=true}
|
||||
process.stdout.write(JSON.stringify({added,name:q.quantitySheets[0].Name,indexes,sameRows,existing,duplicate}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=100).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result["added"]["Name"] == "추가"
|
||||
assert result["name"] == "목록 시험"
|
||||
assert result["added"]["Unite"] == "m"
|
||||
assert result["added"]["GN"] == 0
|
||||
assert all(result[key] for key in ("indexes", "sameRows", "existing", "duplicate"))
|
||||
|
||||
|
||||
def test_quantity_list_append_rejects_unrepresentable_data():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const base={Code:'N1',Name:'sheet',options:{},rows:[],items:[{Code:'r000001',GN:0}]};
|
||||
const bad=[{Code:'r000001'},{Code:'r000002',TargetCode:'E1'},
|
||||
{Code:'r000002',Unite:'123456789'},{Code:'r000002',GN:'1'},
|
||||
{Code:'r000002',Unknown:'lost'}];
|
||||
const rejects=bad.map(item=>{const next=structuredClone(base);next.items.push(item);
|
||||
try{w.editDocument('',base,next);return false}catch{return true}});
|
||||
process.stdout.write(JSON.stringify(rejects));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert all(result)
|
||||
|
||||
|
||||
def test_quantity_list_remove_unreferenced_item_roundtrips_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
const sheet=p.quantitySheets.find(s=>s.items.some(item=>!item.TargetCode&&!item.LinkCode&&!item.LinkIndex&&
|
||||
!s.rows.some(r=>r.markers.includes(item.Code)||r.LineData.includes(item.Code))));
|
||||
const old=structuredClone(sheet),i=sheet.items.findIndex(item=>!item.TargetCode&&!item.LinkCode&&!item.LinkIndex&&
|
||||
!sheet.rows.some(r=>r.markers.includes(item.Code)||r.LineData.includes(item.Code)));
|
||||
const removed=sheet.items.splice(i,1)[0],out=e.exportEstx(p),q=m.importEstx(out);
|
||||
const reread=q.quantitySheets.find(s=>s.Code===sheet.Code),expected=old.items.filter(x=>x.Code!==removed.Code);
|
||||
const a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({items:JSON.stringify(reread.items)===JSON.stringify(expected),
|
||||
rows:JSON.stringify(reread.rows)===JSON.stringify(old.rows),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=120).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert all(result.values())
|
||||
|
||||
|
||||
def test_quantity_list_remove_referenced_item_is_rejected():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const old={Code:'N1',Name:'sheet',options:{},rows:[{LineData:'Q',markers:['r000001']}],items:[{Code:'r000001'}]};
|
||||
const next=structuredClone(old);next.items=[];
|
||||
try{w.editDocument('',old,next);process.stdout.write('false')}catch(e){process.stdout.write(JSON.stringify(e.message.includes('참조')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
|
||||
|
||||
def test_quantity_list_remove_rejects_hidden_native_reference():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const sheet={Code:'N1',Name:'sheet',options:{},rows:[],items:[{Code:'r000001'}]};
|
||||
const row={Code:'N1',Editor:{binary:'AA=='},MulList:{binary:'AA=='}};
|
||||
const target={MulCode:{binary:Buffer.from('N1\tr000001').toString('base64')}};
|
||||
const base={quantitySheets:[sheet],tables:{MulMainDat:[row],EstMainDat:[target]}};
|
||||
const project=structuredClone(base);project.quantitySheets[0].items=[];
|
||||
const rejected=()=>{try{w.updateQuantities(project,base,()=>{},()=>{});return false}
|
||||
catch(e){return e.message.includes('내역에서 참조')}};
|
||||
const original=rejected();base.tables.EstMainDat=[];const changed=rejected();
|
||||
process.stdout.write(JSON.stringify({original,changed}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"original": True, "changed": True}
|
||||
|
||||
|
||||
def test_quantity_list_existing_missing_codes_can_still_edit_names_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const p=m.importEstx(fs.readFileSync(process.argv[1]));
|
||||
const sheet=p.quantitySheets.find(s=>s.items.some(x=>!x.Code));
|
||||
if(!sheet){process.stdout.write('null');process.exit()}
|
||||
sheet.items[0].Name='수정';const q=m.importEstx(e.exportEstx(p));
|
||||
process.stdout.write(JSON.stringify(q.quantitySheets.find(s=>s.Code===sheet.Code).items[0].Name));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == "수정"
|
||||
|
||||
|
||||
def test_quantity_sheet_name_roundtrips_without_modifying_document_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets[0];
|
||||
const before=structuredClone(p.tables.MulMainDat.find(row=>row.Code===sheet.Code));
|
||||
sheet.Name='수량산출서 시험';const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
|
||||
const after=q.tables.MulMainDat.find(row=>row.Code===sheet.Code);
|
||||
process.stdout.write(JSON.stringify({name:q.quantitySheets[0].Name,unchanged:JSON.stringify(before)===JSON.stringify(after),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"name": "수량산출서 시험", "unchanged": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_sheet_name_rejects_conflicting_title_changes():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const sheet={Code:'N1',Name:'old',options:{},rows:[],items:[]};
|
||||
const row={Code:'N1',Editor:{binary:'AA=='},MulList:{binary:'AA=='}};
|
||||
const base={quantitySheets:[sheet],tables:{MulMainDat:[row],MulTitleDat:[{Code:'N1',Name:'old'}],EstMainDat:[]}};
|
||||
const project=structuredClone(base);project.quantitySheets[0].Name='new';project.tables.MulTitleDat[0].Name='other';
|
||||
try{w.updateQuantities(project,base,()=>{},()=>{});process.stdout.write('false')}
|
||||
catch(e){process.stdout.write(JSON.stringify(e.message.includes('제목 데이터 충돌')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
|
||||
|
||||
def test_quantity_list_add_to_empty_original_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>!s.items.length);
|
||||
const before=structuredClone(sheet.rows);sheet.items.push({Code:'r000000',Name:'새 항목',Size:'',Unite:'m',GN:0,TargetCode:''});
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({name:saved.items[0].Name,unit:saved.items[0].Unite,rows:JSON.stringify(before)===JSON.stringify(saved.rows),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"name": "새 항목", "unit": "m", "rows": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_append_calculated_row_roundtrips_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
const sheet=p.quantitySheets.find(s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return s.items.length&&x.errors.length===0&&x.checks.length===n&&
|
||||
x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)});
|
||||
const before=structuredClone(sheet),codes=new Set(sheet.items.map(x=>x.Code));let n=0;
|
||||
while(codes.has('r'+String(n).padStart(6,'0')))n++;
|
||||
const code='r'+String(n).padStart(6,'0');sheet.items.push({Code:code,Name:'new',Size:'',Unite:'',GN:0,TargetCode:''});
|
||||
sheet.rows.push({LineData:'2+3 = \x120\x12',markers:[code]});
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({value:saved.items.at(-1).GN,line:saved.rows.at(-1),
|
||||
rows:JSON.stringify(before.rows)===JSON.stringify(saved.rows.slice(0,-1)),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result["value"] == 5
|
||||
assert result["line"]["LineData"] == "2+3 = \x125\x12"
|
||||
assert result["rows"] and result["indexes"]
|
||||
|
||||
|
||||
def test_quantity_append_row_rejects_duplicate_and_unrepresentable_markers():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const doc='<cs nr=1/><div><begin Tag=1 style="Name:'r000001';"/>1<end Tag=1/></div>';
|
||||
const sheet={items:[{Code:'r000001'},{Code:'r000002'}]};
|
||||
const bad=[{LineData:'1=\x120\x12',markers:['r000001']},
|
||||
{LineData:'1=\x120\x12',markers:['r000003']},
|
||||
{LineData:'1=\x120\x12\x121\x12',markers:['r000002']},
|
||||
{LineData:'^(2)=\x120\x12',markers:['r000002']}];
|
||||
process.stdout.write(JSON.stringify(bad.map(row=>{try{w.appendRows(doc,sheet,[row]);return false}catch{return true}})));"""
|
||||
assert all(json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT)))
|
||||
|
||||
|
||||
def test_quantity_insert_calculated_row_in_middle_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
|
||||
const sheet=p.quantitySheets.find(s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return s.rows.length>3&&s.items.length&&x.errors.length===0&&x.checks.length===n&&
|
||||
x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)});
|
||||
const before=structuredClone(sheet),codes=new Set(sheet.items.map(x=>x.Code));let n=0;
|
||||
while(codes.has('r'+String(n).padStart(6,'0')))n++;
|
||||
const code='r'+String(n).padStart(6,'0');sheet.items.push({Code:code,Name:'new',Size:'',Unite:'',GN:0,TargetCode:''});
|
||||
sheet.rows.splice(2,0,{LineData:'2+3 = \x120\x12',markers:[code]});
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
const rows=saved.rows.slice();const added=rows.splice(2,1)[0];
|
||||
process.stdout.write(JSON.stringify({value:saved.items.at(-1).GN,line:added.LineData,
|
||||
rows:JSON.stringify(before.rows)===JSON.stringify(rows),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"value": 5, "line": "2+3 = \x125\x12", "rows": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_insert_rejects_position_before_default_style():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const doc='<div><cs nr=1/>original</div>',sheet={items:[{Code:'r000001'}]};
|
||||
const row={LineData:'1=\x120\x12',markers:['r000001']};
|
||||
try{w.insertRows(doc,sheet,[{row,at:0}]);process.stdout.write('false')}
|
||||
catch(e){process.stdout.write(JSON.stringify(e.message.includes('기본 글자 서식')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
|
||||
|
||||
def test_quantity_delete_calculated_row_roundtrips_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),sheet=p.quantitySheets.find(s=>s.Code==='N0000000092');
|
||||
const old=structuredClone(sheet),index=sheet.rows.findIndex(row=>row.markers.includes('r000007'));
|
||||
sheet.rows.splice(index,1);sheet.items=sheet.items.filter(item=>item.Code!=='r000007');
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(s=>s.Code===sheet.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
old.rows.splice(index,1);old.items=old.items.filter(item=>item.Code!=='r000007');
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(saved.rows)===JSON.stringify(old.rows),
|
||||
items:JSON.stringify(saved.items)===JSON.stringify(old.items),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"rows": True, "items": True, "indexes": True}
|
||||
|
||||
|
||||
def test_quantity_delete_preserves_styles_and_rejects_shared_tag():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const doc='<cs nr=1/><div><cs nr=2/><begin Tag=1 style="Name:'r000001';"/>1<end Tag=1/></div>';
|
||||
const sheet=q.fromWpt('N1','sheet',doc,[{Code:'r000001'}],{}),codes=new Set(['r000001']);
|
||||
const result=w.removeRows(doc,sheet,codes);let rejected=false;
|
||||
try{w.removeRows(doc+'<end Tag=1/>',sheet,codes)}catch{rejected=true}
|
||||
process.stdout.write(JSON.stringify({style:result.includes('<cs nr=2/>'),removed:!result.includes('<begin'),rejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"style": True, "removed": True, "rejected": True}
|
||||
|
||||
|
||||
def test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets():
|
||||
script = """const {existingTargets}=require('./tools/est-test/native-links.cjs');
|
||||
const sheet={Code:'N1',items:[{Code:'r1'},{Code:'r2'},{Code:'r3'},{Code:'r4'}]};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_reorder_preserves_marker_structure_and_rejects_late_styles():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const div=(tag,code,value)=>'<div cs=1><c nr=1/><begin Tag='+tag+' style="Name:''+code+'';"/>'+value+'<end Tag='+tag+'/></div>';
|
||||
const doc='<cs nr=1/>'+div(1,'r000001','1')+div(2,'r000002','2');
|
||||
const sheet=q.fromWpt('N','sheet',doc,[],{}),rows=[sheet.rows[1],sheet.rows[0]],out=w.reorderRows(doc,sheet,rows);
|
||||
const parsed=q.fromWpt('N','sheet',out.text,[],{});let rejected=false;
|
||||
const late='<cs nr=1/>'+div(1,'r000001','1')+'<cs nr=2/><div cs=2>2</div>';
|
||||
const other=q.fromWpt('N','sheet',late,[],{});
|
||||
try{w.reorderRows(late,other,[other.rows[1],other.rows[0]])}catch{rejected=true}
|
||||
const redefined='<cs nr=1/>'+div(1,'r000001','1')+'<cs nr=1/>'+div(2,'r000002','2');
|
||||
const third=q.fromWpt('N','sheet',redefined,[],{});let changed=false;
|
||||
try{w.reorderRows(redefined,third,[third.rows[1],third.rows[0]])}catch{changed=true}
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(parsed.rows)===JSON.stringify(rows),rejected,changed}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"rows": True, "rejected": True, "changed": True}
|
||||
|
||||
|
||||
def test_append_handles_many_existing_tag_numbers():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs');
|
||||
const doc='<cs nr=1/>'+Array.from({length:150000},(_,i)=>'<end Tag='+i+'/>').join('');
|
||||
const row={LineData:'1=\x120\x12',markers:['r000001']};
|
||||
const out=w.appendRows(doc,{items:[{Code:'r000001'}]},[row]);
|
||||
process.stdout.write(JSON.stringify(out.includes('<begin Tag=150000 ')));"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=15))
|
||||
|
||||
|
||||
def test_move_checks_shared_tags_without_confusing_similar_numbers():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const div=(tag,code)=>'<div cs=1><c nr=1/><begin Tag='+tag+' style="Name:''+code+'';"/>1<end Tag='+tag+'/></div>';
|
||||
const noise=Array.from({length:10000},(_,i)=>'<end Tag='+(i+100)+'/>').join('');
|
||||
const doc='<cs nr=1/>'+noise+div(1,'r000001')+div(10,'r000002');
|
||||
const sheet=q.fromWpt('N','sheet',doc,[],{}),rows=[sheet.rows[1],sheet.rows[0]];
|
||||
const output=w.reorderRows(doc,sheet,rows);let rejected=false;
|
||||
try{w.reorderRows(doc+'<end Tag=1/>',sheet,rows)}catch{rejected=true}
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(q.fromWpt('N','sheet',output.text,[],{}).rows)===JSON.stringify(rows),rejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT, timeout=30))
|
||||
assert result == {"rows": True, "rejected": True}
|
||||
|
||||
|
||||
def test_move_and_edit_keeps_original_row_marker():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const div=(tag,code,value)=>'<div cs=1><c nr=1/>Q='+value+'=<begin Tag='+tag+' style="Name:''+code+'';"/>'+value+'<end Tag='+tag+'/></div>';
|
||||
const doc='<cs nr=1/>'+div(1,'r000001','1')+div(2,'r000002','2');
|
||||
const sheet=q.fromWpt('N','sheet',doc,[],{}),rows=structuredClone([sheet.rows[1],sheet.rows[0]]);
|
||||
rows[0].LineData=rows[0].LineData.replace('Q=2','Q=3');
|
||||
const moved=w.reorderRows(doc,sheet,rows),old={...sheet,rows:moved.rows},edited={...sheet,rows};
|
||||
const result=w.editDocument(moved.text,old,edited),parsed=q.fromWpt('N','sheet',result,[],{});
|
||||
process.stdout.write(JSON.stringify(parsed.rows));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result[0]["markers"] == ["r000002"]
|
||||
assert result[0]["LineData"].startswith("Q=3")
|
||||
assert result[1]["markers"] == ["r000001"]
|
||||
|
||||
|
||||
def test_move_quantity_rows_in_installed_file_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),
|
||||
e=require('./tools/est-test/export-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);let candidate=null;
|
||||
const valid=s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return !x.errors.length&&x.checks.length===n&&x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)};
|
||||
for(const s of p.quantitySheets){if(!valid(s))continue;const r=p.tables.MulMainDat.find(x=>x.Code===s.Code);
|
||||
let b=Buffer.from(r.Editor.binary,'base64');if(b[0]===120)b=z.inflateSync(b);const doc=new TextDecoder('euc-kr').decode(b);
|
||||
for(let i=1;i<s.rows.length;i++){if(s.rows[i-1].markers.length!==1||s.rows[i].markers.length!==1)continue;
|
||||
const rows=s.rows.slice();[rows[i-1],rows[i]]=[rows[i],rows[i-1]];
|
||||
if(JSON.stringify(rows)===JSON.stringify(s.rows))continue;
|
||||
rows[i-1]={...rows[i-1],LineData:' '+rows[i-1].LineData};
|
||||
try{const result=w.reorderRows(doc,s,rows);if(result.text===doc||!valid({...s,rows}))continue;
|
||||
w.editDocument(result.text,{...s,rows:result.rows},{...s,rows});
|
||||
candidate={sheet:s,rows};break}catch{}}
|
||||
if(candidate)break;
|
||||
}
|
||||
if(!candidate)throw Error('no safe move fixture');
|
||||
const s=candidate.sheet,before=structuredClone(s);s.rows=candidate.rows;
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(x=>x.Code===s.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(saved.rows)===JSON.stringify(s.rows),
|
||||
items:JSON.stringify(saved.items)===JSON.stringify(before.items),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=90).decode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
assert result == {"rows": True, "items": True, "indexes": True}
|
||||
@@ -0,0 +1,68 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_delete_plain_row_rejects_document_bookmark_and_preserves_styles():
|
||||
script = r"""const w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs');
|
||||
const doc='<cs nr=1/><div><cs nr=2/>plain</div>',sheet=q.fromWpt('N','sheet',doc,[],{});
|
||||
const out=w.removeRows(doc,sheet,new Set(),new Set([0]));
|
||||
const marked='<cs nr=1/><div><begin Tag=1 style="Name:'T1';"/>title<end Tag=1/></div>';
|
||||
const other=q.fromWpt('N','sheet',marked,[],{});let rejected=false;
|
||||
try{w.removeRows(marked,other,new Set(),new Set([0]))}catch{rejected=true}
|
||||
process.stdout.write(JSON.stringify({style:out.includes('<cs nr=2/>'),removed:!out.includes('plain'),rejected}));"""
|
||||
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT))
|
||||
assert result == {"style": True, "removed": True, "rejected": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edit", [False, True])
|
||||
def test_delete_plain_row_in_installed_file_when_sample_exists(edit):
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),
|
||||
e=require('./tools/est-test/export-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),c=require('./tools/est-test/quantity-calculate.cjs');
|
||||
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);let candidate=null;
|
||||
const valid=s=>{const x=c.calculateQuantity(s),n=s.rows.reduce((a,r)=>a+r.markers.length,0);
|
||||
return !x.errors.length&&x.checks.length===n&&x.checks.every(v=>Number.isFinite(v.stored)&&v.stored===v.calculated)&&x.items.every((v,i)=>v.GN===s.items[i].GN)};
|
||||
for(const s of p.quantitySheets){if(!valid(s))continue;const r=p.tables.MulMainDat.find(x=>x.Code===s.Code);
|
||||
let b=Buffer.from(r.Editor.binary,'base64');if(b[0]===120)b=z.inflateSync(b);const doc=new TextDecoder('euc-kr').decode(b);
|
||||
for(let i=0;i<s.rows.length;i++){const row=s.rows[i];if(row.markers.length||s.rows.filter(x=>JSON.stringify(x)===JSON.stringify(row)).length!==1)continue;
|
||||
const rows=s.rows.filter((_,j)=>j!==i);
|
||||
if(process.argv[2]==='edit'){
|
||||
const j=rows.findIndex(row=>row.markers.length===1&&rows.filter(x=>JSON.stringify(x.markers)===JSON.stringify(row.markers)).length===1);
|
||||
if(j<0)continue;rows[j]={...rows[j],LineData:' '+rows[j].LineData};
|
||||
}
|
||||
try{const source=w.removeRows(doc,s,new Set(),new Set([i]));if(!valid({...s,rows}))continue;
|
||||
w.editDocument(source,{...s,rows:s.rows.filter((_,j)=>j!==i)},{...s,rows});
|
||||
candidate={sheet:s,rows};break}catch{}}
|
||||
if(candidate)break;
|
||||
}
|
||||
if(!candidate)throw Error('no safe plain-row fixture');
|
||||
const s=candidate.sheet,before=structuredClone(s);s.rows=candidate.rows;
|
||||
const out=e.exportEstx(p),q=m.importEstx(out),saved=q.quantitySheets.find(x=>x.Code===s.Code),a=m.unzip(bytes),b=m.unzip(out);
|
||||
process.stdout.write(JSON.stringify({rows:JSON.stringify(saved.rows)===JSON.stringify(s.rows),
|
||||
items:JSON.stringify(saved.items)===JSON.stringify(before.items),
|
||||
indexes:[...a].filter(([name])=>name.endsWith('.idx')).every(([name,data])=>data.equals(b.get(name)))}));"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script, str(sample), "edit" if edit else "plain"], cwd=ROOT, timeout=90
|
||||
).decode("utf-8")
|
||||
)
|
||||
assert result == {"rows": True, "items": True, "indexes": True}
|
||||
|
||||
|
||||
def test_delete_duplicate_plain_row_is_rejected_when_sample_exists():
|
||||
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
|
||||
if not sample.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = r"""const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
|
||||
const p=m.importEstx(fs.readFileSync(process.argv[1])),sheet=p.quantitySheets.find(s=>s.Code==='N0000000071');
|
||||
const index=sheet.rows.findIndex((r,i)=>!r.markers.length&&sheet.rows.some((x,j)=>i!==j&&JSON.stringify(x)===JSON.stringify(r)));
|
||||
if(index<0)throw Error('duplicate fixture unavailable');sheet.rows.splice(index,1);
|
||||
try{e.exportEstx(p);process.stdout.write('false')}catch(error){process.stdout.write(JSON.stringify(error.message.includes('중복')))}"""
|
||||
assert json.loads(subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT))
|
||||
@@ -249,6 +249,25 @@ def test_insert_and_move_quantity_rows_keeps_item_results_linked():
|
||||
assert result["forward"]["errors"]
|
||||
|
||||
|
||||
def test_new_quantity_codes_avoid_items_and_existing_markers():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const handlers={};globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn};
|
||||
const sheet={Code:'N1',Name:'test',options:{},items:[{Code:'r000000'}],
|
||||
rows:[{LineData:'1 = \\x120\\x12',markers:['r000001']}]};
|
||||
const state={quantitySheet:'N1',project:{quantitySheets:[sheet],tables:{EstMainDat:[]}}};
|
||||
globalThis.EstApp={state,change:fn=>fn(),render:()=>{}};EstQuantity.install();
|
||||
const click=selector=>handlers.click({target:{closest:s=>s===selector?{dataset:{}}:null}});
|
||||
click('[data-quantity-add]');click('[data-quantity-add-item]');
|
||||
const code=sheet.rows[1].markers[0];
|
||||
const doc='<div><begin Tag=10 style="Name:''+code+'';"/>1<end Tag=10/></div>';
|
||||
return {codes:sheet.items.map(x=>x.Code),parsed:EstQuantity.fromWpt('N1','test',doc,[],{}).rows[0].markers};
|
||||
})()""",
|
||||
)
|
||||
assert result == {"codes": ["r000000", "r000002", "r000003"], "parsed": ["r000002"]}
|
||||
|
||||
|
||||
def test_manual_and_selected_sheet_totals_validate_before_application():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -651,3 +651,29 @@ def test_text_total_without_result_marker_evaluates_its_expression():
|
||||
assert result["rows"][0]["TC"] == 20
|
||||
assert result["rows"][1]["TC"] == 20
|
||||
assert result["rows"][2]["TC"] == 0
|
||||
|
||||
|
||||
def test_numbered_speed_variable_attached_to_korean_label():
|
||||
result = run(
|
||||
"math",
|
||||
r"""EstText.calculate([
|
||||
{Index:1,Code:'C'},
|
||||
{LineData:' 주유소V41=25 입구 V51=12'},
|
||||
{LineData:' Q = V41 + V51 = \x1237\x12'}
|
||||
])""",
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert result["rows"][2]["TC"] == 37
|
||||
|
||||
|
||||
def test_invalid_unmarked_component_does_not_reuse_previous_declaration():
|
||||
result = run(
|
||||
"math",
|
||||
r"""EstText.calculate([
|
||||
{Index:1,Code:'C'},
|
||||
{SCheck:-3,LineData:' C = \x03100\x03'},
|
||||
{SCheck:-3,LineData:' C = \x03200\x03 + MISSING'}
|
||||
])""",
|
||||
)
|
||||
assert result["errors"]
|
||||
assert result["rows"][2].get("TC") != 100
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
from test_est_rea_parity import run
|
||||
|
||||
|
||||
def test_mean_sea_level_labels_do_not_break_variable_declarations():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, LineData:'D3 = {+}1.9m [ M.S.L ]'},
|
||||
{Index:0, LineData:'1개소당 50㎥ q = 1.53 M.S.L h1 = 3.841'},
|
||||
{Index:0, LineData:'Q = D3 + q + h1 = \\x127.27\\x12'}
|
||||
];
|
||||
return EstText.calculate(rows);
|
||||
})()""",
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert result["checks"][0]["calculated"] == 7.27
|
||||
|
||||
|
||||
def test_equals_in_description_is_not_a_formula_separator():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, LineData:'강판{T=22} 67.659kg × 317.40 = \\x1221474.97\\x12'}
|
||||
];
|
||||
return EstText.calculate(rows);
|
||||
})()""",
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert result["checks"][0]["calculated"] == 21474.97
|
||||
|
||||
|
||||
def test_korean_length_unit_ja_is_not_a_variable():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, SCheck:-1, LineData:'\\x0e목형원(제조)\\x0e : \\x03115706\\x03 × 0.16인 ×80자 = \\x061\\x06\\x121481036.8\\x12'}
|
||||
];
|
||||
return EstText.calculate(rows);
|
||||
})()""",
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert result["checks"][0]["calculated"] == 1481036.8
|
||||
|
||||
|
||||
def test_selected_group_can_be_assigned_to_a_variable():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, LineData:'0.15 = \\x06A\\x06\\x120.15\\x12'},
|
||||
{Index:0, LineData:'0.87 = \\x06A\\x06\\x120.87\\x12'},
|
||||
{Index:0, LinkIndex:62, LineData:'Q = (\\x07A\\x07\\x1a0.15 + 0.87\\x1a) = \\x121\\x12'},
|
||||
{Index:0, LineData:'Q × 2 = \\x122\\x12'}
|
||||
];
|
||||
return EstText.calculate(rows);
|
||||
})()""",
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert result["checks"][-1]["calculated"] == 2.04
|
||||
|
||||
|
||||
def test_independent_results_on_one_line_do_not_replace_the_declared_variable():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, LineData:'Q = 3.43'},
|
||||
{Index:0, LineData:'L = 2인×2조÷(Q×8hr) = \\x120.15\\x12 2×2÷(1×8) = \\x120.5\\x12'},
|
||||
{Index:0, SCheck:-1, LineData:'노무비 : 226122 × L = \\x1233918.3\\x12'}
|
||||
];
|
||||
const initial = EstText.calculate(rows);
|
||||
rows[1].LineData = 'Q = 4';
|
||||
const changed = EstText.calculate(rows);
|
||||
return {initial, changed};
|
||||
})()""",
|
||||
)
|
||||
assert result["initial"]["errors"] == []
|
||||
assert [check["calculated"] for check in result["initial"]["checks"][:2]] == [0.5, 0.15]
|
||||
assert result["initial"]["rows"][3]["TC"] == 33918.3
|
||||
assert [check["calculated"] for check in result["changed"]["checks"][:2]] == [0.5, 0.13]
|
||||
assert result["changed"]["rows"][3]["TC"] == 29395.8
|
||||
|
||||
|
||||
def test_unmarked_cost_component_declaration_is_available_to_later_formulas():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, SCheck:-3, LineData:'D = 1,466원'},
|
||||
{Index:0, SCheck:-3, LineData:'E = D ×0.075 = 1,466 ×0.075 = \\x12109.9\\x12'}
|
||||
];
|
||||
const first = EstText.calculate(rows);
|
||||
rows[1].LineData = 'D = 2,000원';
|
||||
const second = EstText.calculate(rows);
|
||||
return {first, second};
|
||||
})()""",
|
||||
)
|
||||
assert result["first"]["errors"] == []
|
||||
assert result["first"]["rows"][2]["TC"] == 109.9
|
||||
assert result["second"]["rows"][2]["TC"] == 150
|
||||
|
||||
|
||||
def test_repeated_saved_subtotal_tracks_the_preceding_subtotal():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, SCheck:-1, LineData:'100 = \\x12100\\x12'},
|
||||
{Index:0, SCheck:-3, LineData:'50 = \\x1250\\x12'},
|
||||
{Index:0, SCheck:-5, LineData:'소계 : \\x12150\\x12'},
|
||||
{Index:0, SCheck:-5, LineData:'소계 : \\x12150\\x12'}
|
||||
];
|
||||
const first=EstText.calculate(rows);
|
||||
rows[1].LineData='110 = \\x12100\\x12';
|
||||
const second=EstText.calculate(rows);
|
||||
return {first, second};
|
||||
})()""",
|
||||
)
|
||||
assert [row["TC"] for row in result["first"]["rows"][-2:]] == [150, 150]
|
||||
assert [row["TC"] for row in result["second"]["rows"][-2:]] == [160, 160]
|
||||
|
||||
|
||||
def test_comment_only_declaration_waits_for_a_later_numeric_value():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, LineData:'L = {공사구간 1/4}'},
|
||||
{Index:0, LineData:'L = 1.213 km'},
|
||||
{Index:0, LineData:'Q = L × 2 = \\x122.43\\x12'}
|
||||
];
|
||||
const normal=EstText.calculate(rows);
|
||||
rows[1].LineData='L =';
|
||||
const invalid=EstText.calculate(rows);
|
||||
return {normal, invalid};
|
||||
})()""",
|
||||
)
|
||||
assert result["normal"]["errors"] == []
|
||||
assert result["normal"]["rows"][3]["TC"] == 2.43
|
||||
assert result["invalid"]["errors"]
|
||||
|
||||
|
||||
def test_unmarked_linked_price_declaration_excludes_name_and_specification_tags():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, SCheck:-3, LinkIndex:5,
|
||||
LineData:'C = \\x0e구역화물\\x0e\\x13(10.5톤,230km)\\x13 \\x03293,690\\x03'},
|
||||
{Index:0, LineData:'Q = C × 0.1 = \\x1229369\\x12'}
|
||||
];
|
||||
const original = EstText.calculate(rows);
|
||||
const refreshed = EstText.calculate(rows, {}, () => ({JC: 400000}));
|
||||
return {original, refreshed};
|
||||
})()""",
|
||||
)
|
||||
assert result["original"]["errors"] == []
|
||||
assert result["original"]["rows"][2]["TC"] == 29369
|
||||
assert result["refreshed"]["rows"][2]["TC"] == 40000
|
||||
|
||||
|
||||
def test_tonnes_per_area_is_a_unit_in_a_numeric_formula():
|
||||
result = run(
|
||||
"math",
|
||||
"""(() => {
|
||||
const rows = [
|
||||
{Index:1, Code:'D1'},
|
||||
{Index:0, LineData:'0.1 ㎥/㎡ × 2.10 t/㎡ = \\x120.21\\x12'}
|
||||
];
|
||||
const first=EstText.calculate(rows);
|
||||
rows[1].LineData='0.1 ㎥/㎡ × 2.50 t/㎡ = \\x120.21\\x12';
|
||||
const second=EstText.calculate(rows);
|
||||
return {first, second};
|
||||
})()""",
|
||||
)
|
||||
assert result["first"]["errors"] == []
|
||||
assert result["first"]["rows"][1]["TC"] == 0.21
|
||||
assert result["second"]["rows"][1]["TC"] == 0.25
|
||||
@@ -423,6 +423,73 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
|
||||
## Codex · EST 독립 테스트 앱 (2026-11-04)
|
||||
|
||||
- [x] JDL 출력에서 중복 자원 코드와 내부 고정금액 코드 충돌로 다른 단가에 연결되는 문제를 차단하고 왕복 검증한다.
|
||||
- 자체 검증: 중복 자원 세 개를 모두 내보내며 모호한 내역은 저장 금액으로 독립 전개하고, 기존 `LITERAL2` 코드와 내부 고정금액이 충돌하지 않음 확인. JDL·업무 회귀 30개, 중복 자원 세 건과 중복 일위대가의 잘못된 상세 연결 방지 별도 시험 통과. 설치 교량 자료의 JDL 출력·재가져오기 내역 합계 7,989,852,162원 유지. 변환 불가 구성 경고는 유지한다.
|
||||
|
||||
- [x] 수량산출 행 이동·삭제 시 문서 표지의 반복 전체 검색을 줄이고 공유 표지 보호 및 실제 ESTX 저장을 검증한다.
|
||||
- 자체 검증: 표지 빈도를 한 번 집계하여 공유 표지 삭제·이동 차단. 숫자가 비슷한 표지와 추가 표지 1만 개 시험, 실제 설치 ESTX 이동·삭제·재열기 및 인덱스 보존을 포함한 수량·원본 저장 회귀 55개 통과(6분 21초). 문서 해석의 다른 반복 처리에는 이 최적화를 적용하지 않았다.
|
||||
|
||||
- [x] 선택 구간 합계를 변수에 대입한 산식을 계산한다.
|
||||
- 자체 검증: 가시설 원본의 Q 대입과 이후 변수 참조 재현, 산근·수량 업무 시험 63개 통과. 설치 자료 24개 변경 전후 비교에서 이번 산식 보완 전체의 오류 25→15건, 저장 결과 일치 114,371→114,378건, 파일별 일치 감소 없음.
|
||||
|
||||
- [x] 산출근거 숫자 뒤 길이 단위 `자`를 인식한다.
|
||||
- 자체 검증: 교량 자료의 목형 산식 재현, 산근·수량 업무 시험 62개 통과. 설치 자료 24개 변경 전후 비교에서 이번 산식 보완 전체의 오류 25→16건, 저장 결과 일치 114,371→114,377건, 파일별 일치 감소 없음.
|
||||
|
||||
- [x] 산출근거 설명의 중괄호 안 등호를 계산식 구분에서 제외한다.
|
||||
- 자체 검증: 강판 규격 `{T=22}` 원본 산식 재현, 산근·수량 업무 시험 61개 통과. 설치 자료 24개 변경 전후 비교에서 앞선 M.S.L 보완을 포함해 수식 오류 25→18건, 저장 결과 일치 114,371→114,375건, 파일별 일치 감소 없음.
|
||||
|
||||
- [ ] 원본 수식 옆 수평 구분선을 분수로 인식하는 사례를 조사한다.
|
||||
- 진행: 리터럴 옆 구분선만 제외하는 변경으로 오류가 추가 감소했지만 해양 자료의 저장 결과 일치가 30건 감소해 변경을 되돌렸다. 후속 변수와 원본 저장값의 관계를 확인한 뒤 처리한다. 원인: 같은 원본의 H1 식은 음의 수심 변수를 사용하지만 바로 뒤 숫자 대입은 양수이며 저장값 5.3도 양수 대입과 일치한다. 이를 임의 수정하지 않으면 후속 Q가 200.15 대신 229.12가 되어 차이가 전파된다.
|
||||
|
||||
- [x] 산출근거의 평균해수면 표기(M.S.L)를 수식 변수와 구분한다.
|
||||
- 자체 검증: 실제 해양 자료의 대괄호 및 같은 줄 표기 재현 시험, 산근 회귀 45개 통과. 설치 자료 24개 변경 전후 비교에서 수식 오류 25→22건, 저장 결과 일치 114,371건 유지, 파일별 일치 건수 감소 없음. 나머지 오류는 계속 조사한다.
|
||||
|
||||
- [x] 수량산출서의 ESTX 저장 안내를 현재 지원 범위에 맞춘다.
|
||||
- 자체 검증: 화면과 README의 이전 안내를 수정하고 수량 업무 시험 15개 통과, 변경 코드 포맷 확인. 복합 원본 구조와 새 산출서는 계속 제한된다.
|
||||
|
||||
- [x] 기존 수량산출서의 목록 항목 추가를 ESTX 메모리 표에 저장한다. 기존 항목과 문서·인덱스 보존, 실제 파일 저장·재읽기 및 잘못된 코드 거부를 검증한다.
|
||||
- 자체 검증: 원본 메모리 표의 신규 행 형식이 확인되는 기존 문서에 한해, 연결되지 않은 항목 추가·재읽기를 지원. 설치 main.estx의 항목 추가 후 기존 목록·산출 행·전체 인덱스 보존 및 중복 코드 거부 시험, 잘못된 연결·길이·속성 거부 시험 통과. 관련 회귀 31개 통과, 최종 변경 후 추가 시험 2개 통과. 항목 삭제·내역 연결과 빈 원본 목록은 계속 제한. 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 수량산출서에서 참조되지 않는 목록 항목 삭제를 기존 메모리 표에 저장하고 참조 항목은 보호한다. 실제 ESTX 재읽기와 인덱스 보존을 검증한다.
|
||||
- 자체 검증: 설치 main.estx의 미참조 목록 삭제 후 나머지 목록·산출 행·전체 인덱스 보존 및 재읽기, 문서 표지와 숨겨진 원본·현재 내역 연결에 의한 삭제 거부 확인. 코드가 없는 기존 목록 항목도 이름 수정 가능함을 실제 파일로 확인. 관련 회귀 35개 및 최종 추가·삭제 보호 5개, 기존 코드 누락 호환 1개와 원본·현재 연결 보호 3개 통과. 원본 EST 실행 검증은 하지 못함.
|
||||
- [ ] 설치된 JDL 검사 DLL의 호출 규약과 결과를 조사해 생성 파일 검사에 활용한다.
|
||||
- 진행: 32비트 DLL의 `GetDLLVersion` 호출은 1.03을 반환. `IsCertifiedJDL`은 디렉터리 인자를 받는 경로로 확인했으며, 기본 내보내기 파일을 임시 폴더에 풀어 호출한 결과 하위 바이트 0. `JDLCheck`는 한 문자열 인자의 stdcall 경로로 확인했으나 임시 자료 호출이 15초 안에 끝나지 않아 별도 프로세스를 종료했다. 반환 의미와 화면 없이 완료하는 방법은 미확인이며 인증 실패나 검사 성공으로 단정하지 않는다.
|
||||
- [x] 기존 수량산출서 이름을 원본 제목 표에 저장한다. 기존 문서·목록·인덱스 보존과 실제 파일 재읽기를 검증한다.
|
||||
- 자체 검증: 설치 main.estx에서 이름 변경 후 재읽기와 문서·목록·인덱스 불변 확인. 제목 표가 따로 수정된 충돌을 거부하는 시험과 숨겨진 연결 보호 시험 통과. 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 원본 수량 목록이 비어 있는 기존 산출서에도 항목 추가를 지원하고 실제 파일로 재읽기·기존 서식 보존을 검증한다.
|
||||
- 자체 검증: 설치 main.estx의 빈 목록 5개에서 같은 필드 구성을 확인하고, 한 문서에 첫 항목을 추가해 재읽기·산출 행·전체 인덱스 보존 및 입력 거부 시험 2개 통과. 확인된 필드 구성이 아닌 빈 목록은 거부한다. 관련 전체 회귀 38개는 직전 변경까지 통과. 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 신규 수량 항목과 산출 행의 결과 코드를 원본 WPTools가 인식하는 숫자 형식으로 생성하고 기존 코드·표지와 충돌하지 않도록 검증한다.
|
||||
- 자체 검증: 기존 목록 코드와 문서 결과 표지를 모두 피해 `r000000` 형식의 새 코드를 생성하고, 생성한 표지를 WPTools 파서가 인식하는지 확인. 수량 업무 시험 15개 통과. 기존 JSON의 코드는 변경하지 않으며 산출 행 ESTX 추가 저장은 아직 미지원.
|
||||
- [x] 기존 수량산출서 끝에 단일 결과 표지를 가진 산출 행을 추가해 ESTX에 저장하고, 문서 표지·계산 수량·기존 서식·인덱스를 실제 파일로 검증한다.
|
||||
- 자체 검증: 설치 main.estx에서 새 산출식 `2+3`의 결과 표지·목록 수량 5를 저장 후 재읽고 기존 산출 행·전체 인덱스 보존 확인. 중복 표지·연결 누락·복합 표지 거부 및 수량 업무 시험 통과. 관련 전체 회귀 42개 통과. 중간 삽입·이동·삭제와 원본 EST 실행 검증은 남음.
|
||||
- [x] 기존 수량산출서 중간에 새 산출 행을 삽입해 ESTX에 저장하고 기존 행·서식·계산 수량을 검증한다.
|
||||
- 자체 검증: 설치 main.estx 중간 삽입·끝 추가 후 결과 수량 5, 기존 행·인덱스 보존과 재읽기 확인. 기본 서식 선언보다 앞선 삽입은 거부. 관련 전체 회귀 43개 통과(최종 서식 위치 보호는 별도 시험 2개 추가 통과). 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 원본 수량산출서에서 단일 결과 표지 산출 행의 삭제를 지원하고 참조·서식 보존을 실제 파일로 검증한다.
|
||||
- 자체 검증: 설치 main.estx의 산출 행과 연결 목록 항목을 삭제해 재읽기, 나머지 행·항목·전체 인덱스 보존 확인. 글자 서식 선언 보존, 공유 표지·원본/현재 내역 연결 삭제 거부 확인. 관련 회귀 46개 통과. 복합 표지·내역에 연결된 행은 계속 제한하며 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 기존 산출 행을 이동할 때 문서 표지와 글자 서식을 보존해 ESTX에 저장한다. 위험한 공유 표지·서식 구조는 거부하고 실제 파일로 검증한다.
|
||||
- 자체 검증: 설치 main.estx의 결과 표지 행 두 개를 이동해 재읽기·목록 수량·전체 인덱스 보존 확인. 이동 전 선언되지 않은 글자 서식, 이동 범위 내 서식 재정의, 공유 표지 거부 및 기존 표지 15만 개에서도 신규 표지 생성 확인. 전체 회귀 48개 통과 후 보강 범위 18개 추가 통과. 이동과 동시에 행 내용을 편집하거나 복잡한 공유 서식을 이동하는 경우는 제한하며 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 고유 결과 표지가 있는 산출 행을 이동하면서 내용을 함께 수정할 때 표지로 원본 행을 찾아 서식을 보존한다.
|
||||
- 자체 검증: 합성 문서에서 이동과 산식 문자 수정을 동시에 수행해 원래 결과 표지 유지 확인. 설치 main.estx에서도 표지 행을 이동·편집 후 재읽기, 수량·인덱스 보존 확인. 이동 관련 시험 4개 및 전체 수량·저장 회귀 50개 통과. 중복 표지와 복잡한 공유 서식의 이동은 계속 제한하며 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] JDL 기계 상세 중 수량 1이고 비목별 합계가 원본 제목 금액과 일치하는 일반 행을 편집 가능한 상세 행으로 가져온다. 특수 형식과 불일치는 원래 금액 보존 경로를 유지하고 재계산을 검증한다.
|
||||
- 자체 검증: 합성 JDL에서 두 상세 행의 비목 금액 분리와 개별 수정 후 기계 합계 130→140, 불일치 및 수량 2의 보존 경로 확인. JDL·업무 회귀 29개 통과. 상세 산식·기초 단가 자동 연결과 원본 검사 DLL 인증은 미지원으로 경고하며 원본 JDL 실자료 비교는 하지 못함.
|
||||
- [x] 결과 표지가 없는 일반 산출 행의 삭제를 지원하고, 모호한 중복 행과 문서 표지·서식은 보호한다.
|
||||
- 자체 검증: 설치 main.estx에서 일반 행을 삭제한 후 문서·수량 목록·전체 인덱스의 재읽기 확인. 실제 중복 행 삭제와 문서 표지가 있는 행 삭제 거부, 글자 서식 선언 보존 확인. 관련 전체 회귀 53개 통과. 삭제와 다른 행 편집을 함께 하거나 복합 표지가 있는 행은 제한하며 원본 EST 실행 검증은 하지 못함.
|
||||
- [x] 일반 산출 행 삭제와 고유 표지 산출 행 편집을 함께 저장하고 두 행의 대응을 검증한다.
|
||||
- 자체 검증: 설치 main.estx에서 일반 행 삭제와 고유 결과 표지 행 편집을 함께 저장하고 재읽기·수량 목록·인덱스 보존 확인. 일반 삭제, 문서 표지 보호 및 중복 행 거부를 포함해 관련 전체 회귀 54개 통과. 이동이나 표지 없는 다른 행 편집을 함께 한 경우는 제한하며 원본 EST 실행 검증은 하지 못함.
|
||||
|
||||
- [ ] 설치 ESTX 24개 대조에서 나온 수식 오류와 계산값 차이를 조사하고 근거 있는 파서 오류를 수정한다. 원본 오류와 미확인 사항을 구분하고 관련 시험과 실제 파일 재대조로 확인한다.
|
||||
- 진행: 한 줄의 독립 산식 결과를 앞선 결과와 이어 붙이거나 최초 선언 변수를 덮던 문제, 결과 표지 없는 비목 선언 누락, 연속된 동일 소계의 0원 처리 수정. 설치 파일 24개 재대조에서 일치 991,444→991,456/991,517, 수식 오류 35→34, 다른 파일의 일치 수 감소 없음. 관련 회귀 82개와 수정 시나리오 3개 통과. 붙은 변수 처리의 다른 항목 회귀 69건은 재현 후 해당 변경을 되돌림. 잔여 차이·오류는 미완료.
|
||||
- 추가 진행: 설명만 있는 선언, 표지로 감싼 비목 단가, `t/㎡` 단위와 한글 설명에 붙은 번호 속도 변수 처리 수정. 설치 파일 24개 재대조에서 991,457/991,517 일치, 수식 오류 25건(기존 35건), 파일별 일치 수 감소 없음. 관련 회귀 86개 통과. 원본 산식과 저장값이 모순되는 경우 등을 계속 조사한다.
|
||||
- 추가 확인: 오류가 있는 비목 선언이 이전 변수값을 재사용하지 않는 시험 통과. 산림토목·조경·측량설계의 일부 잔여 차이는 저장 정수와 현재 소수 계산값의 정밀도 차이이며, 근거 없이 정밀도를 변경하지 않는다. 상수공사 1건은 결과 표지가 `1.7`에만 적용되고 뒤에 `9`가 남은 원본 형식이다. 테스트 서버는 테일스케일 주소에서 HTTP 200, 자동 재시작 감시 프로세스 동작 확인.
|
||||
|
||||
- [x] 두 사용자 실무 자료의 편집·업무·저장·출력 경로 확인.
|
||||
- 자체 검증: 첫 파일 보정본과 두 번째 원본에서 Edge 수량 수정 및 취소, 당초 비교, 기성, 물가변동, 연차 40/60 계산, 분리된 시험 저장소 저장·재열기 통과. 내역 인쇄 미리보기 각각 103·144행 전체 확인. 전체 Excel 및 ESTX 내보내기→재가져오기에서 계산 오류 0·도급액 508,328,000원·1,352,416,000원 유지. 관련 회귀 70개 통과. 원본 EST 프로그램 실행 비교는 수행하지 않음.
|
||||
|
||||
- [x] 기계경비 주연료 역할명과 연결 자재명이 다른 정상 행의 오류 오탐 수정. 연결 누락과 규격·단위 오류는 유지.
|
||||
- 자체 검증: 오류 검색 시험 13개 통과. 두 실무 파일의 주연료 오탐 23건·3건 제거; 연결 누락·단위 불일치와 일반 자재 품명 차이는 계속 검출. 첫 파일은 원본 괄호 오류가 남아 있으며 저장된 0.56을 유지하는 보정 작업본에서 계산 오류·자료 오류 0, 내역 103행 금액과 도급액 불변을 확인. 원본 EST 실행 동작은 미확인.
|
||||
|
||||
- [x] JDL 기계경비·단가산출 저장 금액을 편집 가능한 호표로 가져오기, 미변환 일위 구성 금액 보존. 상세 산식은 원본에 보존하고 경고.
|
||||
- 자체 검증: 고정 금액 변경→자동계산→내역 전파, 소수 금액, 일위 상세 불일치의 금액 중복 방지 및 미변환 잔액, 변경 없는 JDL 바이트 보존 시험. Edge에서 JDL 불러오기 경고와 기계·단가산출 편집 확인. 전용 회귀 270개 통과 후 최종 관련 계산·입출력 시험 60개 통과. 기존 원가 계산 시험에서 누락된 조정 추적값도 복구.
|
||||
|
||||
- [x] 수량산출 항목을 대용량 내역에 반영할 때 반복 검색을 줄이고 기존 연결·중복 오류 판정을 유지한다.
|
||||
- 자체 검증: 5,000개 항목과 내역 연결 및 중복 식별자 거부 확인. 수량 기능 시험 13개 통과.
|
||||
- [x] 수량산출 화면에서 내역 선택 목록을 필요할 때 생성하여 대용량 화면의 과도한 옵션 생성을 줄인다.
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력·연차 수식·미확정 기성과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems`, 미확정 기성은 `ProgressDrafts`, `ProgressInputs` 시트를 함께 가져옵니다. 확정 기성의 계약 자료와 누계는 Excel 가져오기 시 유지합니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·확정 기성 자료·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다.
|
||||
- **물가변동 Excel**: `Escalation` 시트에서 입찰 당시 단가, 적용 제외 관련 수량과 자동 여부를 편집하고 `EscalationSettings`에서 조사 도급액을 입력합니다. 행 식별자가 없거나 중복되면 가져오기를 중단합니다.
|
||||
- **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다.
|
||||
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 일위대가 상세는 기본 플래그와 연결 자원이 확인된 행을 저장 금액과 함께 불러오며 특수 행은 원문에 보존하고 경고합니다. 자재·노무로 구성된 기본 일위대가는 상세표로 내보냅니다. 가져온 작업을 변경하지 않았다면 원본 JDL을 바이트 그대로 내보내 확장 자료를 보존합니다. 변경된 작업의 특수 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
|
||||
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
|
||||
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 일위대가 상세는 기본 플래그와 연결 자원이 확인된 행을 불러오고 미변환 구성은 저장 금액으로 보존합니다. 기계경비와 단가산출은 저장된 비목별 금액을 편집 가능한 호표로 가져오며 상세 산식은 원본 JDL에 보존합니다. 자재·노무로 구성된 기본 일위대가는 상세표로 내보냅니다. 가져온 작업을 변경하지 않았다면 원본 JDL을 바이트 그대로 내보내 확장 자료를 보존합니다. 변경된 작업의 특수 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
|
||||
- 중복 자원 코드로 연결 대상을 구분할 수 없는 JDL 출력은 해당 내역의 저장 금액을 독립 단가로 기록하고 경고합니다. 자원 행 자체는 각각 보존합니다.
|
||||
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 기존 수량산출서의 이름과 항목을 수정하고, 확인된 형식의 항목과 단일 결과 표지 산출 행을 추가·삭제할 수 있습니다. 고유한 일반 행 삭제와 조건에 맞는 산출 행 이동도 지원합니다. 연결된 항목이나 중복·복합 표지, 불명확한 서식 구조는 보호를 위해 일부 작업을 거부합니다. 기존 서식 구간의 텍스트를 편집할 때 새 글에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 수량산출서, 인덱스 키 변경, 수량의 내역 연결 변경, 웹 기성·연차·당초 사본은 ESTX 저장을 지원하지 않아 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
|
||||
- **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
|
||||
- **기성 진도 반영**: 예정공정표에서 최근 확정 회차의 누계 수량을 해당 회차 계약 수량으로 나누어 연결된 공정의 진도를 갱신할 수 있습니다. 수량이 0이거나 기성 내역에 연결되지 않은 공정은 기존 진도를 유지합니다. 0~100%를 벗어나는 결과는 갱신을 중단합니다.
|
||||
- **기성 초기화**: 기성 화면에서 모든 회차와 확정 결과를 한 번에 초기화할 수 있습니다. 내역과 다른 업무 자료는 유지하고 실행 취소로 복원할 수 있습니다.
|
||||
@@ -67,7 +68,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
|
||||
|
||||
원본 ESTX의 기존 수량산출 항목은 품명·규격을 수정해 ESTX에 저장할 수 있습니다. 연결 변경과 새 항목 추가·삭제는 계속 JSON으로 보관합니다.
|
||||
원본 ESTX의 기존 수량산출 항목은 품명·규격을 수정하고, 지원되는 형식의 항목과 산출 행을 추가·삭제해 ESTX에 저장할 수 있습니다. 내역 연결 변경은 JSON으로 보관합니다.
|
||||
품명·규격만 변경한 경우 기존 산식의 저장 수량과 재계산 수량이 다르더라도 산식과 저장 수량을 그대로 유지합니다.
|
||||
산출서 문구를 수정해도 계산 결과와 결과 표지가 바뀌지 않으면 원래 저장 수량을 보존합니다. 원본 결과가 재계산값과 다른 문서에서 계산 결과를 바꾸는 편집은 JSON으로 보관해야 합니다.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ const zlib = require("node:zlib");
|
||||
const { importEstx, unzip, Values, memoryRows } = require("./import-estx.cjs");
|
||||
const { pack } = require("./archive.cjs");
|
||||
const { updateOptions } = require("./export-options.cjs");
|
||||
const { updateQuantities } = require("./export-quantity.cjs");
|
||||
const { updateQuantities, quantityFields } = require("./export-quantity.cjs");
|
||||
const { updatePrintOptions } = require("./export-print.cjs");
|
||||
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex");
|
||||
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
@@ -68,7 +68,7 @@ function value(data) {
|
||||
return Buffer.concat([Buffer.from([1]), ...data.map(value), Buffer.from([0])]);
|
||||
throw Error("지원하지 않는 TPF0 값");
|
||||
}
|
||||
function memory(buffer, rows) {
|
||||
function memory(buffer, rows, allowListChanges = false) {
|
||||
const compressed = buffer[0] === 0x78,
|
||||
bytes = compressed ? zlib.inflateSync(buffer) : buffer,
|
||||
reader = new Values(bytes),
|
||||
@@ -76,10 +76,26 @@ function memory(buffer, rows) {
|
||||
while (reader.position < bytes.length) values.push(reader.value());
|
||||
const names = values[1].filter((v, i) => i % 10 === 0),
|
||||
entries = values[4];
|
||||
if (rows.length !== entries.length)
|
||||
if (rows.length !== entries.length && !allowListChanges)
|
||||
throw Error("원가 서식 행 추가/삭제는 ESTX 인덱스 재생성이 필요합니다.");
|
||||
const oldRows = allowListChanges ? memoryRows(buffer) : [],
|
||||
oldCodes = new Map(oldRows.map((row, index) => [row.Code, index])),
|
||||
sameCodes =
|
||||
allowListChanges &&
|
||||
same(
|
||||
oldRows.map((row) => row.Code),
|
||||
rows.map((row) => row.Code),
|
||||
);
|
||||
if (allowListChanges && !sameCodes && oldCodes.size !== oldRows.length)
|
||||
throw Error("수량 목록의 기존 코드가 중복됩니다.");
|
||||
if (
|
||||
allowListChanges &&
|
||||
rows.some((row) => !oldCodes.has(row.Code)) &&
|
||||
((!entries.length && !same(names, quantityFields)) || entries.some((entry) => entry[0] !== 1))
|
||||
)
|
||||
throw Error("수량 목록의 신규 행 형식을 확인할 수 없습니다.");
|
||||
values[4] = rows.map((row, i) => [
|
||||
entries[i][0],
|
||||
entries[allowListChanges && !sameCodes ? oldCodes.get(row.Code) : i]?.[0] ?? 1,
|
||||
...names.flatMap((name) => (row[name] == null ? [true] : [false, row[name]])),
|
||||
]);
|
||||
const result = Buffer.concat([Buffer.from("TPF0"), ...values.map(value)]);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
const zlib = require("node:zlib");
|
||||
const quantity = require("./native-quantity.cjs");
|
||||
const { calculateQuantity } = require("./quantity-calculate.cjs");
|
||||
const { memoryRows } = require("./import-estx.cjs");
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const quantityFields = [
|
||||
"Index",
|
||||
"Code",
|
||||
"LinkIndex",
|
||||
"LinkCode",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"GN_",
|
||||
];
|
||||
|
||||
function escapeText(text) {
|
||||
return [...text]
|
||||
@@ -91,9 +103,62 @@ function editStyledRuns(div, before, after, parse) {
|
||||
// Change text inside an existing styled run, never WPTools tags or bookmarks.
|
||||
function editDocument(text, oldSheet, sheet) {
|
||||
const withoutLabels = (items) => items.map(({ Name, Size, Unite, ...rest }) => rest);
|
||||
const oldCodes = new Set(oldSheet.items.map((item) => item.Code));
|
||||
const sameCodes = same(
|
||||
oldSheet.items.map((item) => item.Code),
|
||||
sheet.items.map((item) => item.Code),
|
||||
);
|
||||
const codes = new Set();
|
||||
for (const item of sheet.items) {
|
||||
if (!sameCodes && (!item.Code || codes.has(item.Code)))
|
||||
throw Error("수량 항목 코드가 없거나 중복됩니다.");
|
||||
codes.add(item.Code);
|
||||
}
|
||||
for (const item of oldSheet.items) {
|
||||
if (codes.has(item.Code)) continue;
|
||||
if (
|
||||
item.TargetCode ||
|
||||
item.TargetKey ||
|
||||
item.LinkCode ||
|
||||
item.LinkIndex ||
|
||||
oldSheet.rows.some(
|
||||
(row) => (row.markers || []).includes(item.Code) || row.LineData.includes(item.Code),
|
||||
)
|
||||
)
|
||||
throw Error("참조 중인 수량 항목은 ESTX에서 삭제할 수 없습니다.");
|
||||
}
|
||||
const added = sheet.items.filter((item) => !oldCodes.has(item.Code));
|
||||
for (const item of added) {
|
||||
if (!/^r\d{6}$/.test(item.Code || "")) throw Error("수량 항목 코드가 없거나 중복됩니다.");
|
||||
if (item.TargetCode || item.TargetKey || item.LinkCode || item.LinkIndex)
|
||||
throw Error("신규 수량 항목의 내역 연결은 ESTX 저장을 지원하지 않습니다.");
|
||||
if (
|
||||
[
|
||||
[item.Name, 80],
|
||||
[item.Size, 80],
|
||||
[item.Unite, 8],
|
||||
].some(
|
||||
([value, limit]) => value != null && (typeof value !== "string" || value.length > limit),
|
||||
) ||
|
||||
(item.GN != null && (typeof item.GN !== "number" || !Number.isFinite(item.GN)))
|
||||
)
|
||||
throw Error("신규 수량 항목의 이름·규격·단위 또는 수량 형식 오류");
|
||||
if (
|
||||
Object.keys(item).some(
|
||||
(key) => !["Code", "Name", "Size", "Unite", "GN", "TargetCode"].includes(key),
|
||||
)
|
||||
)
|
||||
throw Error("신규 수량 항목에 저장할 수 없는 속성이 있습니다.");
|
||||
}
|
||||
if (
|
||||
!same({ ...oldSheet, rows: [], items: [] }, { ...sheet, rows: [], items: [] }) ||
|
||||
!same(withoutLabels(oldSheet.items), withoutLabels(sheet.items)) ||
|
||||
!same(
|
||||
{ ...oldSheet, Name: "", rows: [], items: [] },
|
||||
{ ...sheet, Name: "", rows: [], items: [] },
|
||||
) ||
|
||||
!same(
|
||||
withoutLabels(oldSheet.items.filter((item) => codes.has(item.Code))),
|
||||
withoutLabels(sheet.items.filter((item) => oldCodes.has(item.Code))),
|
||||
) ||
|
||||
oldSheet.rows.length !== sheet.rows.length
|
||||
)
|
||||
throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다.");
|
||||
@@ -151,18 +216,274 @@ function editDocument(text, oldSheet, sheet) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function appendRows(text, sheet, rows) {
|
||||
if (!/<cs\s+nr=1\b/i.test(text)) throw Error("수량산출서 기본 글자 서식을 찾을 수 없습니다.");
|
||||
const used = new Set(
|
||||
[...text.matchAll(/<begin\b[^>]*>/gi)]
|
||||
.map((match) => quantity.entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1])
|
||||
.filter(Boolean),
|
||||
);
|
||||
let nextTag = 1;
|
||||
for (const match of text.matchAll(/\bTag=(\d+)/gi))
|
||||
nextTag = Math.max(nextTag, Number(match[1]) + 1);
|
||||
for (const row of rows) {
|
||||
const matches = [...row.LineData.matchAll(/\x12([^\x12]*)\x12/g)];
|
||||
const code = row.markers?.[0];
|
||||
if (
|
||||
matches.length !== 1 ||
|
||||
row.markers?.length !== 1 ||
|
||||
!/^r\d{6}$/.test(code || "") ||
|
||||
used.has(code)
|
||||
)
|
||||
throw Error("추가 산출 행은 단일 결과 표지와 숫자 코드가 필요합니다.");
|
||||
if (!sheet.items.some((item) => item.Code === code))
|
||||
throw Error("추가 산출 행에 연결된 수량 항목이 없습니다.");
|
||||
used.add(code);
|
||||
const [match] = matches;
|
||||
const plain =
|
||||
row.LineData.slice(0, match.index) + row.LineData.slice(match.index + match[0].length);
|
||||
if (/[\x00-\x1f]|\^\(/.test(plain) || /[\x00-\x1f]/.test(match[1]))
|
||||
throw Error("추가 산출 행의 복합 표지나 위첨자는 ESTX 저장을 지원하지 않습니다.");
|
||||
const tag = nextTag++;
|
||||
text += `<div cs=1 wpsty=[[IndentLeft:0;IndentFirst:0;IndentRight:0;]]><c nr=1/>${escapeText(row.LineData.slice(0, match.index))}<begin Tag=${tag} style="Name:'${code}';" wpsty=[[Type:3;Mode:3;Name:"${code}";]] ch=2/>${escapeText(match[1])}<end Tag=${tag} ch=3/>${escapeText(row.LineData.slice(match.index + match[0].length))}</div>\r\n`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function insertRows(text, sheet, inserts) {
|
||||
let staged = text;
|
||||
const snippets = inserts.map(({ row, at }) => {
|
||||
const next = appendRows(staged, sheet, [row]);
|
||||
const snippet = next.slice(staged.length);
|
||||
staged = next;
|
||||
return { at, snippet };
|
||||
});
|
||||
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
|
||||
let index = 0;
|
||||
const stylePosition = text.search(/<cs\s+nr=1\b/i);
|
||||
const updated = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div, offset) => {
|
||||
if (!quantity.fromWpt("", "", declarations + div, [], {}).rows.length) return div;
|
||||
const before = snippets
|
||||
.filter((item) => item.at === index)
|
||||
.map((item) => item.snippet)
|
||||
.join("");
|
||||
if (before && offset < stylePosition)
|
||||
throw Error("추가 위치 앞에 기본 글자 서식이 없어 ESTX에 저장할 수 없습니다.");
|
||||
index++;
|
||||
return before + div;
|
||||
});
|
||||
return (
|
||||
updated +
|
||||
snippets
|
||||
.filter((item) => item.at === index)
|
||||
.map((item) => item.snippet)
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
function removeRows(text, sheet, codes, plainIndexes = new Set()) {
|
||||
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
|
||||
const tags = tagCounts(text);
|
||||
let index = 0;
|
||||
const removed = new Set();
|
||||
const removedPlain = new Set();
|
||||
const document = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
|
||||
const parsed = quantity.fromWpt("", "", declarations + div, [], {}).rows;
|
||||
if (!parsed.length) return div;
|
||||
const rowIndex = index++,
|
||||
row = sheet.rows[rowIndex],
|
||||
code = row?.markers?.[0];
|
||||
if (!row || !same(parsed[0], row)) throw Error("수량산출서 원본 행 대응 오류");
|
||||
if (!codes.has(code) && !plainIndexes.has(rowIndex)) return div;
|
||||
if (plainIndexes.has(rowIndex)) {
|
||||
if (row.markers?.length || /<(?!\/?div\b|c\b|cs\b)[^>]+>/i.test(div))
|
||||
throw Error("문서 표지나 특수 서식이 있는 산출 행은 ESTX에서 삭제할 수 없습니다.");
|
||||
removedPlain.add(rowIndex);
|
||||
return [...div.matchAll(/<cs\b[^>]*>/gi)].map((match) => match[0]).join("");
|
||||
}
|
||||
const item = sheet.items.find((value) => value.Code === code);
|
||||
const begins = [...div.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)];
|
||||
if (
|
||||
row.markers.length !== 1 ||
|
||||
removed.has(code) ||
|
||||
!item ||
|
||||
item.TargetCode ||
|
||||
item.TargetKey ||
|
||||
item.LinkCode ||
|
||||
item.LinkIndex ||
|
||||
begins.length !== 1 ||
|
||||
tags.get(begins[0]?.[1]) !== 2 ||
|
||||
/<(?!\/?div\b|c\b|cs\b|begin\b|end\b)[^>]+>/i.test(div)
|
||||
)
|
||||
throw Error("다른 표지나 연결을 포함한 산출 행은 ESTX에서 삭제할 수 없습니다.");
|
||||
removed.add(code);
|
||||
return [...div.matchAll(/<cs\b[^>]*>/gi)].map((match) => match[0]).join("");
|
||||
});
|
||||
if (
|
||||
index !== sheet.rows.length ||
|
||||
removed.size !== codes.size ||
|
||||
removedPlain.size !== plainIndexes.size
|
||||
)
|
||||
throw Error("삭제할 산출 행을 원본 문서에서 찾을 수 없습니다.");
|
||||
return document;
|
||||
}
|
||||
|
||||
function tagCounts(text) {
|
||||
const counts = new Map();
|
||||
for (const match of text.matchAll(/\bTag=(\d+)(?=\D|$)/gi))
|
||||
counts.set(match[1], (counts.get(match[1]) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
function markerCounts(values) {
|
||||
const counts = new Map();
|
||||
for (const row of values) {
|
||||
if (!row.markers?.length) continue;
|
||||
const key = JSON.stringify(row.markers);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function reorderRows(text, sheet, rows) {
|
||||
const before = markerCounts(sheet.rows),
|
||||
after = markerCounts(rows);
|
||||
const keyFor = (row) => {
|
||||
const markers = JSON.stringify(row.markers);
|
||||
return row.markers?.length && before.get(markers) === 1 && after.get(markers) === 1
|
||||
? `marker:${markers}`
|
||||
: `row:${JSON.stringify(row)}`;
|
||||
};
|
||||
const remaining = new Map();
|
||||
sheet.rows.forEach((row, index) => {
|
||||
const key = keyFor(row);
|
||||
if (!remaining.has(key)) remaining.set(key, { indexes: [], next: 0 });
|
||||
remaining.get(key).indexes.push(index);
|
||||
});
|
||||
const order = [];
|
||||
for (const row of rows) {
|
||||
const entry = remaining.get(keyFor(row));
|
||||
if (!entry || entry.next >= entry.indexes.length) return { text, rows: sheet.rows };
|
||||
order.push(entry.indexes[entry.next++]);
|
||||
}
|
||||
if (order.length !== sheet.rows.length || order.every((value, index) => value === index))
|
||||
return { text, rows: sheet.rows };
|
||||
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
|
||||
const divs = [...text.matchAll(/<div\b[^>]*>[\s\S]*?<\/div>/gi)].filter(
|
||||
(match) => quantity.fromWpt("", "", declarations + match[0], [], {}).rows.length,
|
||||
);
|
||||
if (divs.length !== sheet.rows.length) throw Error("수량산출서 원본 행 대응 오류");
|
||||
if (
|
||||
divs.some(
|
||||
(match, index) =>
|
||||
!same(quantity.fromWpt("", "", declarations + match[0], [], {}).rows[0], sheet.rows[index]),
|
||||
)
|
||||
)
|
||||
throw Error("수량산출서 원본 행 대응 오류");
|
||||
const changed = order
|
||||
.map((source, index) => (source === index ? -1 : index))
|
||||
.filter((n) => n >= 0);
|
||||
const first = divs[changed[0]].index;
|
||||
const styles = new Set(
|
||||
[...text.slice(0, first).matchAll(/<cs\b[^>]*\bnr=(\d+)\b/gi)].map((m) => m[1]),
|
||||
);
|
||||
const last = divs[changed.at(-1)];
|
||||
const redefined = new Set(
|
||||
[...text.slice(first, last.index + last[0].length).matchAll(/<cs\b[^>]*\bnr=(\d+)\b/gi)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
);
|
||||
const tags = tagCounts(text);
|
||||
for (const index of changed) {
|
||||
const div = divs[index][0];
|
||||
const referenced = [
|
||||
...[...div.matchAll(/<div\b[^>]*\bcs=(\d+)/gi)].map((m) => m[1]),
|
||||
...[...div.matchAll(/<c\b[^>]*\bnr=(\d+)/gi)].map((m) => m[1]),
|
||||
];
|
||||
const begins = [...div.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)].map((m) => m[1]);
|
||||
const ends = [...div.matchAll(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi)].map((m) => m[1]);
|
||||
if (
|
||||
/<cs\b/i.test(div) ||
|
||||
referenced.some((id) => !styles.has(id) || redefined.has(id)) ||
|
||||
/<(?!\/?div\b|c\b|begin\b|end\b)[^>]+>/i.test(div) ||
|
||||
!same(begins.slice().sort(), ends.slice().sort()) ||
|
||||
begins.some((id) => tags.get(id) !== 2)
|
||||
)
|
||||
throw Error("공유 표지나 글자 서식이 있는 산출 행은 ESTX에서 이동할 수 없습니다.");
|
||||
}
|
||||
let index = 0;
|
||||
const document = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
|
||||
if (!quantity.fromWpt("", "", declarations + div, [], {}).rows.length) return div;
|
||||
return divs[order[index++]][0];
|
||||
});
|
||||
return { text: document, rows: order.map((source) => sheet.rows[source]) };
|
||||
}
|
||||
|
||||
function updateQuantities(project, base, cp949, memory) {
|
||||
const sheets = project.quantitySheets || [],
|
||||
originals = base.quantitySheets || [];
|
||||
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
|
||||
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
|
||||
const titles = (project.tables.MulTitleDat || []).map((r) => ({ ...r }));
|
||||
const savedSheets = sheets.slice();
|
||||
for (let i = 0; i < sheets.length; i++) {
|
||||
if (same(sheets[i], originals[i])) continue;
|
||||
const row = rows.find((r) => r.Code === originals[i].Code);
|
||||
const oldRow = base.tables.MulMainDat.find((r) => r.Code === originals[i].Code);
|
||||
if (sheets[i].Name !== originals[i].Name) {
|
||||
const matches = titles.filter((title) => title.Code === originals[i].Code),
|
||||
oldMatches = (base.tables.MulTitleDat || []).filter(
|
||||
(title) => title.Code === originals[i].Code,
|
||||
);
|
||||
if (matches.length !== 1 || oldMatches.length !== 1 || matches[0].Name !== oldMatches[0].Name)
|
||||
throw Error("수량산출서 원본 제목 데이터 충돌");
|
||||
if (typeof sheets[i].Name !== "string" || !sheets[i].Name.trim())
|
||||
throw Error("수량산출서 이름을 입력하세요.");
|
||||
matches[0].Name = sheets[i].Name;
|
||||
}
|
||||
if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
|
||||
throw Error("수량산출서 원본 편집기 데이터 충돌");
|
||||
const removed = new Set(
|
||||
originals[i].items
|
||||
.filter((item) => !sheets[i].items.some((next) => next.Code === item.Code))
|
||||
.map((item) => item.Code),
|
||||
);
|
||||
if (removed.size)
|
||||
for (const item of [
|
||||
...(base.tables.EstMainDat || []),
|
||||
...(project.tables.EstMainDat || []),
|
||||
]) {
|
||||
if (!item.MulCode?.binary) continue;
|
||||
const bytes = Buffer.from(item.MulCode.binary, "base64");
|
||||
let text;
|
||||
try {
|
||||
const wide =
|
||||
bytes.length > 1 &&
|
||||
bytes.length % 2 === 0 &&
|
||||
[...bytes].every((byte, index) => index % 2 === 0 || byte === 0);
|
||||
text = wide
|
||||
? bytes.toString("utf16le")
|
||||
: new TextDecoder("euc-kr", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw Error("원본 수량 연결을 확인할 수 없어 항목을 삭제할 수 없습니다.");
|
||||
}
|
||||
const [sheetCode, itemCode] = text.replace(/\0+$/, "").split("\t");
|
||||
if (sheetCode === originals[i].Code && removed.has(itemCode))
|
||||
throw Error("내역에서 참조 중인 수량 항목은 ESTX에서 삭제할 수 없습니다.");
|
||||
}
|
||||
const fields = Object.keys(memoryRows(Buffer.from(oldRow.MulList.binary, "base64"))[0] || {});
|
||||
const originalCodes = new Set(originals[i].items.map((item) => item.Code));
|
||||
const savedItems = (items) =>
|
||||
items.map((item) =>
|
||||
originalCodes.has(item.Code)
|
||||
? item
|
||||
: Object.fromEntries(
|
||||
(fields.length ? fields : quantityFields).map((field) => [
|
||||
field,
|
||||
item[field] ?? null,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const packed = Buffer.from(row.Editor.binary, "base64"),
|
||||
compressed = packed[0] === 0x78;
|
||||
const bytes = compressed
|
||||
@@ -170,21 +491,103 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
: packed;
|
||||
const text = new TextDecoder("euc-kr", { fatal: true }).decode(bytes);
|
||||
if (!cp949(text).equals(bytes)) throw Error("수량산출서 원본 인코딩 보존 불가");
|
||||
let document = editDocument(text, originals[i], sheets[i]);
|
||||
const presentMarkers = new Set(sheets[i].rows.flatMap((line) => line.markers || []));
|
||||
const deletedRows = new Set(
|
||||
originals[i].rows.flatMap((line) =>
|
||||
(line.markers || []).filter((code) => removed.has(code) && !presentMarkers.has(code)),
|
||||
),
|
||||
);
|
||||
const originalMarkers = new Set(originals[i].rows.flatMap((line) => line.markers || []));
|
||||
const existingRows = [],
|
||||
inserts = [];
|
||||
for (const line of sheets[i].rows) {
|
||||
const marker = line.markers?.[0];
|
||||
if (line.markers?.length === 1 && !originalMarkers.has(marker) && !originalCodes.has(marker))
|
||||
inserts.push({ row: line, at: existingRows.length });
|
||||
else existingRows.push(line);
|
||||
}
|
||||
let retainedRows = originals[i].rows.filter(
|
||||
(line) => !(line.markers || []).some((code) => deletedRows.has(code)),
|
||||
);
|
||||
const plainIndexes = new Set();
|
||||
if (existingRows.length < retainedRows.length) {
|
||||
const counts = new Map();
|
||||
const oldMarkers = markerCounts(retainedRows),
|
||||
newMarkers = markerCounts(existingRows);
|
||||
for (const line of originals[i].rows) {
|
||||
const key = JSON.stringify(line);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
let target = 0;
|
||||
for (let n = 0; n < originals[i].rows.length; n++) {
|
||||
const line = originals[i].rows[n];
|
||||
if ((line.markers || []).some((code) => deletedRows.has(code))) continue;
|
||||
const key = JSON.stringify(line.markers);
|
||||
const matchesMarker =
|
||||
line.markers?.length &&
|
||||
oldMarkers.get(key) === 1 &&
|
||||
newMarkers.get(key) === 1 &&
|
||||
key === JSON.stringify(existingRows[target]?.markers);
|
||||
if (target < existingRows.length && (same(line, existingRows[target]) || matchesMarker))
|
||||
target++;
|
||||
else if (!line.markers?.length && counts.get(JSON.stringify(line)) === 1)
|
||||
plainIndexes.add(n);
|
||||
else throw Error("중복되거나 함께 수정된 산출 행은 ESTX에서 삭제할 수 없습니다.");
|
||||
}
|
||||
if (target !== existingRows.length) throw Error("삭제할 산출 행을 확인할 수 없습니다.");
|
||||
retainedRows = originals[i].rows.filter(
|
||||
(line, n) =>
|
||||
!plainIndexes.has(n) && !(line.markers || []).some((code) => deletedRows.has(code)),
|
||||
);
|
||||
}
|
||||
if (existingRows.length !== retainedRows.length)
|
||||
throw Error("수량산출 행 삭제는 ESTX 저장을 지원하지 않습니다.");
|
||||
const existing = { ...sheets[i], rows: existingRows };
|
||||
const source =
|
||||
deletedRows.size || plainIndexes.size
|
||||
? removeRows(text, originals[i], deletedRows, plainIndexes)
|
||||
: text;
|
||||
let retained = {
|
||||
...originals[i],
|
||||
rows: retainedRows,
|
||||
items: originals[i].items.filter((item) => !deletedRows.has(item.Code)),
|
||||
};
|
||||
const reordered = reorderRows(source, retained, existingRows);
|
||||
retained = { ...retained, rows: reordered.rows };
|
||||
let document = editDocument(reordered.text, retained, existing);
|
||||
if (inserts.length) {
|
||||
document = insertRows(document, sheets[i], inserts);
|
||||
if (
|
||||
!same(
|
||||
quantity.fromWpt(
|
||||
sheets[i].Code,
|
||||
sheets[i].Name,
|
||||
document,
|
||||
sheets[i].items,
|
||||
sheets[i].options,
|
||||
),
|
||||
sheets[i],
|
||||
)
|
||||
)
|
||||
throw Error("추가 산출 행 재읽기 검산 실패");
|
||||
}
|
||||
if (same(sheets[i].rows, originals[i].rows)) {
|
||||
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
if (!same(sheets[i].items, originals[i].items))
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items, true).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
savedSheets[i] = { ...sheets[i], items: savedItems(sheets[i].items) };
|
||||
continue;
|
||||
}
|
||||
const baseline = calculateQuantity(originals[i]),
|
||||
calculated = calculateQuantity(sheets[i]);
|
||||
if (calculated.errors.length || baseline.errors.length)
|
||||
throw Error(`수량산출서 계산 오류: ${calculated.errors[0] || baseline.errors[0]}`);
|
||||
const markerCount = originals[i].rows.reduce((total, row) => total + row.markers.length, 0);
|
||||
const markerCount = originals[i].rows.reduce((total, row) => total + row.markers.length, 0),
|
||||
updatedMarkerCount = sheets[i].rows.reduce((total, row) => total + row.markers.length, 0);
|
||||
const results = (value) => ({
|
||||
checks: value.checks.map(({ name, calculated }) => ({ name, calculated })),
|
||||
items: value.items.map(({ Code, GN }) => ({ Code, GN })),
|
||||
@@ -193,7 +596,7 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
sheet.rows.map((row) => [...row.LineData.matchAll(/\x12[^\x12]*\x12/g)].map((m) => m[0]));
|
||||
if (
|
||||
baseline.checks.length === markerCount &&
|
||||
calculated.checks.length === markerCount &&
|
||||
calculated.checks.length === updatedMarkerCount &&
|
||||
baseline.checks.every((value) => Number.isFinite(value.calculated)) &&
|
||||
calculated.checks.every((value) => Number.isFinite(value.calculated)) &&
|
||||
same(resultMarkers(originals[i]), resultMarkers(sheets[i])) &&
|
||||
@@ -202,17 +605,18 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
||||
if (!same(sheets[i].items, originals[i].items))
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items).toString(
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items, true).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
savedSheets[i] = { ...sheets[i], items: savedItems(sheets[i].items) };
|
||||
const output = cp949(document);
|
||||
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
baseline.checks.length !== markerCount ||
|
||||
calculated.checks.length !== markerCount ||
|
||||
calculated.checks.length !== updatedMarkerCount ||
|
||||
baseline.checks.some((v) => !Number.isFinite(v.stored) || v.stored !== v.calculated) ||
|
||||
baseline.items.some((v, n) => v.GN !== originals[i].items[n].GN)
|
||||
)
|
||||
@@ -240,11 +644,11 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
|
||||
if (!same(calculated.items, originals[i].items)) {
|
||||
row.MulList = {
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), calculated.items).toString(
|
||||
binary: memory(Buffer.from(row.MulList.binary, "base64"), calculated.items, true).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
current.items = calculated.items;
|
||||
current.items = savedItems(calculated.items);
|
||||
}
|
||||
savedSheets[i] = current;
|
||||
const output = cp949(document);
|
||||
@@ -253,7 +657,15 @@ function updateQuantities(project, base, cp949, memory) {
|
||||
return {
|
||||
...project,
|
||||
quantitySheets: savedSheets,
|
||||
tables: { ...project.tables, MulMainDat: rows },
|
||||
tables: { ...project.tables, MulMainDat: rows, MulTitleDat: titles },
|
||||
};
|
||||
}
|
||||
module.exports = { editDocument, updateQuantities };
|
||||
module.exports = {
|
||||
editDocument,
|
||||
updateQuantities,
|
||||
appendRows,
|
||||
insertRows,
|
||||
removeRows,
|
||||
reorderRows,
|
||||
quantityFields,
|
||||
};
|
||||
|
||||
+185
-6
@@ -73,11 +73,16 @@ function output(project) {
|
||||
};
|
||||
const tables = Object.fromEntries(Object.keys(schema).map((k) => [k, []])),
|
||||
warnings = [],
|
||||
map = new Map();
|
||||
map = new Map(),
|
||||
ambiguous = new Set();
|
||||
let serial = 0;
|
||||
function source(kind, row) {
|
||||
const key = kind + ":" + row.Code;
|
||||
if (map.has(key)) return map.get(key);
|
||||
if (row.Code && map.has(key)) {
|
||||
map.delete(key);
|
||||
ambiguous.add(key);
|
||||
warnings.push(`${row.Code}: 중복 자원 코드는 독립 금액으로 전개합니다.`);
|
||||
}
|
||||
const prefix = kind === 5 ? "M" : kind === 6 ? "L" : "W",
|
||||
code = prefix + String(++serial).padStart(10, "0");
|
||||
const price =
|
||||
@@ -95,7 +100,7 @@ function output(project) {
|
||||
...price,
|
||||
F_ORICODE: "00000000000",
|
||||
});
|
||||
map.set(key, code);
|
||||
if (row.Code && !ambiguous.has(key)) map.set(key, code);
|
||||
return code;
|
||||
}
|
||||
for (const [kind, table] of [
|
||||
@@ -119,6 +124,8 @@ function output(project) {
|
||||
for (const title of project.tables.HeadTitleDat || []) {
|
||||
const parts = unitGroups.get(title.Code);
|
||||
if (
|
||||
!title.Code ||
|
||||
ambiguous.has("103:" + title.Code) ||
|
||||
num(title.SC) ||
|
||||
!parts?.length ||
|
||||
parts.some(
|
||||
@@ -206,7 +213,7 @@ function output(project) {
|
||||
num(row.CalcIndex) === 3 ||
|
||||
Math.abs(gn * (m + l + e) - num(row.TC)) > 0.000001
|
||||
) {
|
||||
key = source(101, {
|
||||
key = source(0, {
|
||||
Code: "LITERAL" + ++serial,
|
||||
Name: row.Name,
|
||||
Size: row.Size,
|
||||
@@ -320,6 +327,140 @@ function read(bytes, project) {
|
||||
}
|
||||
let unitPk = 0;
|
||||
const unitWarnings = [];
|
||||
const machineParts = new Map();
|
||||
for (const part of rows["jpart.txt"]) {
|
||||
if (!machineParts.has(part.F_CODE)) machineParts.set(part.F_CODE, []);
|
||||
machineParts.get(part.F_CODE).push(part);
|
||||
}
|
||||
let machineDetailed = false,
|
||||
machineFallback = false;
|
||||
for (const title of rows["junit.txt"]) {
|
||||
const gc = num(title.F_MPRICE),
|
||||
nc = num(title.F_LPRICE),
|
||||
jc = num(title.F_EPRICE);
|
||||
const amounts = { GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc };
|
||||
const parts = machineParts.get(title.F_CODE) || [];
|
||||
const uniqueSequences = new Set(parts.map((part) => part.F_SEQ));
|
||||
const basic =
|
||||
parts.length > 0 &&
|
||||
uniqueSequences.size === parts.length &&
|
||||
parts.every(
|
||||
(part) =>
|
||||
Boolean(part.F_SEQ) &&
|
||||
num(part.F_QQTY) === 1 &&
|
||||
!["%", "%"].includes(part.F_UNIT) &&
|
||||
["", "0"].includes(part.F_SPROPER) &&
|
||||
["", "T"].includes(part.F_LOADBM) &&
|
||||
["", "T"].includes(part.F_SAVEBM) &&
|
||||
!num(part.F_SRATE) &&
|
||||
["", "0"].includes(part.F_KWAN) &&
|
||||
["", "0"].includes(part.F_CLASS),
|
||||
) &&
|
||||
[
|
||||
["F_MPRICE", gc],
|
||||
["F_LPRICE", nc],
|
||||
["F_EPRICE", jc],
|
||||
].every(
|
||||
([field, value]) =>
|
||||
Math.abs(parts.reduce((sum, part) => sum + num(part[field]), 0) - value) < 1e-6,
|
||||
);
|
||||
project.tables.MachMainDat.push({
|
||||
PkID: project.tables.MachMainDat.length + 1,
|
||||
Index: 1,
|
||||
Code: title.F_CODE,
|
||||
JdlSummary: true,
|
||||
Name: title.F_DES,
|
||||
Size: title.F_SIZE,
|
||||
Unite: title.F_UNIT,
|
||||
...amounts,
|
||||
});
|
||||
if (basic) {
|
||||
machineDetailed = true;
|
||||
for (const part of parts) {
|
||||
const GC = num(part.F_MPRICE),
|
||||
NC = num(part.F_LPRICE),
|
||||
JC = num(part.F_EPRICE);
|
||||
project.tables.MachMainDat.push({
|
||||
PkID: project.tables.MachMainDat.length + 1,
|
||||
Index: 0,
|
||||
CalcIndex: 1,
|
||||
JdlSummary: true,
|
||||
JdlSourceCode: part.F_CODE1,
|
||||
Name: part.F_DES,
|
||||
Size: part.F_SIZE,
|
||||
Unite: part.F_UNIT,
|
||||
GN: 1,
|
||||
GC,
|
||||
NC,
|
||||
JC,
|
||||
SC: 0,
|
||||
TC: GC + NC + JC,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
machineFallback = true;
|
||||
project.tables.MachMainDat.push({
|
||||
PkID: project.tables.MachMainDat.length + 1,
|
||||
Index: 0,
|
||||
CalcIndex: 1,
|
||||
JdlSummary: true,
|
||||
Name: "JDL 저장 금액 (상세 산식 미변환)",
|
||||
...amounts,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (machineDetailed)
|
||||
unitWarnings.push(
|
||||
"일반 기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.",
|
||||
);
|
||||
if (machineFallback)
|
||||
unitWarnings.push(
|
||||
"일부 기계경비는 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.",
|
||||
);
|
||||
if (rows["jpart.txt"].some((r) => !byCode.has(r.F_CODE) || byCode.get(r.F_CODE).kind !== 105))
|
||||
unitWarnings.push("부모 기계 단가가 없는 상세 행은 원본 JDL에만 보존됩니다.");
|
||||
for (const title of rows["danga.txt"]) {
|
||||
const gc = num(title.F_MPRICE),
|
||||
nc = num(title.F_LPRICE),
|
||||
jc = num(title.F_EPRICE);
|
||||
project.tables.CostMainDat.push({
|
||||
PkID: project.tables.CostMainDat.length + 1,
|
||||
Index: 1,
|
||||
Code: title.F_CODE,
|
||||
JdlSummary: true,
|
||||
Name: title.F_DES,
|
||||
Size: title.F_SIZE,
|
||||
Unite: title.F_UNIT,
|
||||
GC: gc,
|
||||
NC: nc,
|
||||
JC: jc,
|
||||
SC: 0,
|
||||
TC: gc + nc + jc,
|
||||
});
|
||||
// shortcut: JDL 단가 산출 상세는 원문에만 보존; 산식 형식 확인 후 상세 변환.
|
||||
for (const [key, value, check] of [
|
||||
["GC", gc, -2],
|
||||
["NC", nc, -1],
|
||||
["JC", jc, -3],
|
||||
]) {
|
||||
const literal = value.toLocaleString("en-US", {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
project.tables.CostMainDat.push({
|
||||
PkID: project.tables.CostMainDat.length + 1,
|
||||
Index: 0,
|
||||
SCheck: check,
|
||||
JdlSummary: true,
|
||||
Name: "JDL 저장 금액",
|
||||
LineData: `${literal} = \x12${literal}\x12`,
|
||||
[key]: value,
|
||||
TC: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows["danga.txt"].length)
|
||||
unitWarnings.push("단가산출은 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.");
|
||||
const unitCodes = new Set(rows["unit.txt"].map((r) => r.F_CODE));
|
||||
const partsByUnit = new Map();
|
||||
for (const part of rows["unitpart.txt"]) {
|
||||
@@ -330,11 +471,15 @@ function read(bytes, project) {
|
||||
const gc = num(title.F_MPRICE),
|
||||
nc = num(title.F_LPRICE),
|
||||
jc = num(title.F_EPRICE);
|
||||
const parts = partsByUnit.get(title.F_CODE) || [];
|
||||
let incomplete = !parts.length;
|
||||
const included = { GC: 0, NC: 0, JC: 0 };
|
||||
project.tables.HeadMainDat.push({
|
||||
PkID: ++unitPk,
|
||||
Index: 1,
|
||||
Code: title.F_CODE,
|
||||
TitleCode: title.F_CODE,
|
||||
JdlSummary: true,
|
||||
Name: title.F_DES,
|
||||
Size: title.F_SIZE,
|
||||
Unite: title.F_UNIT,
|
||||
@@ -344,7 +489,7 @@ function read(bytes, project) {
|
||||
SC: 0,
|
||||
TC: gc + nc + jc,
|
||||
});
|
||||
for (const part of partsByUnit.get(title.F_CODE) || []) {
|
||||
for (const part of parts) {
|
||||
const source = byCode.get(part.F_CODE1);
|
||||
const basic =
|
||||
source &&
|
||||
@@ -355,12 +500,24 @@ function read(bytes, project) {
|
||||
["", "0"].includes(part.F_KWAN) &&
|
||||
["", "0"].includes(part.F_CLASS);
|
||||
if (!basic) {
|
||||
incomplete = true;
|
||||
unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 원문에만 보존`);
|
||||
continue;
|
||||
}
|
||||
const gc = num(part.F_MCOST),
|
||||
nc = num(part.F_LCOST),
|
||||
jc = num(part.F_ECOST);
|
||||
const quantity = num(part.F_QQTY) / (["%", "%"].includes(part.F_UNIT) ? 100 : 1);
|
||||
const fixedAmount = [
|
||||
[gc, num(part.F_MPRICE)],
|
||||
[nc, num(part.F_LPRICE)],
|
||||
[jc, num(part.F_EPRICE)],
|
||||
].some(([amount, price]) => Math.abs(amount - Math.trunc(quantity * price)) > 0.000001);
|
||||
if (fixedAmount)
|
||||
unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 상세의 저장 금액을 고정합니다.`);
|
||||
included.GC += gc;
|
||||
included.NC += nc;
|
||||
included.JC += jc;
|
||||
project.tables.HeadMainDat.push({
|
||||
PkID: ++unitPk,
|
||||
Index: 0,
|
||||
@@ -369,7 +526,8 @@ function read(bytes, project) {
|
||||
Size: part.F_SIZE,
|
||||
Unite: part.F_UNIT,
|
||||
LinkIndex: source.kind,
|
||||
CalcIndex: 0,
|
||||
CalcIndex: fixedAmount ? 1 : 0,
|
||||
...(fixedAmount ? { JdlSummary: true } : {}),
|
||||
GN: num(part.F_QQTY),
|
||||
GU: num(part.F_MPRICE),
|
||||
NU: num(part.F_LPRICE),
|
||||
@@ -383,6 +541,27 @@ function read(bytes, project) {
|
||||
TU: num(part.F_MPRICE) + num(part.F_LPRICE) + num(part.F_EPRICE),
|
||||
});
|
||||
}
|
||||
if (
|
||||
incomplete ||
|
||||
["GC", "NC", "JC"].some((key, i) => Math.abs([gc, nc, jc][i] - included[key]) > 0.000001)
|
||||
) {
|
||||
const gc = num(title.F_MPRICE) - included.GC,
|
||||
nc = num(title.F_LPRICE) - included.NC,
|
||||
jc = num(title.F_EPRICE) - included.JC;
|
||||
project.tables.HeadMainDat.push({
|
||||
PkID: ++unitPk,
|
||||
Index: 0,
|
||||
CalcIndex: 1,
|
||||
JdlSummary: true,
|
||||
Name: "JDL 저장 금액 차액",
|
||||
GC: gc,
|
||||
NC: nc,
|
||||
JC: jc,
|
||||
SC: 0,
|
||||
TC: gc + nc + jc,
|
||||
});
|
||||
unitWarnings.push(`일위 ${title.F_CODE}: 상세와 저장 합계의 차액을 보존합니다.`);
|
||||
}
|
||||
}
|
||||
if (rows["unitpart.txt"].some((r) => !unitCodes.has(r.F_CODE)))
|
||||
unitWarnings.push("부모 일위대가가 없는 상세 행은 원문에만 보존");
|
||||
|
||||
@@ -579,7 +579,14 @@ const EstExchange = (() => {
|
||||
});
|
||||
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
|
||||
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
|
||||
if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) EstApp.setProject(next);
|
||||
if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) {
|
||||
EstApp.setProject(next);
|
||||
if (next.importWarnings?.length)
|
||||
EstApp.showDialog(
|
||||
"JDL 가져오기 확인",
|
||||
`<ul>${next.importWarnings.map((warning) => `<li>${EstApp.esc(warning)}</li>`).join("")}</ul>`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
EstApp.state.notice = error.message;
|
||||
EstApp.render();
|
||||
|
||||
@@ -56,6 +56,13 @@ const EstIssues = (() => {
|
||||
issues.push({ table, index, code: row.Code, message: "연결 코드 중복" });
|
||||
else
|
||||
for (const field of ["Name", "Size", "Unite"]) {
|
||||
if (
|
||||
table === "MachMainDat" &&
|
||||
Number(row.Index) === -2 &&
|
||||
field === "Name" &&
|
||||
EstNative.normalize(row.Name) === "주연료"
|
||||
)
|
||||
continue;
|
||||
const a = row[field],
|
||||
b = matches[0].row[field];
|
||||
if (a && b && EstNative.normalize(a) !== EstNative.normalize(b))
|
||||
|
||||
@@ -30,9 +30,11 @@ const EstMachine = (() => {
|
||||
if (!head) return;
|
||||
components.forEach(
|
||||
(key) =>
|
||||
(head[key] = floating(
|
||||
body.filter((r) => num(r.CalcIndex) < 2).reduce((n, r) => n + num(r[key]), 0),
|
||||
)),
|
||||
(head[key] = head.JdlSummary
|
||||
? body.filter((r) => num(r.CalcIndex) < 2).reduce((n, r) => n + num(r[key]), 0)
|
||||
: floating(
|
||||
body.filter((r) => num(r.CalcIndex) < 2).reduce((n, r) => n + num(r[key]), 0),
|
||||
)),
|
||||
);
|
||||
head.TC = sum(head);
|
||||
titles.push({
|
||||
@@ -67,6 +69,7 @@ const EstMachine = (() => {
|
||||
continue;
|
||||
}
|
||||
if (![0, 2].includes(num(row.CalcIndex))) {
|
||||
if (row.JdlSummary) row.TC = sum(row);
|
||||
body.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -315,7 +315,18 @@ const EstQuantity = (() => {
|
||||
{ esc, number, byId } = EstApp;
|
||||
byId("work-content").innerHTML =
|
||||
selector(state) +
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function nextItemCode(sheet) {
|
||||
const codes = new Set([
|
||||
...sheet.items.map((item) => item.Code),
|
||||
...sheet.rows.flatMap((row) => row.markers || []),
|
||||
]);
|
||||
for (let index = 0; index < 1000000; index++) {
|
||||
const code = `r${String(index).padStart(6, "0")}`;
|
||||
if (!codes.has(code)) return code;
|
||||
}
|
||||
throw Error("수량 항목 코드를 더 만들 수 없습니다.");
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("focusin", (event) => {
|
||||
@@ -504,10 +515,7 @@ const EstQuantity = (() => {
|
||||
const addItem = event.target.closest("[data-quantity-add-item]");
|
||||
if (addItem && sheet) {
|
||||
EstApp.change(() => {
|
||||
const codes = new Set(sheet.items.map((it) => it.Code));
|
||||
let index = 0;
|
||||
while (codes.has(`r${String(index).padStart(6, "0")}`)) index++;
|
||||
const code = `r${String(index).padStart(6, "0")}`;
|
||||
const code = nextItemCode(sheet);
|
||||
sheet.items.push({
|
||||
Code: code,
|
||||
Name: "새 수량항목",
|
||||
@@ -540,7 +548,7 @@ const EstQuantity = (() => {
|
||||
const addRow = event.target.closest("[data-quantity-add]");
|
||||
if (addRow && sheet) {
|
||||
EstApp.change(() => {
|
||||
const code = `r${EstApp.uid()}`;
|
||||
const code = nextItemCode(sheet);
|
||||
const at =
|
||||
addRow.dataset.index === undefined
|
||||
? sheet.rows.length
|
||||
|
||||
@@ -92,6 +92,14 @@ const EstRows = (() => {
|
||||
function finish() {
|
||||
if (!current) return;
|
||||
Object.assign(current, add(body));
|
||||
if (current.JdlSummary) {
|
||||
components.forEach((key) => {
|
||||
current[key] = body
|
||||
.filter((row) => num(row.CalcIndex) < 2)
|
||||
.reduce((n, row) => n + num(row[key]), 0);
|
||||
});
|
||||
current.TC = sum(current);
|
||||
}
|
||||
titles.push({ ...current });
|
||||
}
|
||||
for (const [index, row] of output.entries()) {
|
||||
@@ -132,6 +140,7 @@ const EstRows = (() => {
|
||||
}
|
||||
}
|
||||
amounts(row, options.HeadDigits || 0, options.TotalFit);
|
||||
if (row.JdlSummary) row.TC = sum(row);
|
||||
}
|
||||
body.push(row);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const EstText = (() => {
|
||||
return clean(
|
||||
mathBraces(text)
|
||||
.replace(///g, "/")
|
||||
.replace(/\[\s*M\.S\.L\s*\]|(?<=\s)M\.S\.L(?=\s|$)/gi, "")
|
||||
.replace(
|
||||
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|[〔[][^〕]]*[〕]]|\[[가-힣][^\]]*\]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
|
||||
"",
|
||||
@@ -41,6 +42,8 @@ const EstText = (() => {
|
||||
"$1",
|
||||
)
|
||||
.replace(/(?<=\d)\s*\/\s*(?:일|㎥|㎡|hr)(?=\s|$|[×*+)])/gi, "")
|
||||
.replace(/(?<=\d)\s*자(?=\s*(?:[×*+÷/)=]|$))/g, "")
|
||||
.replace(/(?<=\d)\s+t\s*\/\s*(?:㎡|m2)(?=\s|$|[×*+)])/gi, "")
|
||||
.replace(/[】]〕]\s*$/, "")
|
||||
.replace(/(?<=\))\s*(?:개|인|본|면|단|회|㎥|㎡)(?:\/(?:㎡|㎥|hr))?/g, "")
|
||||
.replace(/^\s*[-*•※⊙◎]?\s*[가-힣㎡㎥][가-힣㎡㎥\s]*\s+(?=[\d.(])/, "")
|
||||
@@ -72,7 +75,12 @@ const EstText = (() => {
|
||||
return;
|
||||
}
|
||||
components.forEach(
|
||||
(key) => (head[key] = !hasAutoTotals ? floating(num(plainTotals[key])) : num(totals[key])),
|
||||
(key) =>
|
||||
(head[key] = !hasAutoTotals
|
||||
? head.JdlSummary
|
||||
? num(plainTotals[key])
|
||||
: floating(num(plainTotals[key]))
|
||||
: num(totals[key])),
|
||||
);
|
||||
head.TC = sum(head);
|
||||
titles.push({ ...head });
|
||||
@@ -263,6 +271,7 @@ const EstText = (() => {
|
||||
digits = 0;
|
||||
const assignedResults = new Map();
|
||||
const assignedNames = new Map();
|
||||
const declaredNames = new Set();
|
||||
let componentDeclaration;
|
||||
try {
|
||||
if (selectionError) throw Error(selectionError);
|
||||
@@ -307,10 +316,21 @@ const EstText = (() => {
|
||||
.replace(/=\s*$/, "")
|
||||
.trim();
|
||||
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
|
||||
body = body.replace(/^\s*[A-Za-z]\w*\s*=\s*/, "");
|
||||
calculated = body ? EstExpression.evaluate(body, (key) => variables[key]) : 0;
|
||||
}
|
||||
} else if (matched && sc === -5) {
|
||||
const section = [];
|
||||
const previous = rows[index - 1];
|
||||
const savedPrevious = String(input[index - 1]?.LineData || "").match(marker);
|
||||
if (
|
||||
num(previous?.SCheck) === -5 &&
|
||||
num(previous?.Index) <= 0 &&
|
||||
num(previous?.Level) <= 0 &&
|
||||
savedPrevious &&
|
||||
numeric(savedPrevious[1]) === oldValue
|
||||
)
|
||||
section.push(num(previous.TC));
|
||||
for (
|
||||
let j = index - 1;
|
||||
j >= 0 &&
|
||||
@@ -336,11 +356,11 @@ const EstText = (() => {
|
||||
(value) => " ".repeat(value.length),
|
||||
);
|
||||
const assignments =
|
||||
sc < 0
|
||||
sc < 0 && matched
|
||||
? []
|
||||
: [
|
||||
...assignmentText.matchAll(
|
||||
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|(?<=hr)\s+|[::]\s*|['"]\s*)([A-Za-z\u0370-\u03ff][A-Za-z0-9\u0370-\u03ff]*)\s*=/g,
|
||||
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|(?<=[가-힣])(?=V\d+\s*=)|(?<=hr)\s+|[::]\s*|['"]\s*)([A-Za-z\u0370-\u03ff][A-Za-z0-9\u0370-\u03ff]*)\s*=/g,
|
||||
),
|
||||
].filter((match) => !/[+*/÷×−-]\s*$/.test(assignmentText.slice(0, match.index)));
|
||||
for (let j = 0; j < assignments.length; j++) {
|
||||
@@ -350,11 +370,13 @@ const EstText = (() => {
|
||||
if (!matched && /⇒.*─/.test(body)) body = body.split("⇒")[0];
|
||||
if (!matched) body = body.split(/[≈≒]/)[0];
|
||||
if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\*\s*$/, "");
|
||||
if (!matched && /^\s*\{\s*[가-힣][^{}]*\}\s*$/.test(body)) continue;
|
||||
body = mathBraces(body);
|
||||
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 (sc < 0 && !matched) body = stripLabels(body);
|
||||
if (body.includes("─") && calculated !== null) {
|
||||
variables[assignment[1]] = floating(calculated, 2, 1);
|
||||
continue;
|
||||
@@ -399,6 +421,7 @@ const EstText = (() => {
|
||||
variables[assignment[1]] = result
|
||||
? floating(value, row.Precision ?? explicit?.digits ?? precision, explicit?.method ?? 1)
|
||||
: value;
|
||||
declaredNames.add(assignment[1]);
|
||||
if (result) {
|
||||
const position = text.indexOf("\x12", assignment.index + assignment[0].length);
|
||||
assignedResults.set(position, variables[assignment[1]]);
|
||||
@@ -407,7 +430,7 @@ const EstText = (() => {
|
||||
}
|
||||
}
|
||||
if (matched && calculated === null && sc === 0 && text.includes("=")) {
|
||||
let body = stripLabels(text.slice(0, text.indexOf("\x12")))
|
||||
let body = mathBraces(stripLabels(text.slice(0, text.indexOf("\x12"))))
|
||||
.replace(/\x06[^\x06]*\x06/g, "")
|
||||
.split("=")
|
||||
.filter((x) => x.trim())
|
||||
@@ -438,11 +461,12 @@ const EstText = (() => {
|
||||
const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
|
||||
const intermediate =
|
||||
results.length > 1 && sc < 0 && !explicit && row.Precision === undefined;
|
||||
calculated = floating(
|
||||
calculated,
|
||||
intermediate ? (options.MidCostDiget ?? 2) : digits,
|
||||
intermediate ? 2 : method,
|
||||
);
|
||||
if (!row.JdlSummary || num(row.DigetIndex) || row.Precision != null || num(row.RoundMode))
|
||||
calculated = floating(
|
||||
calculated,
|
||||
intermediate ? (options.MidCostDiget ?? 2) : digits,
|
||||
intermediate ? 2 : method,
|
||||
);
|
||||
const firstValue = calculated;
|
||||
let rendered = text.replace(marker, `\x12${calculated}\x12`);
|
||||
let assignmentName = assignedNames.get(results[0]?.index);
|
||||
@@ -450,14 +474,14 @@ const EstText = (() => {
|
||||
const previous = results[part - 1],
|
||||
next = results[part];
|
||||
const format = row.ResultFormats?.[part];
|
||||
const operation = expression(
|
||||
text.slice(previous.index + previous[0].length, next.index),
|
||||
).trim();
|
||||
const segment = text.slice(previous.index + previous[0].length, next.index),
|
||||
operation = expression(segment).trim(),
|
||||
separate = /=\s*$/.test(segment) && /^[\d.(]/.test(operation);
|
||||
calculated = assignedResults.has(next.index)
|
||||
? assignedResults.get(next.index)
|
||||
: floating(
|
||||
EstExpression.evaluate(
|
||||
expression(`${calculated}${operation}`),
|
||||
separate ? operation : expression(`${calculated}${operation}`),
|
||||
(key) => variables[key],
|
||||
),
|
||||
format?.digits ??
|
||||
@@ -466,7 +490,7 @@ const EstText = (() => {
|
||||
: digits),
|
||||
format?.method ?? (intermediate && part < results.length - 1 ? 2 : method),
|
||||
);
|
||||
assignmentName = assignedNames.get(next.index) || assignmentName;
|
||||
assignmentName = assignedNames.get(next.index) || (separate ? null : assignmentName);
|
||||
if (assignmentName) variables[assignmentName] = calculated;
|
||||
const old = numeric(next[1]);
|
||||
let occurrence = 0;
|
||||
@@ -512,11 +536,18 @@ const EstText = (() => {
|
||||
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
|
||||
}
|
||||
} else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) {
|
||||
const body = stripLabels(text.slice(text.indexOf(":") + 1));
|
||||
row.TC = floating(
|
||||
EstExpression.evaluate(expression(body), (key) => variables[key]),
|
||||
1,
|
||||
);
|
||||
const named =
|
||||
text.indexOf(":") < 0 &&
|
||||
assignments.length === 1 &&
|
||||
declaredNames.has(assignments[0][1]);
|
||||
if (named) row.TC = num(variables[assignments[0][1]]);
|
||||
else {
|
||||
const body = stripLabels(text.slice(text.indexOf(":") + 1));
|
||||
row.TC = floating(
|
||||
EstExpression.evaluate(expression(body), (key) => variables[key]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
row.LineData = text;
|
||||
} else if (!matched && autoTotal(row) && component[sc]) {
|
||||
let value = clean(text).split(":").slice(1).join(":").trim();
|
||||
|
||||
@@ -108,7 +108,8 @@ const EstWonga = (() => {
|
||||
|
||||
function calculate(input, options = {}, bases = {}) {
|
||||
const rows = structuredClone(input || []),
|
||||
errors = [];
|
||||
errors = [],
|
||||
adjustments = [];
|
||||
const overrides = new Map();
|
||||
|
||||
function pass() {
|
||||
@@ -127,6 +128,7 @@ const EstWonga = (() => {
|
||||
if (profit < 0) throw Error("도급액 고정: 조정할 이윤 행이 없습니다.");
|
||||
for (let iteration = 0; iteration < 100; iteration++) {
|
||||
const delta = num(rows[targetIndex].Cost) - target;
|
||||
adjustments.push({ difference: delta });
|
||||
if (delta === 0) break;
|
||||
overrides.set(profit, num(rows[profit].Cost) - delta);
|
||||
pass();
|
||||
@@ -144,6 +146,7 @@ const EstWonga = (() => {
|
||||
return {
|
||||
rows,
|
||||
errors,
|
||||
adjustments,
|
||||
total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user