From 741805ffdecfd92bae20c5fe65d4a28435a6f6f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 9 Nov 2026 19:59:33 +0900 Subject: [PATCH] feat(est-test): preserve special JDL costs --- resources/tester/test_est_jdl.py | 101 ++++++++++++++++++++++- resources/tester/test_est_jdl_special.py | 93 +++++++++++++++++++++ resources/tester/test_est_text_parser.py | 31 +++++++ tools/est-test/PLAN.md | 18 ++++ tools/est-test/README.md | 4 + tools/est-test/jdl-machine-parts.cjs | 12 +++ tools/est-test/jdl-special-unit.cjs | 42 ++++++++++ tools/est-test/jdl-unit-order.cjs | 5 +- tools/est-test/jdl-unit-part.cjs | 34 ++++++++ tools/est-test/jdl.cjs | 100 ++++++++++++---------- tools/est-test/native-text.js | 15 +++- 11 files changed, 408 insertions(+), 47 deletions(-) create mode 100644 resources/tester/test_est_jdl_special.py create mode 100644 tools/est-test/jdl-machine-parts.cjs create mode 100644 tools/est-test/jdl-special-unit.cjs create mode 100644 tools/est-test/jdl-unit-part.cjs diff --git a/resources/tester/test_est_jdl.py b/resources/tester/test_est_jdl.py index 71ff5dda3..06e8f4851 100644 --- a/resources/tester/test_est_jdl.py +++ b/resources/tester/test_est_jdl.py @@ -108,6 +108,35 @@ def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_mon assert result["warnings"] +def test_large_jdl_import_keeps_group_order_and_rejects_orphans_and_duplicates(): + 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'); + 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 n=3000,groups=Array.from({length:n},(_,i)=>({F_CODE:String(i),F_DES:'group '+i})); + const parts=groups.slice().reverse().flatMap(g=>[{F_CODE:g.F_CODE,F_SEQ:'1',F_CODE1:'M',F_DES:'first',F_QQTY:1,F_MPRICE:3}, + {F_CODE:g.F_CODE,F_SEQ:'2',F_CODE1:'M',F_DES:'second',F_QQTY:2,F_MPRICE:3}]); + function archive(gs,ps){return a.pack(new Map([['Global.ini',Buffer.from('[BASIC]')],['Main.txt',Buffer.from('0\\tSample\\t02\\x05\\r\\n')], + ['m.txt',table('m.txt',[{F_CODE:'M',F_DES:'material',F_MPRICE:3}])],['gongjong.txt',table('gongjong.txt',gs)],['mainpart.txt',table('mainpart.txt',ps)]]))} + const result=j.read(archive(groups,parts),structuredClone(empty));let orphan=false,duplicate=false; + try{j.read(archive(groups.slice(0,1),parts.slice(0,1)),structuredClone(empty))}catch{orphan=true} + try{j.read(archive([groups[0],groups[0]],[]),structuredClone(empty))}catch{duplicate=true} + const rows=result.tables.EstMainDat; + process.stdout.write(JSON.stringify({count:rows.length,first:rows.slice(0,3).map(r=>r.Name),total:rows.at(-1).TC,orphan,duplicate}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT, timeout=30 + ) + ) + assert result == { + "count": 9001, + "first": ["group 0", "first", "second"], + "total": 27000, + "orphan": True, + "duplicate": True, + } + + def test_unchanged_import_preserves_original_jdl_and_edited_work_is_not_silently_discarded(): project = run("math", "EstNative.empty()") script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs'),s=require('./tools/est-test/server.cjs'); @@ -228,7 +257,51 @@ def test_jdl_unit_price_preserves_other_source_costs_as_fixed_resources(): ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT ) ) - assert result == {"parts": 4, "links": [101, 101, 101, 101], "total": 40} + assert result == {"parts": 4, "links": [101, 101, 104, 101], "total": 40} + + +def test_jdl_cost_title_survives_direct_and_unit_price_links(): + 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 p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='cost'; + p.tables.CostTitleDat=[{Code:'D',Name:'cost',GC:100,NC:20,JC:5,TC:125}]; + p.tables.HeadTitleDat=[{Code:'U',Name:'unit',GC:100,NC:20,JC:5,TC:125}]; + p.tables.HeadMainDat=[{Index:1,Code:'U'}, + {Index:0,Code:'D',Name:'cost',LinkIndex:104,GN:1,GU:100,NU:20,JU:5,GC:100,NC:20,JC:5,TC:125}]; + p.tables.EstMainDat=[{Code:'D',Name:'direct',LinkIndex:104,GN:2,GU:100,NU:20,JU:5,GC:200,NC:40,JC:10,TC:250}, + {Code:'U',Name:'unit',LinkIndex:103,GN:3,GU:100,NU:20,JU:5,GC:300,NC:60,JC:15,TC:375},{LinkIndex:54}]; + const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); + process.stdout.write(JSON.stringify({danga:j.parse('danga.txt',files.get('danga.txt')).length, + costs:q.tables.CostMainDat,unit:q.tables.HeadMainDat[1].LinkIndex, + direct:q.tables.EstMainDat[1].LinkIndex,total:q.tables.EstMainDat.at(-1).TC,warnings:out.warnings}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert result["danga"] == 1 + assert [row["TC"] for row in result["costs"][1:]] == [100, 20, 5] + assert result["unit"] == result["direct"] == 104 + assert result["total"] == 625 + assert result["warnings"] + + +def test_jdl_duplicate_cost_titles_fall_back_without_wrong_link(): + 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 p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);p.name='duplicates'; + p.tables.CostTitleDat=[{Code:'D',Name:'one',GC:10},{Code:'D',Name:'two',GC:20}]; + p.tables.EstMainDat=[{Code:'D',Name:'item',LinkIndex:104,GN:2,GU:20,GC:40,TC:40},{LinkIndex:54}]; + const out=j.output(p),files=a.unpack(out.bytes),q=j.read(out.bytes,empty); + process.stdout.write(JSON.stringify({danga:j.parse('danga.txt',files.get('danga.txt')).length, + kind:q.tables.EstMainDat[1].LinkIndex,total:q.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 == {"danga": 0, "kind": 101, "total": 40, "warning": True} def test_jdl_nested_units_preserve_detail_or_warn_on_forward_reference(): @@ -437,6 +510,32 @@ def test_jdl_complete_unit_preserves_difference_from_saved_title(): assert [row["TC"] for row in result["titles"]] == [205.25, 30.12] +def test_jdl_fixed_unit_detail_and_saved_difference_survive_edit_and_export(): + 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')),blank=structuredClone(empty); + 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_DES:'unit',F_MPRICE:205.25}])], + ['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'M1',F_DES:'material',F_QQTY:2,F_MPRICE:100,F_MCOST:200}])], + ['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])], + ['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'U1',F_DES:'unit',F_QQTY:1,F_MPRICE:205.25}])]]); + const p=j.read(a.pack(files),empty);p.options.AutoCalc=true;p.options.SourceRefresh=true; + p.tables.HeadMainDat[2].GC=7;const q=s.calculate(p),out=j.output(q),entries=a.unpack(out.bytes),back=j.read(out.bytes,blank); + process.stdout.write(JSON.stringify({parts:j.parse('unitpart.txt',entries.get('unitpart.txt')), + rows:back.tables.HeadMainDat,total:back.tables.EstMainDat.at(-1).TC,errors:q.calculation.errors}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert len(result["parts"]) == 2 + assert [row["GC"] for row in result["rows"][1:3]] == [200, 7] + assert result["total"] == 207 + assert not result["errors"] + + 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'); diff --git a/resources/tester/test_est_jdl_special.py b/resources/tester/test_est_jdl_special.py new file mode 100644 index 000000000..e8a106577 --- /dev/null +++ b/resources/tester/test_est_jdl_special.py @@ -0,0 +1,93 @@ +import json +import subprocess +from pathlib import Path +from test_est_rea_parity import run + +ROOT = Path(__file__).resolve().parents[2] + + +def test_jdl_special_unit_detail_with_matching_totals_is_editable(): + 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')),blank=structuredClone(empty); + 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_DES:'material',F_MPRICE:100}])], + ['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:300,F_LPRICE:50}])], + ['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'M1',F_DES:'normal',F_QQTY:2,F_MPRICE:100,F_MCOST:200}, + {F_CODE:'U1',F_SEQ:'2',F_CODE1:'M1',F_DES:'special',F_QQTY:1,F_MCOST:100,F_LCOST:50,F_SPROPER:'1'}])], + ['gongjong.txt',table('gongjong.txt',[{F_CODE:'01',F_DES:'work'}])], + ['mainpart.txt',table('mainpart.txt',[{F_CODE:'01',F_SEQ:'1',F_CODE1:'U1',F_DES:'unit',F_QQTY:1,F_MPRICE:300,F_LPRICE:50}])]]); + const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.HeadMainDat);p.options.AutoCalc=true;p.options.SourceRefresh=true; + p.tables.HeadMainDat[2].GC=110;const q=s.calculate(p),out=j.output(q),back=j.read(out.bytes,blank); + process.stdout.write(JSON.stringify({before,title:q.tables.HeadTitleDat[0],rows:back.tables.HeadMainDat, + total:back.tables.EstMainDat.at(-1).TC,warnings:p.importWarnings,errors:q.calculation.errors}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert len(result["before"]) == 3 + assert result["before"][2]["Name"] == "special" + assert result["before"][2]["CalcIndex"] == 1 + assert result["title"]["TC"] == 360 + assert result["rows"][2]["GC"] == 110 + assert result["rows"][2]["NC"] == 50 + assert result["total"] == 360 + assert any("특수 구성" in warning for warning in result["warnings"]) + assert not result["errors"] + + +def test_jdl_special_unit_detail_without_source_keeps_recorded_amount(): + 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'); + 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')], + ['unit.txt',table('unit.txt',[{F_CODE:'U1',F_DES:'unit',F_MPRICE:70}])], + ['unitpart.txt',table('unitpart.txt',[{F_CODE:'U1',F_SEQ:'1',F_CODE1:'missing',F_DES:'special',F_QQTY:1,F_MCOST:70,F_SPROPER:'1'}])]]); + const p=j.read(a.pack(files),empty);process.stdout.write(JSON.stringify({rows:p.tables.HeadMainDat,warnings:p.importWarnings}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert len(result["rows"]) == 2 + assert result["rows"][1]["GC"] == 70 + assert result["rows"][1]["LinkIndex"] == 0 + assert result["rows"][1]["JdlSourceCode"] == "missing" + assert any("특수 구성" in warning for warning in result["warnings"]) + + +def test_jdl_special_machine_detail_is_fixed_only_when_amounts_match(): + 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')),blank=structuredClone(empty); + 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_CODE:'J2',F_DES:'mismatch',F_MPRICE:50},{F_CODE:'J3',F_DES:'percent',F_MPRICE:50}, + {F_CODE:'J4',F_DES:'weighted',F_MPRICE:100}])], + ['jpart.txt',table('jpart.txt',[{F_CODE:'J1',F_SEQ:'1',F_DES:'normal',F_QQTY:1,F_MPRICE:60}, + {F_CODE:'J1',F_SEQ:'2',F_DES:'special',F_QQTY:1,F_MPRICE:40,F_LPRICE:20,F_SPROPER:'1'}, + {F_CODE:'J2',F_SEQ:'1',F_DES:'mismatch',F_QQTY:1,F_MPRICE:10,F_SPROPER:'1'}, + {F_CODE:'J3',F_SEQ:'1',F_DES:'percent',F_UNIT:'%',F_QQTY:1,F_MPRICE:50,F_SPROPER:'1'}, + {F_CODE:'J4',F_SEQ:'1',F_DES:'weighted part',F_QQTY:2,F_MPRICE:50,F_SPROPER:'1'}])]]); + const p=j.read(a.pack(files),empty),before=structuredClone(p.tables.MachMainDat);p.options.AutoCalc=true; + p.tables.MachMainDat[2].GC=50;const q=s.calculate(p),out=j.output(q),back=j.read(out.bytes,blank); + process.stdout.write(JSON.stringify({before,titles:q.tables.MachTitleDat,back:back.tables.MachMainDat, + warnings:p.importWarnings,errors:q.calculation.errors}));""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script], input=json.dumps(project).encode(), cwd=ROOT + ) + ) + assert result["before"][2]["Name"] == "special" + assert result["before"][2]["CalcIndex"] == 1 + assert result["titles"][0]["TC"] == 130 + assert result["back"][2]["GC"] == 50 + assert result["titles"][3]["TC"] == 100 + assert "상세 산식 미변환" in result["before"][4]["Name"] + assert "상세 산식 미변환" in result["before"][6]["Name"] + assert any("특수 기계 상세" in warning for warning in result["warnings"]) + assert not result["errors"] diff --git a/resources/tester/test_est_text_parser.py b/resources/tester/test_est_text_parser.py index daaceaec0..d46594ec3 100644 --- a/resources/tester/test_est_text_parser.py +++ b/resources/tester/test_est_text_parser.py @@ -18,6 +18,37 @@ def test_mean_sea_level_labels_do_not_break_variable_declarations(): assert result["checks"][0]["calculated"] == 7.27 +def test_redundant_price_caption_keeps_quantity_and_source_updates(): + result = run( + "math", + """(() => { + const rows = [ + {Index:1, Code:'D1'}, + {Index:0, LineData:'Q = 7.35'}, + {Index:0, SCheck:-2, LinkIndex:5, + LineData:'\\x0e황동주물\\x0e \\x0324,200\\x03C × Q = 24,200 × 7.35 = \\x12177,870\\x12'} + ]; + const initial=EstText.calculate(rows); + rows[1].LineData='Q = 4'; + const changed=EstText.calculate(rows,{},()=>({GC:30000,TC:30000,Code:'M1',SourceKind:5})); + const repeat=EstText.calculate(changed.rows,{},()=>({GC:30000,TC:30000,Code:'M1',SourceKind:5})); + const conflict=structuredClone(rows);conflict.splice(1,0,{Index:0,LineData:'C = 2'}); + const declared=EstText.calculate(conflict); + const mismatch=structuredClone(rows);mismatch[2].LineData=mismatch[2].LineData.replace('= 24,200','= 24,201'); + return {initial,changed,repeat,declared,mismatch:EstText.calculate(mismatch)}; + })()""", + ) + assert result["initial"]["errors"] == [] + assert result["initial"]["rows"][2]["TC"] == 177870 + assert result["changed"]["errors"] == [] + assert result["changed"]["rows"][2]["TC"] == 120000 + assert result["changed"]["rows"][2]["ResourceFactor"] == 4 + assert result["repeat"]["errors"] == [] + assert result["repeat"]["rows"][2]["TC"] == 120000 + assert result["declared"]["errors"] + assert result["mismatch"]["errors"] + + def test_equals_in_description_is_not_a_formula_separator(): result = run( "math", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 4cf0ae1bc..421280df1 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -423,6 +423,24 @@ 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 기계경비의 특수 상세도 저장 금액 합계가 명확히 호표와 일치하면 개별 고정 금액으로 가져와 편집·출력한다. 특수 산식은 해석하지 않는다. + - 자체 검증: 합성 JDL의 일반·특수 상세 120을 각각 불러와 특수 금액 변경 후 합계 130, JDL 재가져오기 유지. 합계 불일치·퍼센트 행은 기존 호표 합계 경로 유지, 수량 2의 합계 일치 사례 확인. JDL·업무·오류 검사 58개 통과. 특수 산식·외부 JDL 인증은 미확인. 이후 산식·JDL·업무·오류 검색 통합 회귀 107개 통과. + +- [x] JDL 특수 일위 상세의 저장 비목 합계가 호표와 정확히 일치하면 상세별 고정 금액으로 가져와 편집·출력한다. 불일치나 행 식별이 모호한 경우 기존 합계 보존을 유지한다. + - 자체 검증: 정상·특수 상세 합계 350인 합성 JDL에서 특수 상세 금액 변경 후 360으로 내역까지 재계산·JDL 왕복. 원천이 없는 특수 행의 저장 금액 보존, 합계 불일치 시 기존 합계 경로 유지. JDL·업무·오류 검사 57개 통과. 특수 산식과 외부 JDL 인증은 미확인. + +- [x] 단가 표지 뒤 남은 미선언 C가 대입식의 단가와 동일한 설명인 경우 수식에서 구분하고, 단가·수량 변경 전파와 실제 원본 자료 회귀를 검증한다. + - 자체 검증: 교량 원본 행과 단가·수량 변경 후 반복 재계산 및 동원 수량 계수 확인. 변경된 단가가 뒤의 숫자 대입에도 반영되며, C가 선언되거나 숫자 대입이 다른 경우 보호한다. 설치 ESTX 24개 변경 전후 수식 오류 15→14, 저장 결과 일치 114,378→114,379, 파일별 일치 감소 없음. 산근 시험 49개와 추가 보호 시험 통과. 실제 교량 자료에서 원천 단가 24,200→30,000 변경 후 220,500원·동원 계수 7.35가 두 번의 계산에서 안정적으로 유지됨을 확인. + +- [x] JDL 출력에서 단가산출 호표의 비목별 저장 금액과 내역·일위 연결을 단가산출 자료로 보존하고 재가져오기를 검증한다. 원래 산출근거는 JSON에 보존하고 변환 시 알린다. + - 자체 검증: 합성 자료의 단가산출→직접 내역·일위 연결 및 직접비 625 재가져오기 확인. 설치 교량 ESTX를 JDL로 전환·재가져와 단가산출 호표 41개와 내역 합계 7,989,852,162원 유지. JDL·업무 시험 41개 통과. 상시 서버 API에서 JDL 단가산출 100→120 수정 후 저장·재열기 시 연결 104와 내역 합계 290 확인. 원래 산출근거와 외부 인증은 미지원. + +- [x] JDL 일위대가의 고정 금액 상세와 저장 합계 차액을 각각 보존해 다시 출력하고, 편집 후 재가져오기와 합계를 검증한다. + - 자체 검증: 합성 JDL의 상세 200과 저장 차액 5.25를 가져와 차액 7로 변경한 뒤 출력·재가져오기 시 두 상세와 합계 207 유지. JDL·업무 시험 40개 통과. 고정 금액을 독립 단가로 출력하므로 재가져온 정수 상세는 일반 연결 행으로 표시될 수 있다. + +- [x] 대량 JDL 공종 가져오기에서 내역을 공종별로 미리 묶어 반복 전체 검색을 없애고 공종 누락·중복 검사를 유지한다. + - 자체 검증: 3,000공종·6,000상세의 그룹 순서와 합계 27,000원, 부모 누락·중복 공종 거부 확인. JDL·업무 시험 39개 통과. 별도 주요 EST 업무·계산·입출력 회귀는 278개 통과(대용량 원본 저장과 브라우저 캐시 시험 제외). + - [x] DBISAM 엔진 서명 전체를 확인해 손상된 ESTX 테이블을 정상 자료로 읽지 않도록 하고 실제 파일·손상 사례를 검증한다. - 자체 검증: 기존 16바이트 접두 일치 후 마지막 해시 바이트가 다른 테이블과 자재 등 주요 테이블의 서명 손상을 거부해 조용한 누락을 방지. 설치 ESTX 24개 읽기, 정상 main.estx 변조 거부, 실제 원본 저장·인덱스 보존 포함 관련 시험 18개와 추가 손상·산식 회귀 39개 통과. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 52507e696..cbd94672b 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -48,6 +48,10 @@ - JDL 기계 상세의 수량과 단가를 곱한 금액이 비목별 저장 합계와 맞는 경우 각 상세 금액을 편집할 수 있습니다. 상세 산식과 원천 단가 연결은 원본에 보존하며, 수량 해석이 모호하면 합계만 가져옵니다. - 가져온 JDL 기계 상세의 편집 금액과 호표 합계가 맞으면 JDL로 다시 저장할 때 각 상세를 고정 단가로 기록합니다. 원래의 상세 산식과 수량 관계는 복원하지 않습니다. - 일위대가에 연결된 기계·실적·견적·단가산출 자료도 일반 상세 조건을 만족하면 JDL로 출력합니다. 기계 상세가 변환되지 않거나 그 외 복합 원천은 고정 단가로 전개합니다. +- JDL 일위대가의 고정 금액 상세와 저장 합계 차액은 별도 단가로 내보냅니다. 다시 가져온 정수 금액은 해당 별도 단가에 연결된 일반 행으로 표시될 수 있습니다. +- 특수 일위 상세라도 행별 저장 금액의 합계가 호표와 맞고 행 식별이 가능하면 각 금액을 고정해 편집할 수 있습니다. 특수 산식은 원본 JDL에 보존하며 변환하지 않습니다. +- 특수 기계 상세도 수량과 금액의 해석이 명확히 합계와 맞는 경우 개별 고정 금액으로 표시합니다. 퍼센트 행과 합계가 불일치하는 구성은 호표 합계를 보존합니다. +- 단가산출 호표의 저장된 비목별 금액은 JDL 단가산출 자료로 내보내 내역·일위 연결을 유지합니다. 원래 산출근거의 식은 JDL에서 복원되지 않습니다. - **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 기존 수량산출서의 이름과 항목을 수정하고, 확인된 형식의 항목과 단일 결과 표지 산출 행을 추가·삭제할 수 있습니다. 고유한 일반 행 삭제와 조건에 맞는 산출 행 이동도 지원합니다. 연결된 항목이나 중복·복합 표지, 불명확한 서식 구조는 보호를 위해 일부 작업을 거부합니다. 기존 서식 구간의 텍스트를 편집할 때 새 글에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 수량산출서, 인덱스 키 변경, 수량의 내역 연결 변경, 웹 기성·연차·당초 사본은 ESTX 저장을 지원하지 않아 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **기성 진도 반영**: 예정공정표에서 최근 확정 회차의 누계 수량을 해당 회차 계약 수량으로 나누어 연결된 공정의 진도를 갱신할 수 있습니다. 수량이 0이거나 기성 내역에 연결되지 않은 공정은 기존 진도를 유지합니다. 0~100%를 벗어나는 결과는 갱신을 중단합니다. diff --git a/tools/est-test/jdl-machine-parts.cjs b/tools/est-test/jdl-machine-parts.cjs new file mode 100644 index 000000000..9c81be78b --- /dev/null +++ b/tools/est-test/jdl-machine-parts.cjs @@ -0,0 +1,12 @@ +function ordinaryMachinePart(part, num) { + return ( + ["", "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) + ); +} + +module.exports = { ordinaryMachinePart }; diff --git a/tools/est-test/jdl-special-unit.cjs b/tools/est-test/jdl-special-unit.cjs new file mode 100644 index 000000000..b3990ec6f --- /dev/null +++ b/tools/est-test/jdl-special-unit.cjs @@ -0,0 +1,42 @@ +function canPreserveSpecial(parts, title, num) { + const sequences = new Set(parts.map((part) => part.F_SEQ)); + return ( + parts.length > 0 && + sequences.size === parts.length && + parts.every((part) => Boolean(part.F_SEQ)) && + [ + ["F_MCOST", "F_MPRICE"], + ["F_LCOST", "F_LPRICE"], + ["F_ECOST", "F_EPRICE"], + ].every( + ([field, total]) => + Math.abs(parts.reduce((sum, part) => sum + num(part[field]), 0) - num(title[total])) < 1e-6, + ) + ); +} + +function fixedSpecialPart(part, source, pk, num) { + const GC = num(part.F_MCOST), + NC = num(part.F_LCOST), + JC = num(part.F_ECOST); + return { + PkID: pk, + Index: 0, + Code: source ? part.F_CODE1 : "", + JdlSourceCode: part.F_CODE1, + Name: part.F_DES, + Size: part.F_SIZE, + Unite: part.F_UNIT, + LinkIndex: source?.kind || 0, + CalcIndex: 1, + JdlSummary: true, + GN: num(part.F_QQTY), + GC, + NC, + JC, + SC: 0, + TC: GC + NC + JC, + }; +} + +module.exports = { canPreserveSpecial, fixedSpecialPart }; diff --git a/tools/est-test/jdl-unit-order.cjs b/tools/est-test/jdl-unit-order.cjs index 5e3903885..bb1b954f4 100644 --- a/tools/est-test/jdl-unit-order.cjs +++ b/tools/est-test/jdl-unit-order.cjs @@ -17,7 +17,10 @@ function orderUnits(titles, groups) { state.set(code, 1); stack.push({ title: item.title, expanded: true }); for (const row of groups.get(code) || []) { - const dependency = Number(row.LinkIndex) === 103 && byCode.get(row.Code); + const dependency = + !(row.JdlSummary && Number(row.CalcIndex) === 1) && + Number(row.LinkIndex) === 103 && + byCode.get(row.Code); if (dependency && !state.has(dependency.Code)) stack.push({ title: dependency, expanded: false }); } diff --git a/tools/est-test/jdl-unit-part.cjs b/tools/est-test/jdl-unit-part.cjs new file mode 100644 index 000000000..f293df3d1 --- /dev/null +++ b/tools/est-test/jdl-unit-part.cjs @@ -0,0 +1,34 @@ +function isFixedPart(row, num) { + return Boolean(row.JdlSummary) && num(row.CalcIndex) === 1; +} + +function exportUnitPart(row, code, index, map, source, num, next) { + const fixed = isFixedPart(row, num); + const code1 = fixed + ? source(0, { ...row, Code: "UNIT-PART-" + next() }) + : map.get(num(row.LinkIndex) + ":" + row.Code); + return { + F_CODE: code, + F_SEQ: String(index + 1), + F_CODE1: code1, + F_DES: row.Name, + F_SIZE: row.Size, + F_UNIT: row.Unite, + F_QQTY: fixed ? 1 : num(row.GN), + F_MPRICE: num(fixed ? row.GC : row.GU), + F_LPRICE: num(fixed ? row.NC : row.NU), + F_EPRICE: num(fixed ? row.JC : row.JU) + num(fixed ? row.SC : row.SU), + F_MCOST: num(row.GC), + F_LCOST: num(row.NC), + F_ECOST: num(row.JC) + num(row.SC), + F_SPROPER: "0", + F_LOADBM: "T", + F_SAVEBM: "T", + F_SRATE: 0, + F_KWAN: "0", + F_CLASS: "0", + F_ORICODE: "00000000000", + }; +} + +module.exports = { isFixedPart, exportUnitPart }; diff --git a/tools/est-test/jdl.cjs b/tools/est-test/jdl.cjs index a5d82f736..5b08bbbd8 100644 --- a/tools/est-test/jdl.cjs +++ b/tools/est-test/jdl.cjs @@ -5,6 +5,9 @@ const { cp949 } = require("./export-estx.cjs"); const schema = require("./jdl-schema.json"); const { exportMachines } = require("./jdl-machine-export.cjs"); const { orderUnits } = require("./jdl-unit-order.cjs"); +const { isFixedPart, exportUnitPart } = require("./jdl-unit-part.cjs"); +const { canPreserveSpecial, fixedSpecialPart } = require("./jdl-special-unit.cjs"); +const { ordinaryMachinePart } = require("./jdl-machine-parts.cjs"); const decoder = new TextDecoder("euc-kr", { fatal: true }); function fingerprint(project) { const { originalJdlState, id, ...content } = project; @@ -115,6 +118,21 @@ function output(project) { for (const table of ["CostTitleDat", "MachTitleDat", "HeadTitleDat"]) for (const row of project.tables[table] || []) source({ CostTitleDat: 104, MachTitleDat: 105, HeadTitleDat: 103 }[table], row); + for (const title of project.tables.CostTitleDat || []) { + if (!title.Code || ambiguous.has("104:" + title.Code)) continue; + const code = "D" + String(++serial).padStart(10, "0"); + map.set("104:" + title.Code, code); + tables["danga.txt"].push({ + F_CODE: code, + F_DES: title.Name, + F_SIZE: title.Size, + F_UNIT: title.Unite, + F_MPRICE: num(title.GC), + F_LPRICE: num(title.NC), + F_EPRICE: num(title.JC) + num(title.SC), + F_ORICODE: "00000000000", + }); + } exportMachines(project, tables, map, ambiguous, num, () => ++serial, source, warnings); const unitGroups = new Map(); let unitGroup; @@ -138,20 +156,22 @@ function output(project) { !parts?.length || parts.some( (row) => - ![5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) || - (num(row.LinkIndex) === 103 && row.Code === title.Code) || - !map.has(num(row.LinkIndex) + ":" + row.Code) || - ["CalcIndex", "SKind", "Gangub", "OCheck", "Biul", "SC", "SU"].some((key) => - num(row[key]), - ), + (!isFixedPart(row, num) && + (![5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) || + num(row.CalcIndex) || + (num(row.LinkIndex) === 103 && row.Code === title.Code) || + !map.has(num(row.LinkIndex) + ":" + row.Code))) || + ["SKind", "Gangub", "OCheck", "Biul", "SC", "SU"].some((key) => num(row[key])), ) ) continue; const code = "U" + String(++serial).padStart(10, "0"); map.set("103:" + title.Code, code); for (const row of parts) - if (num(row.LinkIndex) === 103 && !exportedUnits.has(row.Code)) + if (!isFixedPart(row, num) && num(row.LinkIndex) === 103 && !exportedUnits.has(row.Code)) warnings.push(`${title.Code}/${row.Code}: 하위 일위대가를 고정 단가로 전개합니다.`); + if (parts.some((row) => isFixedPart(row, num))) + warnings.push(`${title.Code}: 일위 상세의 고정 금액을 별도 단가로 내보냅니다.`); exportedUnits.add(title.Code); tables["unit.txt"].push({ F_CODE: code, @@ -164,28 +184,9 @@ function output(project) { F_ORICODE: "00000000000", }); for (const [index, row] of parts.entries()) - tables["unitpart.txt"].push({ - F_CODE: code, - F_SEQ: String(index + 1), - F_CODE1: map.get(num(row.LinkIndex) + ":" + row.Code), - F_DES: row.Name, - F_SIZE: row.Size, - F_UNIT: row.Unite, - F_QQTY: num(row.GN), - F_MPRICE: num(row.GU), - F_LPRICE: num(row.NU), - F_EPRICE: num(row.JU) + num(row.SU), - F_MCOST: num(row.GC), - F_LCOST: num(row.NC), - F_ECOST: num(row.JC) + num(row.SC), - F_SPROPER: "0", - F_LOADBM: "T", - F_SAVEBM: "T", - F_SRATE: 0, - F_KWAN: "0", - F_CLASS: "0", - F_ORICODE: "00000000000", - }); + tables["unitpart.txt"].push( + exportUnitPart(row, code, index, map, source, num, () => ++serial), + ); } let group = null, groupNumber = 0, @@ -347,6 +348,7 @@ function read(bytes, project) { } let machineDetailed = false, machineWeighted = false, + machineSpecial = false, machineFallback = false; for (const title of rows["junit.txt"]) { const gc = num(title.F_MPRICE), @@ -355,21 +357,13 @@ function read(bytes, project) { 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 ordinary = + const identifiable = parts.length > 0 && uniqueSequences.size === parts.length && parts.every( - (part) => - Boolean(part.F_SEQ) && - num(part.F_QQTY) > 0 && - !["%", "%"].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), + (part) => Boolean(part.F_SEQ) && num(part.F_QQTY) > 0 && !["%", "%"].includes(part.F_UNIT), ); + const ordinary = identifiable && parts.every((part) => ordinaryMachinePart(part, num)); const matches = (weighted) => [ ["F_MPRICE", gc], @@ -384,8 +378,8 @@ function read(bytes, project) { ) - value, ) < 1e-6, ); - const direct = ordinary && matches(false); - const weighted = ordinary && !direct && matches(true); + const direct = identifiable && matches(false); + const weighted = identifiable && !direct && matches(true); const basic = (direct && parts.every((part) => num(part.F_QQTY) === 1)) || weighted; project.tables.MachMainDat.push({ PkID: project.tables.MachMainDat.length + 1, @@ -400,6 +394,7 @@ function read(bytes, project) { if (basic) { machineDetailed = true; machineWeighted ||= weighted; + machineSpecial ||= !ordinary; for (const part of parts) { const factor = weighted ? num(part.F_QQTY) : 1, GC = num(part.F_MPRICE) * factor, @@ -437,10 +432,12 @@ function read(bytes, project) { } if (machineDetailed) unitWarnings.push( - "일반 기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.", + "기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.", ); if (machineWeighted) unitWarnings.push("일부 기계 상세는 수량×단가가 저장 합계와 일치해 계산 금액으로 표시합니다."); + if (machineSpecial) + unitWarnings.push("특수 기계 상세는 저장 금액으로 고정하며 특수 산식은 원문에 보존합니다."); if (machineFallback) unitWarnings.push( "일부 기계경비는 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.", @@ -500,6 +497,7 @@ function read(bytes, project) { nc = num(title.F_LPRICE), jc = num(title.F_EPRICE); const parts = partsByUnit.get(title.F_CODE) || []; + const preserveSpecial = canPreserveSpecial(parts, title, num); let incomplete = !parts.length; const included = { GC: 0, NC: 0, JC: 0 }; project.tables.HeadMainDat.push({ @@ -528,6 +526,15 @@ function read(bytes, project) { ["", "0"].includes(part.F_KWAN) && ["", "0"].includes(part.F_CLASS); if (!basic) { + if (preserveSpecial) { + const row = fixedSpecialPart(part, source, ++unitPk, num); + project.tables.HeadMainDat.push(row); + for (const key of ["GC", "NC", "JC"]) included[key] += row[key]; + unitWarnings.push( + `일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 저장 금액으로 고정하며 산식은 원문에 보존합니다.`, + ); + continue; + } incomplete = true; unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 원문에만 보존`); continue; @@ -594,6 +601,11 @@ function read(bytes, project) { if (rows["unitpart.txt"].some((r) => !unitCodes.has(r.F_CODE))) unitWarnings.push("부모 일위대가가 없는 상세 행은 원문에만 보존"); const headings = new Set(); + const partsByGroup = new Map(); + for (const part of rows["mainpart.txt"]) { + if (!partsByGroup.has(part.F_CODE)) partsByGroup.set(part.F_CODE, []); + partsByGroup.get(part.F_CODE).push(part); + } let pk = 0; for (const group of rows["gongjong.txt"]) { if (headings.has(group.F_CODE)) throw Error("JDL 공종 코드 중복"); @@ -609,7 +621,7 @@ function read(bytes, project) { SC: 0, TC: group.F_MPRICE + group.F_LPRICE + group.F_EPRICE, }); - for (const r of rows["mainpart.txt"].filter((r) => r.F_CODE === group.F_CODE)) { + for (const r of partsByGroup.get(group.F_CODE) || []) { const source = byCode.get(r.F_CODE1); if (!source) throw Error("JDL 연결 자원 없음: " + r.F_CODE1); const row = { diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index 651c09cc7..3d4e92712 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -272,7 +272,8 @@ const EstText = (() => { const assignedResults = new Map(); const assignedNames = new Map(); const declaredNames = new Set(); - let componentDeclaration; + let componentDeclaration, + priceCaption = false; try { if (selectionError) throw Error(selectionError); if (!matched) @@ -344,6 +345,17 @@ const EstText = (() => { } else if (matched && sc < 0) { let body = stripLabels(text.slice(0, text.indexOf("\x12"))).replace(/=\s*$/, ""); if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); + const caption = original.match( + /\x03([-\d.,]+)\x03C(?=\s*[×*])[\s\S]*?=\s*([-\d.,]+)(?=\s*[×*])/, + ); + if (caption && variables.C === undefined && numeric(caption[1]) === numeric(caption[2])) { + priceCaption = true; + body = body.replace(/(\x03[-\d.,]+\x03)C(?=\s*[×*])/, "$1"); + text = text.replace( + /(\x03([-\d.,]+)\x03C(?=\s*[×*])[\s\S]*?=\s*)([-\d.,]+)(?=\s*[×*])/, + (_, prefix, price) => prefix + price, + ); + } const parts = body.split("="); if (parts.length > 1 && /^[A-Za-z가-힣][A-Za-z0-9가-힣]*$/.test(parts[0].trim())) { componentDeclaration = parts[0].trim(); @@ -505,6 +517,7 @@ const EstText = (() => { if (currentSource && text.includes("\x03")) { let body = stripLabels(text.slice(0, text.indexOf("\x12"))).split("=")[0]; if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); + if (priceCaption) body = body.replace(/(\x03[-\d.,]+\x03)C(?=\s*[×*])/, "$1"); try { row.ResourceFactor = EstExpression.evaluate( expression(body.replace(/\x03[-\d.,]+\x03/g, "1")),