From 170086a5b17638e48986d415319528d4d4adc438 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 02:33:07 +0900 Subject: [PATCH 01/76] fix(est): reject ambiguous quantity targets --- .../tester/test_est_quantity_workflow.py | 30 +++++++++++++++++++ tools/est-test/README.md | 2 ++ tools/est-test/native-quantity.cjs | 7 +++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_est_quantity_workflow.py diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py new file mode 100644 index 000000000..dd480185d --- /dev/null +++ b/resources/tester/test_est_quantity_workflow.py @@ -0,0 +1,30 @@ +from test_est_rea_parity import run + + +def test_quantity_apply_requires_one_detail_row_per_code(): + result = run( + "math", + """(()=>{ + function apply(rows,items=[{Code:'r1',GN:7,TargetCode:'X'}]){ + const handlers={}; + globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + const sheet={Code:'Q',rows:[],items,options:{}}; + const state={quantitySheet:'Q',project:{quantitySheets:[sheet],tables:{EstMainDat:rows}}}; + globalThis.EstApp={state,change:fn=>fn(),render:()=>{}}; + EstQuantity.install();handlers.click({target:{closest:()=>true}}); + return {rows,notice:state.notice||''}; + } + return {duplicate:apply([{Code:'X',Level:0,GN:1},{Code:'X',Level:0,GN:2}]), + heading:apply([{Code:'X',Level:1,GN:1}]), + valid:apply([{Code:'X',Level:1,GN:1},{Code:'X',Level:0,GN:2}]), + partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}])}; + })()""", + ) + assert [r["GN"] for r in result["duplicate"]["rows"]] == [1, 2] + assert result["duplicate"]["notice"] + assert result["heading"]["rows"][0]["GN"] == 1 + assert result["heading"]["notice"] + assert [r["GN"] for r in result["valid"]["rows"]] == [1, 7] + assert result["valid"]["notice"] == "" + assert result["partial"]["rows"][0]["GN"] == 1 + assert result["partial"]["notice"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index ad2b4842e..74ca5417f 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -25,6 +25,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 기존 ESTX의 기본 수량 연결은 문서·항목 코드와 내역 코드가 유일한 경우 화면에 표시하며, 중복·다른 모드는 원문에 보존하고 경고합니다. 원본 연결 데이터 실자료 검증과 새 연결의 ESTX 쓰기는 아직 지원하지 않습니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다. +수량산출서에서 내역에 수량을 반영할 때 연결 코드에 해당하는 상세 내역이 정확히 하나여야 합니다. 코드가 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 553cda4d5..257bd9265 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -164,8 +164,11 @@ const EstQuantity = (() => { for (const item of output.items) if (item.TargetCode || item.LinkCode) { const code = item.TargetCode || item.LinkCode; - if (!state.project.tables.EstMainDat.some((r) => r.Code === code)) - throw Error(`연결 내역코드 없음: ${code}`); + const targets = state.project.tables.EstMainDat.filter( + (r) => r.Code === code && !Number(r.Level), + ); + if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`); + if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`); quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0)); } if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요."); From c03929e369f95f3e131766224fff335c75d98737 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 02:35:12 +0900 Subject: [PATCH 02/76] fix(est): combine quantity text edits --- resources/tester/test_est_native_export.py | 11 +++++++++++ tools/est-test/export-quantity.cjs | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_native_export.py b/resources/tester/test_est_native_export.py index a7cc4682e..2012553c0 100644 --- a/resources/tester/test_est_native_export.py +++ b/resources/tester/test_est_native_export.py @@ -101,6 +101,17 @@ def test_quantity_document_edit_across_character_styles_preserves_tags(): assert result == {"same": True, "tags": True} +def test_quantity_document_edit_across_styles_and_separate_marker(): + script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs'); + const doc='
abcdef6
'; + const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old); + edited.rows[0].LineData=edited.rows[0].LineData.replace('bcde','변경').replace('6','12'); + const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{}),tags=x=>x.match(/<[^>]*>/g); + process.stdout.write(JSON.stringify({same:JSON.stringify(again)===JSON.stringify(edited),tags:JSON.stringify(tags(doc))===JSON.stringify(tags(next))}));""" + result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) + assert result == {"same": True, "tags": True} + + def test_multiple_quantity_text_segments_preserve_original_tags_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs index 56ce016c0..7167ae5c0 100644 --- a/tools/est-test/export-quantity.cjs +++ b/tools/est-test/export-quantity.cjs @@ -121,7 +121,9 @@ function editDocument(text, oldSheet, sheet) { if (previous[i] === next[i]) continue; previous[i] = next[i]; const updated = { ...current, LineData: previous.join("") }; - const patched = editRun(candidate, current, updated, parse); + const patched = + editRun(candidate, current, updated, parse) || + editStyledRuns(candidate, current, updated, parse); if (!patched) break; candidate = patched; current = updated; From 19097b7255bc0bcc48ddfc23d9cda3d2a8bf343a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 02:38:42 +0900 Subject: [PATCH 03/76] fix(est): match prior unnamed cost codes --- .../tester/test_est_progress_workflow.py | 19 +++++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/native-progress.js | 17 +++++++++++++---- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_est_progress_workflow.py b/resources/tester/test_est_progress_workflow.py index 29d90e7c9..6b88b1989 100644 --- a/resources/tester/test_est_progress_workflow.py +++ b/resources/tester/test_est_progress_workflow.py @@ -121,3 +121,22 @@ def test_progress_preserves_zero_wonga_total_and_falls_back_only_when_absent(): assert result["zero"]["rows"][0]["GumTC"] == 200 assert result["zero"]["total"] == 0 assert result["absent"]["total"] == 200 + + +def test_progress_preserves_prior_costs_for_multiple_blank_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100,NU:50},{LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'',Name:'material',BaseField:'GC'}, + {Gubun:'',Name:'labor',BaseField:'NC'}]; + p.progress=[{id:'one',inputs:{'1':{Gum:2}}}]; + const q=EstProgress.lock(EstNative.calculate(p),'one'); + q.progress.push({id:'two',inputs:{'1':{Gum:3}}}); + return EstProgress.calculate(q,'two'); + })()""", + ) + assert result["errors"] == [] + assert [row["JunCost"] for row in result["costs"]] == [200, 100] + assert [row["TotalCost"] for row in result["costs"]] == [500, 250] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 74ca5417f..cf96d3d26 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -61,7 +61,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 2026-11-06 지정 범위인 설계변경·기성·물가변동·연차의 웹앱 구현을 완료했습니다. 설계변경은 당초 보존·재계산 0/1/2와 신규·삭제·변경 비교를 제공합니다. 삭제된 당초/기성 행 번호를 새 행에 재사용하지 않습니다. -기성은 수량·금액·비율·비목별 입력, 사정 승인, 전회·금회·누계 원가를 계산합니다. 확정 회차는 당시 계약 자료를 보존하고 서버 저장 시 결과를 재검산합니다. 전회 수정은 이후 회차의 확정도 해제하며, 전회 미확정 상태에서 다음 회차를 확정할 수 없습니다. +기성은 수량·금액·비율·비목별 입력, 사정 승인, 전회·금회·누계 원가를 계산합니다. 원가 기호가 없는 항목은 이름과 같은 이름의 순서로 전회 금액을 대응합니다. 확정 회차는 당시 계약 자료를 보존하고 서버 저장 시 결과를 재검산합니다. 전회 수정은 이후 회차의 확정도 해제하며, 전회 미확정 상태에서 다음 회차를 확정할 수 없습니다. 물가변동은 같은 행 구조의 계약·입찰 자료(JSON/ESTX)를 불러와 조사·계약·변동 단가를 나눕니다. 원본 레코드 순서 방식에 따라 행 순서·코드·레벨이 다른 자료는 거부합니다. 등락 분기, 조사금액 기준 조정률, 자동 최대수량/수동 적용제외, 간접비 포함 적용대가와 기성 누계 불러오기를 제공합니다. 기성과 물가변동은 원가계산서의 명시적인 0원 도급액을 보존합니다. 연차는 수식 참조 오류를 차단하며, 미리보기 뒤 선택한 차수만 독립 사본으로 엽니다. diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js index 6afced713..c71f624f7 100644 --- a/tools/est-test/native-progress.js +++ b/tools/est-test/native-progress.js @@ -224,11 +224,20 @@ const EstProgress = (() => { row[prefix + field] = children.reduce((n, child) => n + num(child[prefix + field]), 0); } const acceptedCost = round.approved ? sa : gum; - const priorCosts = new Map( - (previous?.costs || previous?.wonga || []).map((row) => [row.Gubun ?? row.Name, row]), - ); + const costKeys = (items) => { + const occurrences = new Map(); + return items.map((row) => { + const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]); + const occurrence = occurrences.get(identity) || 0; + occurrences.set(identity, occurrence + 1); + return JSON.stringify([identity, occurrence]); + }); + }; + const oldCosts = previous?.costs || previous?.wonga || []; + const priorCosts = new Map(costKeys(oldCosts).map((key, index) => [key, oldCosts[index]])); + const currentKeys = costKeys(acceptedCost.rows); const costs = acceptedCost.rows.map((row, index) => { - const previous = priorCosts.get(row.Gubun ?? row.Name); + const previous = priorCosts.get(currentKeys[index]); const jun = num(previous?.TotalCost ?? previous?.Cost); return { ...row, From 042d574fee759323f9f56c3ceb363557c7e57b28 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 02:41:05 +0900 Subject: [PATCH 04/76] test(est): cover original text style boundary --- resources/tester/test_est_native_export.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/resources/tester/test_est_native_export.py b/resources/tester/test_est_native_export.py index 2012553c0..5fdd4c3ce 100644 --- a/resources/tester/test_est_native_export.py +++ b/resources/tester/test_est_native_export.py @@ -112,6 +112,24 @@ def test_quantity_document_edit_across_styles_and_separate_marker(): assert result == {"same": True, "tags": True} +def test_quantity_document_edit_across_actual_style_boundary_when_sample_exists(): + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + if not sample.exists(): + pytest.skip("installed EST main sample unavailable") + script = """const fs=require('fs'),z=require('zlib'),m=require('./tools/est-test/import-estx.cjs'),w=require('./tools/est-test/export-quantity.cjs'),q=require('./tools/est-test/native-quantity.cjs'); + const p=m.importEstx(fs.readFileSync(process.argv[1])),old=p.quantitySheets.find(s=>s.Code==='N0000000113'); + const r=p.tables.MulMainDat.find(x=>x.Code===old.Code),b=Buffer.from(r.Editor.binary,'base64'); + const text=new TextDecoder('euc-kr').decode(b[0]===120?z.inflateSync(b):b),edited=structuredClone(old); + const i=edited.rows.findIndex(r=>r.LineData.includes('27.22 ='));if(i<0)throw Error('sample line missing'); + edited.rows[i].LineData=edited.rows[i].LineData.replace('27.22 =','27.22XY'); + const out=w.editDocument(text,old,edited),tags=x=>x.match(/<[^>]*>/g); + process.stdout.write(JSON.stringify({same:JSON.stringify(q.fromWpt(edited.Code,edited.Name,out,edited.items,edited.options))===JSON.stringify(edited),tags:JSON.stringify(tags(text))===JSON.stringify(tags(out))}));""" + result = json.loads( + subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") + ) + assert result == {"same": True, "tags": True} + + def test_multiple_quantity_text_segments_preserve_original_tags_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") if not sample.exists(): From 6cea0c97f2dea6c54eea721b4808d716e5070f62 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 02:43:36 +0900 Subject: [PATCH 05/76] fix(est): reject reserved annual names --- resources/tester/test_est_annual_workflow.py | 16 ++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/native-business.js | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_annual_workflow.py b/resources/tester/test_est_annual_workflow.py index 0962d1d33..9a8552441 100644 --- a/resources/tester/test_est_annual_workflow.py +++ b/resources/tester/test_est_annual_workflow.py @@ -39,3 +39,19 @@ def test_annual_copy_rejects_invalid_or_forward_references(): })()""", ) assert "참조 항목 없음" in result + + +def test_annual_rejects_names_that_shadow_contract_references(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{Code:'R',GN:1,GC:100,TC:100,CalcIndex:1}]; + return ['기준','계약','계약수량','first'].map(name=>{ + p.annual=[{name,formula:'[기준]*0.5'},{name:'next',formula:'[기준]-['+name+']'}]; + return EstBusiness.annual(p,false); + }); + })()""", + ) + assert all(item["errors"] for item in result[:3]) + assert result[3]["errors"] == [] + assert result[3]["results"][1]["project"]["tables"]["EstMainDat"][0]["TC"] == 50 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index cf96d3d26..a945b35d5 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -41,7 +41,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 단가 변경은 산근 소계(61), 비목 참조 비율(58), 선택 구간 합계(62)를 거쳐 내역으로 전달됩니다. 산출근거 편집기의 `구간지정`·`구간합계` 표지로 A~Z 묶음을 제어합니다. 소계 표지 1,433개를 추가한 대용량 원본 대조는 115,145개 일치·해석 오류 0이며, 원본 산근 낙찰 행(52)의 성분 계산과 연결 편집도 회귀 시험합니다. -연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다. +연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다. 참조가 충돌하지 않도록 차수 이름에는 `기준`, `계약`, `계약수량`을 사용할 수 없습니다. 연차의 조달수수료는 원본처럼 연결 종류 99로 전환해 금액을 배분하며 요율은 유지합니다. 관급 합계와 안전관리비의 수수료 제외 계산에 배분액을 반영하고, 합산 제외 행은 제외 상태를 유지합니다. 차수 내역 열기는 해당 내역서를 표시하며, 저장하면 원본과 다른 파일 ID를 부여해 원본을 덮어쓰지 않습니다. diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index f8cd7b612..cb908bcbc 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -222,6 +222,8 @@ const EstBusiness = (() => { const names = (project.annual || []).map((year) => String(year.name || "").trim()); if (names.some((name) => !name.trim()) || new Set(names).size !== names.length) return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] }; + if (names.some((name) => ["기준", "계약", "계약수량"].includes(name))) + return { results, errors: ["차수 이름에 기준·계약·계약수량은 사용할 수 없습니다."] }; for (const year of project.annual || []) { const next = full ? copy(project) : EstNative.empty(); if (!full) { From 864ff1f55bdfa50d2f47413dce56b880dd6dfd82 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:33:57 +0900 Subject: [PATCH 06/76] feat(est): manage quantity calculation sheets --- .../tester/test_est_quantity_workflow.py | 78 +++++++++++++++++- tools/est-test/README.md | 2 + tools/est-test/native-quantity.cjs | 82 +++++++++++++++++-- 3 files changed, 152 insertions(+), 10 deletions(-) diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index dd480185d..2b888dacd 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -11,13 +11,14 @@ def test_quantity_apply_requires_one_detail_row_per_code(): const sheet={Code:'Q',rows:[],items,options:{}}; const state={quantitySheet:'Q',project:{quantitySheets:[sheet],tables:{EstMainDat:rows}}}; globalThis.EstApp={state,change:fn=>fn(),render:()=>{}}; - EstQuantity.install();handlers.click({target:{closest:()=>true}}); + EstQuantity.install();handlers.click({target:{closest:selector=>selector==='[data-quantity-apply]'?{}:null}}); return {rows,notice:state.notice||''}; } return {duplicate:apply([{Code:'X',Level:0,GN:1},{Code:'X',Level:0,GN:2}]), heading:apply([{Code:'X',Level:1,GN:1}]), valid:apply([{Code:'X',Level:1,GN:1},{Code:'X',Level:0,GN:2}]), - partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}])}; + partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}]), + disconnected:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'',LinkCode:'X'}])}; })()""", ) assert [r["GN"] for r in result["duplicate"]["rows"]] == [1, 2] @@ -28,3 +29,76 @@ def test_quantity_apply_requires_one_detail_row_per_code(): assert result["valid"]["notice"] == "" assert result["partial"]["rows"][0]["GN"] == 1 assert result["partial"]["notice"] + assert result["disconnected"]["rows"][0]["GN"] == 1 + assert result["disconnected"]["notice"] + + +def test_create_edit_apply_and_delete_quantity_sheet(): + result = run( + "math", + """(()=>{ + const handlers={};let id=0; + globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + globalThis.crypto={randomUUID:()=>String(++id)};globalThis.confirm=()=>true; + const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[{Code:'X',Level:0,GN:1}]}}}; + globalThis.EstApp={state,change:fn=>fn(),render:()=>{}}; + EstQuantity.install(); + const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}}); + const change=(dataset,value)=>handlers.change({target:{dataset,value}}); + click('[data-quantity-new]');click('[data-quantity-add]'); + change({quantityName:''},'Earthwork'); + change({quantityItem:'Name',index:'0'},'excavation'); + change({quantityTarget:'0'},'X'); + change({quantityLine:'0'},'2*3 = ⟦결과⟧0⟦결과⟧'); + const saved=JSON.parse(JSON.stringify(state.project)); + click('[data-quantity-apply]'); + const applied=state.project.tables.EstMainDat[0].GN; + const sheet=state.project.quantitySheets[0]; + const computed=EstQuantity.calculate(sheet); + click('[data-quantity-delete-row]',{quantityDeleteRow:'0'}); + const empty=[sheet.rows.length,sheet.items.length]; + click('[data-quantity-delete-sheet]'); + return {saved,applied,computed,empty,remaining:state.project.quantitySheets.length,selection:state.quantitySheet}; + })()""", + ) + assert result["saved"]["quantitySheets"][0]["Name"] == "Earthwork" + assert result["saved"]["quantitySheets"][0]["items"][0]["Name"] == "excavation" + assert result["computed"]["errors"] == [] + assert result["computed"]["items"][0]["GN"] == 6 + assert result["applied"] == 6 + assert result["empty"] == [0, 0] + assert result["remaining"] == 0 + assert result["selection"] == "manual" + + +def test_insert_and_move_quantity_rows_keeps_item_results_linked(): + result = run( + "math", + """(()=>{ + const handlers={};let id=0; + globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + globalThis.crypto={randomUUID:()=>String(++id)}; + const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[]}}}; + globalThis.EstApp={state,change:fn=>fn(),render:()=>{}};EstQuantity.install(); + const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}}); + click('[data-quantity-new]');click('[data-quantity-add]');click('[data-quantity-add]'); + const sheet=state.project.quantitySheets[0]; + sheet.rows[0].LineData='1+2 = \\x120\\x12';sheet.rows[1].LineData='4+5 = \\x120\\x12'; + click('[data-quantity-add]',{index:'0'}); + sheet.rows[1].LineData='6+7 = \\x120\\x12'; + const before=EstQuantity.calculate(sheet).items.map(x=>x.GN); + click('[data-quantity-move]',{index:'2',quantityMove:'-1'}); + const after=EstQuantity.calculate(sheet).items.map(x=>x.GN); + const order=sheet.rows.map(x=>x.LineData); + sheet.rows[0].LineData='A=2 = \\x120\\x12';sheet.rows[1].LineData='A*3 = \\x120\\x12'; + const linked=EstQuantity.calculate(sheet); + click('[data-quantity-move]',{index:'1',quantityMove:'-1'}); + const forward=EstQuantity.calculate(sheet); + return {before,after,order,linked,forward}; + })()""", + ) + assert result["before"] == result["after"] == [3, 9, 13] + assert result["order"][1].startswith("4+5") + assert result["linked"]["errors"] == [] + assert result["linked"]["items"][1]["GN"] == 6 + assert result["forward"]["errors"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index a945b35d5..506cb91ac 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -27,6 +27,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량산출서에서 내역에 수량을 반영할 때 연결 코드에 해당하는 상세 내역이 정확히 하나여야 합니다. 코드가 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. +수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 257bd9265..43d428cea 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -114,9 +114,8 @@ const EstQuantity = (() => { }; } function selector(state) { - if (!state.project.quantitySheets?.length) return ""; const { esc } = EstApp; - return `
`; + return `
`; } function render(state) { state.quantitySheet ||= state.project.quantitySheets[0]?.Code; @@ -128,7 +127,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${esc(sheet.Name)}
${output.items.map((item, index) => ``).join("")}
산출코드품명규격원본 저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${esc(item.Name)}${esc(item.Size)}${number(sheet.items[index].GN)}${number(item.GN)}

${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. ESTX 저장 시 원본 결과가 계산과 일치하면 편집 결과와 수량 목록을 갱신합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${number(sheet.items[index].GN)}${number(item.GN)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } function install() { document.addEventListener("change", (event) => { @@ -141,6 +140,13 @@ const EstQuantity = (() => { } const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); if (!sheet) return; + if (node.dataset.quantityName !== undefined) + EstApp.change(() => (sheet.Name = node.value), false); + if (["Name", "Size"].includes(node.dataset.quantityItem)) + EstApp.change( + () => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value), + false, + ); if (node.dataset.quantityLine !== undefined) EstApp.change( () => @@ -154,16 +160,76 @@ const EstQuantity = (() => { ); }); document.addEventListener("click", (event) => { + const { state } = EstApp; + if (event.target.closest("[data-quantity-new]")) { + EstApp.change(() => { + const code = `Q${crypto.randomUUID()}`; + state.project.quantitySheets ||= []; + state.project.quantitySheets.push({ + Code: code, + Name: "새 수량산출서", + rows: [], + items: [], + options: { digits: 2, roundMode: 2 }, + }); + state.quantitySheet = code; + }, false); + return; + } + const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + const addRow = event.target.closest("[data-quantity-add]"); + if (addRow && sheet) { + EstApp.change(() => { + const code = `r${crypto.randomUUID()}`; + const at = + addRow.dataset.index === undefined + ? sheet.rows.length + : Number(addRow.dataset.index) + 1; + sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] }); + sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" }); + }, false); + return; + } + const moveRow = event.target.closest("[data-quantity-move]"); + if (moveRow && sheet) { + const index = Number(moveRow.dataset.index), + next = index + Number(moveRow.dataset.quantityMove); + if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length) + EstApp.change(() => { + [sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]]; + }, false); + return; + } + const deleteRow = event.target.closest("[data-quantity-delete-row]"); + if (deleteRow && sheet) { + if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?")) + EstApp.change(() => { + const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1); + const remaining = new Set(sheet.rows.flatMap((row) => row.markers || [])); + sheet.items = sheet.items.filter( + (item) => !removed.markers.includes(item.Code) || remaining.has(item.Code), + ); + }, false); + return; + } + if (event.target.closest("[data-quantity-delete-sheet]") && sheet) { + if (confirm("이 수량산출서를 삭제하시겠습니까?")) + EstApp.change(() => { + state.project.quantitySheets = state.project.quantitySheets.filter( + (item) => item !== sheet, + ); + state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual"; + }, false); + return; + } if (!event.target.closest("[data-quantity-apply]")) return; try { - const { state } = EstApp, - sheet = state.project.quantitySheets.find((s) => s.Code === state.quantitySheet), - output = calculate(sheet); + const output = calculate(sheet); if (output.errors.length) throw Error(output.errors.join(" / ")); const quantities = new Map(); for (const item of output.items) - if (item.TargetCode || item.LinkCode) { - const code = item.TargetCode || item.LinkCode; + if (item.TargetCode ?? item.LinkCode) { + const code = item.TargetCode ?? item.LinkCode; const targets = state.project.tables.EstMainDat.filter( (r) => r.Code === code && !Number(r.Level), ); From 4c6bb9d70afaffbb3f994aabf4038c91c4c4e295 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:37:40 +0900 Subject: [PATCH 07/76] fix(est): apply selected quantity calculations --- .../tester/test_est_quantity_workflow.py | 25 ++++++++ tools/est-test/README.md | 2 + tools/est-test/native-actions.js | 23 +------ tools/est-test/native-app.js | 1 + tools/est-test/native-quantity.cjs | 60 +++++++++++++------ 5 files changed, 71 insertions(+), 40 deletions(-) diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index 2b888dacd..9c1bbcd18 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -102,3 +102,28 @@ def test_insert_and_move_quantity_rows_keeps_item_results_linked(): assert result["linked"]["errors"] == [] assert result["linked"]["items"][1]["GN"] == 6 assert result["forward"]["errors"] + + +def test_manual_and_selected_sheet_totals_validate_before_application(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{Code:'X',Level:0,GN:1,MulCode:{binary:'YWJj'}}]; + p.quantities=[{Code:'A',formula:'2',TargetCode:'X'}, + {Code:'B',formula:'[A]*3',TargetCode:'X'}]; + const manual=EstQuantity.totals(p,'manual');EstQuantity.applyTotals(p,manual); + const a=structuredClone(p.tables.EstMainDat[0]); + p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'4+5 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',GN:0,TargetCode:'X'}]}]; + const sheet=EstQuantity.totals(p,'Q');EstQuantity.applyTotals(p,sheet); + let invalid='';p.quantities.push({Code:'C',formula:'1',TargetCode:'missing'}); + try{EstQuantity.totals(p,'manual')}catch(e){invalid=e.message} + return {a,b:p.tables.EstMainDat[0],invalid}; + })()""", + ) + assert result["a"]["GN"] == 8 + assert result["a"]["MulCode"] == {"binary": "YWJj"} + assert result["b"]["GN"] == 9 + assert result["b"]["MulCode"] == {"binary": "YWJj"} + assert result["invalid"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 506cb91ac..61807ca56 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -29,6 +29,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. +재계산 메뉴의 수량 반영도 현재 선택한 산출서 또는 직접 작성 수식표를 사용합니다. 반영 전에 모든 연결 대상과 수식을 검사하며, 기존 원본 연결 데이터는 그대로 보관합니다. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index ac8f5b35e..121e90aa4 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -410,28 +410,9 @@ const EstActions = (() => { false, ); else if (name === "apply-quantity") { - const values = Object.create(null), - totals = new Map(); - for (const row of project.quantities) { - const value = EstMath.floating( - EstExpression.evaluate(row.formula, (key) => values[key]), - row.digits ?? 2, - 1, - ); - values[row.Code] = value; - if (row.TargetCode) totals.set(row.TargetCode, (totals.get(row.TargetCode) || 0) + value); - } - if (!totals.size) throw Error("연결 내역코드를 입력하세요."); + const totals = EstQuantity.totals(project, state.quantitySheet); if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?")) - EstApp.change(() => { - for (const row of project.tables.EstMainDat) - if (totals.has(row.Code)) { - row.GN = totals.get(row.Code); - row.MullangIndex = 1; - row.ExcelSheetIndex = 0; - row.MulCode = row.Code; - } - }); + EstApp.change(() => EstQuantity.applyTotals(project, totals)); } else if (name === "add-round") { const next = { id: uid(), diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index 2c7fb0a56..acafbbf46 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -101,6 +101,7 @@ const EstApp = (() => { state.selection.clear(); state.group = ""; state.page = 0; + state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual"; state.roundId = state.project.progress.find((round) => round.id === state.roundId)?.id || state.project.progress[0]?.id; diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 43d428cea..4333c7506 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -113,6 +113,44 @@ const EstQuantity = (() => { errors: result.errors, }; } + function totals(project, selected) { + let items; + if (selected && selected !== "manual") { + const sheet = project.quantitySheets?.find((s) => s.Code === selected); + if (!sheet) throw Error("선택한 수량산출서가 없습니다."); + const output = calculate(sheet); + if (output.errors.length) throw Error(output.errors.join(" / ")); + items = output.items; + } else { + const values = Object.create(null); + items = (project.quantities || []).map((row) => { + if (!row.Code || Object.hasOwn(values, row.Code)) + throw Error(`산출코드가 비어 있거나 중복됩니다: ${row.Code || ""}`); + const GN = EstMath.floating( + EstExpression.evaluate(row.formula || "0", (key) => values[key]), + row.digits ?? 2, + 1, + ); + values[row.Code] = GN; + return { ...row, GN }; + }); + } + const quantities = new Map(); + for (const item of items) { + const code = item.TargetCode ?? item.LinkCode; + if (!code) continue; + const targets = project.tables.EstMainDat.filter((r) => r.Code === code && !Number(r.Level)); + if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`); + if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`); + quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0)); + } + if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요."); + return quantities; + } + function applyTotals(project, quantities) { + for (const row of project.tables.EstMainDat) + if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code); + } function selector(state) { const { esc } = EstApp; return `
`; @@ -224,30 +262,14 @@ const EstQuantity = (() => { } if (!event.target.closest("[data-quantity-apply]")) return; try { - const output = calculate(sheet); - if (output.errors.length) throw Error(output.errors.join(" / ")); - const quantities = new Map(); - for (const item of output.items) - if (item.TargetCode ?? item.LinkCode) { - const code = item.TargetCode ?? item.LinkCode; - const targets = state.project.tables.EstMainDat.filter( - (r) => r.Code === code && !Number(r.Level), - ); - if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`); - if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`); - quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0)); - } - if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요."); - EstApp.change(() => { - for (const row of state.project.tables.EstMainDat) - if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code); - }); + const quantities = totals(state.project, state.quantitySheet); + EstApp.change(() => applyTotals(state.project, quantities)); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } }); } - return { entities, fromWpt, calculate, selector, render, install }; + return { entities, fromWpt, calculate, totals, applyTotals, selector, render, install }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity; From 8c1da2c29e515851db36cd90e5c5e61760bccf28 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:42:34 +0900 Subject: [PATCH 08/76] feat(est): populate schedule from estimates --- resources/tester/test_est_documents.py | 45 +++++++++++ tools/est-test/README.md | 2 +- tools/est-test/native-schedule.js | 101 +++++++++++++++++++++---- 3 files changed, 133 insertions(+), 15 deletions(-) diff --git a/resources/tester/test_est_documents.py b/resources/tester/test_est_documents.py index 7dab80c36..cb62065c2 100644 --- a/resources/tester/test_est_documents.py +++ b/resources/tester/test_est_documents.py @@ -44,3 +44,48 @@ def test_schedule_dates_ranges_and_weighted_user_progress(): assert result["valid"]["rows"][0]["width"] == 50 assert result["valid"]["rows"][1]["left"] == 50 assert result["bad"] == [True] * 4 + + +def test_schedule_import_and_update_weights_preserve_user_schedule(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(); + p.tables.EstMainDat=[{Code:'A',Name:'one',TC:100}, {Code:'B',Name:'two',TC:300}, + {Code:'HEADER',Level:1,TC:999},{Code:'TOTAL',LinkIndex:54,TC:400}]; + p.schedule=[{id:'old',Code:'A',Name:'custom',start:'2026-01-01',end:'2026-01-10',weight:1,progress:40}, + {id:'manual',Code:'other',Name:'manual',start:'',end:'',weight:5,progress:20}]; + let id=0;const added=EstSchedule.fromEstimate(p,()=>String(++id));p.schedule.push(...added); + p.tables.EstMainDat[0].TC=200;p.schedule=EstSchedule.refreshWeights(p); + let duplicate='';p.tables.EstMainDat.push({Code:'A',TC:20}); + try{EstSchedule.fromEstimate(p,()=>String(++id))}catch(e){duplicate=e.message} + return {added,rows:p.schedule,duplicate}; + })()""", + ) + assert [row["Code"] for row in result["added"]] == ["B"] + assert [row["weight"] for row in result["rows"]] == [200, 5, 300] + assert result["rows"][0]["Name"] == "custom" + assert result["rows"][0]["start"] == "2026-01-01" + assert result["rows"][0]["progress"] == 40 + assert result["duplicate"] + + +def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'X',Name:'one',TC:100},{PkID:2,Code:'X',Name:'two',TC:200}, + {PkID:3,Code:null,Name:'three',TC:300}]; + let id=0;p.schedule=EstSchedule.fromEstimate(p,()=>String(++id)); + p.schedule[0].start='2026-01-01';p.schedule[0].progress=50; + p.tables.EstMainDat.reverse();p.tables.EstMainDat[0].TC=400; + const updated=EstSchedule.refreshWeights(p); + return {rows:p.schedule,updated,again:EstSchedule.fromEstimate(p,()=>String(++id))}; + })()""", + ) + assert [row["SourceKey"] for row in result["rows"]] == ["id:1", "id:2", "id:3"] + assert [row["weight"] for row in result["updated"]] == [100, 200, 400] + assert result["updated"][0]["start"] == "2026-01-01" + assert result["updated"][0]["progress"] == 50 + assert result["again"] == [] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 61807ca56..348b2670d 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -14,7 +14,7 @@ - **조달청 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 실행 검증은 못했습니다. -- **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. +- **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. diff --git a/tools/est-test/native-schedule.js b/tools/est-test/native-schedule.js index f376386af..0138f9a08 100644 --- a/tools/est-test/native-schedule.js +++ b/tools/est-test/native-schedule.js @@ -16,6 +16,11 @@ const EstSchedule = (() => { if (!row || typeof row.id !== "string" || !row.id || ids.has(row.id)) throw Error("공정 항목 ID 오류"); ids.add(row.id); + if ( + row.SourceKey != null && + (typeof row.SourceKey !== "string" || row.SourceKey.length > 1000) + ) + throw Error("공정 내역 연결 오류"); for (const key of ["Code", "Name", "start", "end"]) if (typeof row[key] !== "string" || row[key].length > 1000) throw Error("공정 문자열 오류"); for (const key of ["weight", "progress"]) @@ -53,33 +58,100 @@ const EstSchedule = (() => { end: dated.length ? new Date(last * 86400000).toISOString().slice(0, 10) : "", }; } + function estimateRows(project) { + const rows = new Map(), + codes = new Map(); + for (const row of project.tables.EstMainDat) { + if ( + Number(row.Level) > 0 || + Number(row.CalcIndex) >= 2 || + [54, 55, 58, 59].includes(Number(row.LinkIndex)) + ) + continue; + const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : ""; + if (!key) continue; + if (rows.has(key)) throw Error(`내역 행 식별자 중복: ${key}`); + rows.set(key, row); + if (row.Code) { + const matches = codes.get(row.Code) || []; + matches.push(key); + codes.set(row.Code, matches); + } + } + function sourceKey(row) { + if (row.SourceKey) return rows.has(row.SourceKey) ? row.SourceKey : ""; + const matches = codes.get(row.Code) || []; + if (matches.length > 1) throw Error(`기존 공정의 내역코드 중복: ${row.Code}`); + return matches[0] || ""; + } + return { rows, sourceKey }; + } + function fromEstimate(project, makeId) { + const { rows, sourceKey } = estimateRows(project), + existing = new Set((project.schedule || []).map(sourceKey).filter(Boolean)); + return [...rows.entries()] + .filter(([key]) => !existing.has(key)) + .map(([key, row]) => ({ + id: makeId(), + SourceKey: key, + Code: String(row.Code || ""), + Name: String(row.Name || ""), + start: "", + end: "", + weight: Math.max(0, Number(row.TC) || 0), + progress: 0, + })); + } + function refreshWeights(project) { + const { rows, sourceKey } = estimateRows(project); + return (project.schedule || []).map((row) => { + const key = sourceKey(row); + return { ...row, weight: key ? Math.max(0, Number(rows.get(key).TC) || 0) : row.weight }; + }); + } function render() { const { esc, number, state } = EstApp, output = calculate(state.project); EstApp.byId("work-content").innerHTML = - `
시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h}
`; + `
시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h}
`; } function install() { document.addEventListener("click", (event) => { const node = event.target.closest("[data-action]"); if (!node) return; const p = EstApp.state.project; - if (node.dataset.action === "schedule-add") - EstApp.change(() => { - p.schedule ||= []; - const row = p.tables.EstMainDat.find( - (r) => r.Code && !Number(r.Level) && !p.schedule.some((s) => s.Code === r.Code), - ); - p.schedule.push({ + if (["schedule-import", "schedule-weights"].includes(node.dataset.action)) { + try { + if (node.dataset.action === "schedule-import") { + const added = fromEstimate(p, () => crypto.randomUUID()); + EstApp.change(() => (p.schedule ||= []).push(...added), false); + } else { + const rows = refreshWeights(p); + EstApp.change(() => (p.schedule = rows), false); + } + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } + return; + } + if (node.dataset.action === "schedule-add") { + try { + const row = fromEstimate(p, () => crypto.randomUUID())[0] || { id: crypto.randomUUID(), - Code: row?.Code || "", - Name: row?.Name || "새 공정", + Code: "", + Name: "새 공정", start: "", end: "", - weight: Math.max(0, Number(row?.TC) || 0), + weight: 0, progress: 0, - }); - }, false); + }; + EstApp.change(() => (p.schedule ||= []).push(row), false); + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } + } if (node.dataset.action === "schedule-delete") EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false); }); @@ -90,6 +162,7 @@ const EstSchedule = (() => { row = p.schedule[Number(node.dataset.index)], key = node.dataset.schedule; try { + if (key === "Code" && row.Code !== node.value) delete row.SourceKey; row[key] = ["weight", "progress"].includes(key) ? Number(node.value) : node.value; validate(p); EstApp.change(() => (EstApp.state.project = p), false); @@ -99,5 +172,5 @@ const EstSchedule = (() => { } }); } - return { validate, calculate, render, install }; + return { validate, calculate, fromEstimate, refreshWeights, render, install }; })(); From 19d709305072041c730d52e9174bc68fd2f7b0a2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:44:29 +0900 Subject: [PATCH 09/76] fix(est): avoid reused quantity references --- .../tester/test_est_quantity_workflow.py | 9 +++++++++ tools/est-test/native-actions.js | 20 +++++++++---------- tools/est-test/native-quantity.cjs | 10 +++++++++- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index 9c1bbcd18..7429b3b17 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -127,3 +127,12 @@ def test_manual_and_selected_sheet_totals_validate_before_application(): assert result["b"]["GN"] == 9 assert result["b"]["MulCode"] == {"binary": "YWJj"} assert result["invalid"] + + +def test_new_quantity_code_does_not_reuse_existing_or_referenced_codes(): + result = run( + "math", + """[EstQuantity.newCode([{Code:'Q2',formula:'[Q1]*3'}]), + EstQuantity.newCode([{Code:'Q2',formula:'1'}]), EstQuantity.newCode([])]""", + ) + assert result == ["Q3", "Q1", "Q1"] diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 121e90aa4..57c17ec32 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -398,17 +398,15 @@ const EstActions = (() => { }, ); else if (name === "add-quantity") - EstApp.change( - () => - project.quantities.push({ - Code: `Q${project.quantities.length + 1}`, - Name: "새 수량", - TargetCode: "", - formula: "1", - digits: 2, - }), - false, - ); + EstApp.change(() => { + project.quantities.push({ + Code: EstQuantity.newCode(project.quantities), + Name: "새 수량", + TargetCode: "", + formula: "1", + digits: 2, + }); + }, false); else if (name === "apply-quantity") { const totals = EstQuantity.totals(project, state.quantitySheet); if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?")) diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 4333c7506..d0aa0b5b8 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -151,6 +151,14 @@ const EstQuantity = (() => { for (const row of project.tables.EstMainDat) if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code); } + function newCode(items) { + const codes = new Set(items.map((row) => row.Code)); + for (const row of items) + for (const match of String(row.formula || "").matchAll(/\[([^\]]+)\]/g)) codes.add(match[1]); + let index = 1; + while (codes.has(`Q${index}`)) index++; + return `Q${index}`; + } function selector(state) { const { esc } = EstApp; return `
`; @@ -270,6 +278,6 @@ const EstQuantity = (() => { } }); } - return { entities, fromWpt, calculate, totals, applyTotals, selector, render, install }; + return { entities, fromWpt, calculate, totals, applyTotals, newCode, selector, render, install }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity; From 9380080afe06ee8bf44bee556820aa881e4d258e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:46:09 +0900 Subject: [PATCH 10/76] fix(est): show changes without item codes --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_change_workflow.py | 14 ++++++++++++++ tools/est-test/native-extra.js | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 4ddfc3f6d..819b0c1f2 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -21,6 +21,7 @@ for (const file of [ "native-c3r.js", "native-exchange.js", "native-verify.js", + "native-extra.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); } diff --git a/resources/tester/test_est_change_workflow.py b/resources/tester/test_est_change_workflow.py index ef3fa5ad4..44b9170c6 100644 --- a/resources/tester/test_est_change_workflow.py +++ b/resources/tester/test_est_change_workflow.py @@ -1,6 +1,20 @@ from test_est_rea_parity import run +def test_change_view_includes_named_rows_without_codes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'earthwork',GN:2,TC:200}]; + p.baseline={name:'base',tables:{EstMainDat:[{PkID:1,Code:'',Name:'earthwork',GN:1,TC:100}]}}; + const node={innerHTML:''};globalThis.EstApp={esc:x=>String(x??''),number:x=>String(x??0),byId:()=>node}; + const state={project:p,view:'change'};EstExtra.render(state);return {html:node.innerHTML,changes:EstBusiness.changes(p)}; + })()""", + ) + assert "earthwork" in result["html"] + assert result["changes"][0]["difference"] == 100 + + def test_original_recalculation_modes_keep_old_quantities_and_only_refresh_selected_prices(): result = run( "math", diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 1cbee51b4..2dbc2b90e 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -67,7 +67,7 @@ const EstExtra = (() => { table( ["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"], changes - .filter((row) => row.Code) + .filter((row) => row.Code || row.Name) .map((row) => [ esc({ new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" }[row.status]), esc(row.Code), From b6d98ac9d57908b11988858d87b6626999e678c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:47:58 +0900 Subject: [PATCH 11/76] feat(est): print quantity formulas and results --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_documents.py | 22 ++++++++++++ tools/est-test/README.md | 2 ++ tools/est-test/native-extra.js | 35 ++++++------------ tools/est-test/native-forms.js | 36 ++++++++++++++----- tools/est-test/native-quantity.cjs | 50 +++++++++++++++++++------- 6 files changed, 100 insertions(+), 46 deletions(-) diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 819b0c1f2..938b01250 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -22,6 +22,7 @@ for (const file of [ "native-exchange.js", "native-verify.js", "native-extra.js", + "native-forms.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); } diff --git a/resources/tester/test_est_documents.py b/resources/tester/test_est_documents.py index cb62065c2..77fd19b20 100644 --- a/resources/tester/test_est_documents.py +++ b/resources/tester/test_est_documents.py @@ -89,3 +89,25 @@ def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes(): assert result["updated"][0]["start"] == "2026-01-01" assert result["updated"][0]["progress"] == 50 assert result["again"] == [] + + +def test_quantity_print_includes_manual_and_sheet_formulas_and_errors(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.quantities=[ + {Code:'A',Name:'first',formula:'2',TargetCode:'X'}, + {Code:'B',Name:'second',formula:'[A]*3',TargetCode:'X'}, + {Code:'C',Name:'bad',formula:'[missing]',TargetCode:'X'}]; + p.quantitySheets=[{Code:'Q',Name:'sheet',options:{},rows:[ + {LineData:'4+5 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',Name:'sheet item',GN:0,TargetCode:'Y'}]}]; + return EstForms.data(14,p); + })()""", + ) + assert [row["GN"] for row in result["rows"]] == [2, 6, None, 9] + assert result["rows"][1]["formula"] == "[A]*3" + assert "4+5" in result["rows"][3]["formula"] + assert result["rows"][3]["TargetCode"] == "Y" + assert result["rows"][2]["Error"] + assert result["errors"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 348b2670d..9331dd4c9 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -31,6 +31,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 재계산 메뉴의 수량 반영도 현재 선택한 산출서 또는 직접 작성 수식표를 사용합니다. 반영 전에 모든 연결 대상과 수식을 검사하며, 기존 원본 연결 데이터는 그대로 보관합니다. +수량산출서 인쇄 서식에는 직접 작성한 수식표와 산출서의 산식, 계산 수량, 연결 내역이 함께 표시됩니다. 계산할 수 없는 수식은 오류를 표시합니다. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 2dbc2b90e..0a53fa34d 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -24,30 +24,17 @@ const EstExtra = (() => { EstQuantity.render(state); return; } - const values = Object.create(null), - rows = project.quantities.map((row, index) => { - let result, - error = ""; - try { - result = EstMath.floating( - EstExpression.evaluate(row.formula || "0", (key) => values[key]), - row.digits ?? 2, - 1, - ); - values[row.Code] = result; - } catch (e) { - error = e.message; - } - return [ - field("quantities", index, "Code", row.Code, "text"), - field("quantities", index, "Name", row.Name, "text"), - field("quantities", index, "TargetCode", row.TargetCode, "text"), - field("quantities", index, "formula", row.formula, "text"), - field("quantities", index, "digits", row.digits ?? 2), - esc(error || number(result)), - button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`), - ]; - }); + const rows = EstQuantity.manual(project.quantities).rows.map((row, index) => { + return [ + field("quantities", index, "Code", row.Code, "text"), + field("quantities", index, "Name", row.Name, "text"), + field("quantities", index, "TargetCode", row.TargetCode, "text"), + field("quantities", index, "formula", row.formula, "text"), + field("quantities", index, "digits", row.digits ?? 2), + esc(row.Error || number(row.GN)), + button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`), + ]; + }); html = EstQuantity.selector(state) + box( diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index 08e6b35b1..d68cb0e5a 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -95,17 +95,33 @@ const EstForms = (() => { rows: output.rows.filter((r) => r.kind === kinds[id]), }; } - if (id === 14) - return { - columns: ["Code", "Name", "formula", "GN", "TargetCode"], - rows: (project.quantitySheets || []).flatMap((sheet) => - EstQuantity.calculate(sheet).items.map((item) => ({ + if (id === 14) { + const manual = EstQuantity.manual(project.quantities || []), + rows = manual.rows.map((row) => ({ + ...row, + SheetName: "수식표", + formula: row.formula || "", + })), + errors = [...manual.errors]; + for (const sheet of project.quantitySheets || []) { + const calculated = EstQuantity.calculate(sheet); + errors.push(...calculated.errors.map((error) => `${sheet.Name || sheet.Code}: ${error}`)); + for (const item of calculated.items) { + const index = sheet.rows.findIndex((row) => (row.markers || []).includes(item.Code)); + rows.push({ ...item, - Code: sheet.Code, - Name: item.Name || sheet.Name, - })), - ), + SheetName: sheet.Name || sheet.Code, + formula: index < 0 ? "" : EstText.clean(calculated.rows[index].LineData), + TargetCode: item.TargetCode ?? item.LinkCode ?? "", + }); + } + } + return { + columns: ["SheetName", "Code", "Name", "formula", "GN", "TargetCode", "Error"], + rows, + errors, }; + } return null; } const labels = { @@ -122,6 +138,8 @@ const EstForms = (() => { WorkCode: "공종 코드", WorkName: "공종명", formula: "산식", + SheetName: "산출서", + Error: "계산 오류", TargetCode: "연결 내역", TU: "단가", TC: "금액", diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index d0aa0b5b8..d5c1116f1 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -113,6 +113,28 @@ const EstQuantity = (() => { errors: result.errors, }; } + function manual(items) { + const values = Object.create(null), + codes = new Set(), + errors = []; + const rows = items.map((row) => { + try { + if (!row.Code || codes.has(row.Code)) throw Error("산출코드가 비어 있거나 중복됩니다."); + codes.add(row.Code); + const GN = EstMath.floating( + EstExpression.evaluate(row.formula || "0", (key) => values[key]), + row.digits ?? 2, + 1, + ); + values[row.Code] = GN; + return { ...row, GN }; + } catch (error) { + errors.push(`${row.Code || "수량"}: ${error.message}`); + return { ...row, GN: null, Error: error.message }; + } + }); + return { rows, errors }; + } function totals(project, selected) { let items; if (selected && selected !== "manual") { @@ -122,18 +144,9 @@ const EstQuantity = (() => { if (output.errors.length) throw Error(output.errors.join(" / ")); items = output.items; } else { - const values = Object.create(null); - items = (project.quantities || []).map((row) => { - if (!row.Code || Object.hasOwn(values, row.Code)) - throw Error(`산출코드가 비어 있거나 중복됩니다: ${row.Code || ""}`); - const GN = EstMath.floating( - EstExpression.evaluate(row.formula || "0", (key) => values[key]), - row.digits ?? 2, - 1, - ); - values[row.Code] = GN; - return { ...row, GN }; - }); + const output = manual(project.quantities || []); + if (output.errors.length) throw Error(output.errors.join(" / ")); + items = output.rows; } const quantities = new Map(); for (const item of items) { @@ -278,6 +291,17 @@ const EstQuantity = (() => { } }); } - return { entities, fromWpt, calculate, totals, applyTotals, newCode, selector, render, install }; + return { + entities, + fromWpt, + calculate, + manual, + totals, + applyTotals, + newCode, + selector, + render, + install, + }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity; From b279b964ceca5a26f4c24af0bfb676c2bac55742 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:51:42 +0900 Subject: [PATCH 12/76] feat(est): link quantity to individual rows --- .../tester/test_est_quantity_workflow.py | 24 ++++++++ tools/est-test/README.md | 2 +- tools/est-test/native-actions.js | 11 ++-- tools/est-test/native-extra.js | 3 +- tools/est-test/native-forms.js | 10 +++- tools/est-test/native-quantity.cjs | 59 +++++++++++++++---- 6 files changed, 89 insertions(+), 20 deletions(-) diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index 7429b3b17..f09de4c74 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -136,3 +136,27 @@ def test_new_quantity_code_does_not_reuse_existing_or_referenced_codes(): EstQuantity.newCode([{Code:'Q2',formula:'1'}]), EstQuantity.newCode([])]""", ) assert result == ["Q3", "Q1", "Q1"] + + +def test_target_row_ids_support_blank_and_duplicate_codes_for_both_modes(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[ + {PkID:1,Code:'X',GN:1},{PkID:2,Code:'X',GN:2},{PkID:3,Code:'',GN:3}]; + p.quantities=[{Code:'A',formula:'7',TargetCode:'X',TargetKey:'id:2'}, + {Code:'B',formula:'4',TargetCode:'',TargetKey:'id:3'}]; + EstQuantity.applyTotals(p,EstQuantity.totals(p,'manual')); + const manual=p.tables.EstMainDat.map(row=>row.GN); + p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2+3 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',GN:0,TargetCode:'X',TargetKey:'id:1'}]}]; + p.tables.EstMainDat.reverse();EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q')); + const sheet=p.tables.EstMainDat.map(row=>[row.PkID,row.GN]); + p.quantitySheets[0].items[0].TargetKey='id:missing';let error=''; + try{EstQuantity.totals(p,'Q')}catch(e){error=e.message} + return {manual,sheet,error}; + })()""", + ) + assert result["manual"] == [1, 7, 4] + assert result["sheet"] == [[3, 4], [2, 7], [1, 5]] + assert result["error"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 9331dd4c9..cc1dedc32 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -25,7 +25,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 기존 ESTX의 기본 수량 연결은 문서·항목 코드와 내역 코드가 유일한 경우 화면에 표시하며, 중복·다른 모드는 원문에 보존하고 경고합니다. 원본 연결 데이터 실자료 검증과 새 연결의 ESTX 쓰기는 아직 지원하지 않습니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다. -수량산출서에서 내역에 수량을 반영할 때 연결 코드에 해당하는 상세 내역이 정확히 하나여야 합니다. 코드가 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. +수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. 수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 57c17ec32..bf9b2c1d6 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -545,11 +545,12 @@ const EstActions = (() => { if (node.matches("[data-extra]")) { const value = node.type === "number" ? Number(node.value) : node.value; if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류"); - EstApp.change( - () => - (project[node.dataset.extra][Number(node.dataset.row)][node.dataset.field] = value), - false, - ); + EstApp.change(() => { + const row = project[node.dataset.extra][Number(node.dataset.row)]; + row[node.dataset.field] = value; + if (node.dataset.extra === "quantities" && node.dataset.field === "TargetCode") + row.TargetKey = ""; + }, false); return; } if (node.matches("[data-table][data-field]")) { diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 0a53fa34d..eadc22255 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -28,7 +28,8 @@ const EstExtra = (() => { return [ field("quantities", index, "Code", row.Code, "text"), field("quantities", index, "Name", row.Name, "text"), - field("quantities", index, "TargetCode", row.TargetCode, "text"), + field("quantities", index, "TargetCode", row.TargetCode, "text") + + EstQuantity.targetSelect(project, row, "manual", index), field("quantities", index, "formula", row.formula, "text"), field("quantities", index, "digits", row.digits ?? 2), esc(row.Error || number(row.GN)), diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index d68cb0e5a..100c39b88 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -96,11 +96,19 @@ const EstForms = (() => { }; } if (id === 14) { + const target = (item) => { + if (!item.TargetKey) return item.TargetCode ?? item.LinkCode ?? ""; + const row = project.tables.EstMainDat.find( + (entry) => entry.PkID != null && `id:${entry.PkID}` === item.TargetKey, + ); + return row ? `${row.Name || "내역"} (${row.Code || item.TargetKey})` : item.TargetKey; + }; const manual = EstQuantity.manual(project.quantities || []), rows = manual.rows.map((row) => ({ ...row, SheetName: "수식표", formula: row.formula || "", + TargetCode: target(row), })), errors = [...manual.errors]; for (const sheet of project.quantitySheets || []) { @@ -112,7 +120,7 @@ const EstForms = (() => { ...item, SheetName: sheet.Name || sheet.Code, formula: index < 0 ? "" : EstText.clean(calculated.rows[index].LineData), - TargetCode: item.TargetCode ?? item.LinkCode ?? "", + TargetCode: target(item), }); } } diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index d5c1116f1..3abea1d08 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -151,18 +151,34 @@ const EstQuantity = (() => { const quantities = new Map(); for (const item of items) { const code = item.TargetCode ?? item.LinkCode; - if (!code) continue; - const targets = project.tables.EstMainDat.filter((r) => r.Code === code && !Number(r.Level)); - if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`); - if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`); - quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0)); + if (!code && !item.TargetKey) continue; + const targets = project.tables.EstMainDat.map((row, index) => ({ row, index })).filter( + ({ row }) => + !Number(row.Level) && + (item.TargetKey + ? row.PkID != null && `id:${row.PkID}` === item.TargetKey + : row.Code === code), + ); + if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`); + if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`); + const index = targets[0].index; + quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0)); } if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요."); return quantities; } function applyTotals(project, quantities) { - for (const row of project.tables.EstMainDat) - if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code); + for (const [index, value] of quantities) project.tables.EstMainDat[index].GN = value; + } + function targetSelect(project, item, section, index) { + const { esc } = EstApp; + const rows = project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level)); + return ``; } function newCode(items) { const codes = new Set(items.map((row) => row.Code)); @@ -186,7 +202,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${number(sheet.items[index].GN)}${number(item.GN)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${number(sheet.items[index].GN)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } function install() { document.addEventListener("change", (event) => { @@ -197,6 +213,23 @@ const EstQuantity = (() => { EstApp.render(); return; } + if (node.dataset.quantitySelect !== undefined) { + const item = + node.dataset.quantitySelect === "manual" + ? state.project.quantities[Number(node.dataset.index)] + : state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items[ + Number(node.dataset.index) + ]; + if (!item) return; + const row = state.project.tables.EstMainDat.find( + (r) => r.PkID != null && `id:${r.PkID}` === node.value && !Number(r.Level), + ); + EstApp.change(() => { + item.TargetKey = row ? node.value : ""; + item.TargetCode = row?.Code || ""; + }, false); + return; + } const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); if (!sheet) return; if (node.dataset.quantityName !== undefined) @@ -213,10 +246,11 @@ const EstQuantity = (() => { false, ); if (node.dataset.quantityTarget !== undefined) - EstApp.change( - () => (sheet.items[Number(node.dataset.quantityTarget)].TargetCode = node.value), - false, - ); + EstApp.change(() => { + const item = sheet.items[Number(node.dataset.quantityTarget)]; + item.TargetCode = node.value; + item.TargetKey = ""; + }, false); }); document.addEventListener("click", (event) => { const { state } = EstApp; @@ -298,6 +332,7 @@ const EstQuantity = (() => { manual, totals, applyTotals, + targetSelect, newCode, selector, render, From 4e50e2fe94380211f1e82722844346c75e1cac52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:53:54 +0900 Subject: [PATCH 13/76] feat(est): reorder annual work stages --- resources/tester/test_est_annual_workflow.py | 10 ++++++++++ tools/est-test/README.md | 2 ++ tools/est-test/native-actions.js | 18 ++++++++++++++++-- tools/est-test/native-business.js | 9 +++++++++ tools/est-test/native-extra.js | 12 +++++++++++- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_est_annual_workflow.py b/resources/tester/test_est_annual_workflow.py index 9a8552441..3beb2c091 100644 --- a/resources/tester/test_est_annual_workflow.py +++ b/resources/tester/test_est_annual_workflow.py @@ -55,3 +55,13 @@ def test_annual_rejects_names_that_shadow_contract_references(): assert all(item["errors"] for item in result[:3]) assert result[3]["errors"] == [] assert result[3]["results"][1]["project"]["tables"]["EstMainDat"][0]["TC"] == 50 + + +def test_new_annual_name_does_not_reuse_existing_or_referenced_names(): + result = run( + "math", + """[EstBusiness.newAnnualName([{name:'2차',formula:'[기준]-[1차]'}]), + EstBusiness.newAnnualName([{name:'2차',formula:'[기준]'}]), + EstBusiness.newAnnualName([])]""", + ) + assert result == ["3차", "1차", "1차"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index cc1dedc32..dbf672263 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -49,6 +49,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다. 참조가 충돌하지 않도록 차수 이름에는 `기준`, `계약`, `계약수량`을 사용할 수 없습니다. +차수는 위·아래로 이동할 수 있습니다. 앞서 계산되지 않은 차수를 참조하면 오류를 표시하며, 새 차수는 이미 사용 중이거나 수식에서 참조 중인 이름을 피해서 만듭니다. + 연차의 조달수수료는 원본처럼 연결 종류 99로 전환해 금액을 배분하며 요율은 유지합니다. 관급 합계와 안전관리비의 수수료 제외 계산에 배분액을 반영하고, 합산 제외 행은 제외 상태를 유지합니다. 차수 내역 열기는 해당 내역서를 표시하며, 저장하면 원본과 다른 파일 ID를 부여해 원본을 덮어쓰지 않습니다. 동원 집계는 비목별 낙찰률과 단위환산을 하위 산근·일위대가에도 전달합니다. 연결 단가가 없는 금액 입력분은 원본 전용 코드로 따로 합산하고, 장비 시간으로 바꿀 수 없는 경비는 `제외금액` 단위로 보존합니다. 산근에서 연결을 해제한 단가는 앞 행의 원천을 가져오지 않습니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index bf9b2c1d6..014d71236 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -432,10 +432,24 @@ const EstActions = (() => { } } else if (name === "add-annual") EstApp.change( - () => project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[기준]" }), + () => + project.annual.push({ + name: EstBusiness.newAnnualName(project.annual), + formula: "[기준]", + }), false, ); - else if (name === "open-annual") { + else if (name === "move-annual") { + const index = Number(node.dataset.row), + next = index + Number(node.dataset.direction); + if (index >= 0 && index < project.annual.length && next >= 0 && next < project.annual.length) + EstApp.change(() => { + [project.annual[index], project.annual[next]] = [ + project.annual[next], + project.annual[index], + ]; + }, false); + } else if (name === "open-annual") { const result = EstBusiness.annualCopy(project, Number(node.dataset.row)); if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) { EstApp.setProject(result); diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index cb908bcbc..2731f2c78 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -216,6 +216,14 @@ const EstBusiness = (() => { function escalation(project) { return EstEscalation.calculate(project); } + function newAnnualName(years) { + const names = new Set(years.map((year) => String(year.name || "").trim())); + for (const year of years) + for (const match of String(year.formula || "").matchAll(/\[([^\]]+)\]/g)) names.add(match[1]); + let index = 1; + while (names.has(`${index}차`)) index++; + return `${index}차`; + } function annual(project, full = true) { const errors = [], results = []; @@ -295,6 +303,7 @@ const EstBusiness = (() => { progress, escalation, annual, + newAnnualName, rowKey, importContract, importBid, diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index eadc22255..64460b7d2 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -278,7 +278,17 @@ const EstExtra = (() => { number(output.results[index]?.project.calculation.direct.TC), number(output.results[index]?.project.calculation.total), button("open-annual", "차수 내역 열기", `data-row="${index}"`), - button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`), + button( + "move-annual", + "↑", + `data-row="${index}" data-direction="-1" ${index === 0 ? "disabled" : ""}`, + ) + + button( + "move-annual", + "↓", + `data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`, + ) + + button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`), ]), ), `수식 예: [기준]*0.5, [기준]-[1차]. 같은 수식을 수량과 각 성분 금액에 따로 적용하고 금액은 원 미만 절사합니다. ${esc(output.errors.join(" / "))}`, From 922c9a05a1119d582861846ddab713b1b38c7078 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 07:59:24 +0900 Subject: [PATCH 14/76] feat(est): add contract review workflow --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_review_workflow.py | 45 ++++++ tools/est-test/README.md | 1 + tools/est-test/export-estx.cjs | 9 +- tools/est-test/index.html | 3 +- tools/est-test/native-app.js | 2 + tools/est-test/native-extra.js | 4 + tools/est-test/native-model.js | 1 + tools/est-test/native-review.js | 154 +++++++++++++++++++ tools/est-test/server.cjs | 1 + 10 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_est_review_workflow.py create mode 100644 tools/est-test/native-review.js diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 938b01250..4a81c6616 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -15,6 +15,7 @@ for (const file of [ "native-escalation.js", "native-progress.js", "native-business.js", + "native-review.js", "native-resources.js", "native-schedule.js", "native-model.js", diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py new file mode 100644 index 000000000..935bbaca7 --- /dev/null +++ b/resources/tester/test_est_review_workflow.py @@ -0,0 +1,45 @@ +from test_est_rea_parity import run + + +def test_review_recalculates_user_inputs_and_opens_independent_copy(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.name='contract';p.id='123456789012345678901234'; + p.options.SourceRefresh=false;p.tables.EstMainDat=[ + {PkID:1,Code:'',Name:'normal',GN:10,GU:100}, + {PkID:2,Code:'',Name:'fixed',GN:1,GC:100,TC:100,CalcIndex:1}, + {LinkIndex:54}]; + p.tables.Wonga=[{Gubun:'D',BaseField:'TC'}, + {Gubun:'V',VSusic:'D*0.1'},{Gubun:'T',LinkIndex:55,VSusic:'D+V'}]; + const q=EstNative.calculate(p);q.contractReview={edits:{'row:1':{GN:2,GU:80},'row:2':{GC:20}}}; + const before=JSON.stringify(q),review=EstReview.calculate(q),copy=EstReview.copy(q); + const same=JSON.stringify(q)===before;copy.tables.EstMainDat[0].GN=99; + return {review,same,original:q.tables.EstMainDat[0].GN,copy}; + })()""", + ) + assert result["same"] and result["original"] == 10 + assert result["review"]["errors"] == [] + assert result["review"]["before"] == 1210 + assert result["review"]["after"] == 198 + assert result["review"]["reviewed"]["tables"]["EstMainDat"][1]["TC"] == 20 + assert "id" not in result["copy"] + assert "contractReview" not in result["copy"] + + +def test_review_rejects_invalid_and_stale_inputs(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1,GU:10}]; + const invalid=[];for(const edit of [{GN:'bad'},{GN:Infinity},{unrelated:1}]){ + p.contractReview={edits:{'row:1':edit}}; + try{EstModel.validate(p);invalid.push(false)}catch{invalid.push(true)} + } + p.contractReview={edits:{'row:missing':{GN:2}}}; + let rejected=false;try{EstReview.copy(p)}catch{rejected=true} + return {invalid,rejected}; + })()""", + ) + assert result["invalid"] == [True, True, True] + assert result["rejected"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index dbf672263..66d2801e8 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -16,6 +16,7 @@ - **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. +- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs index 9764201ce..4b994e233 100644 --- a/tools/est-test/export-estx.cjs +++ b/tools/est-test/export-estx.cjs @@ -163,9 +163,14 @@ function exportEstx(project) { if (!optionFields[key]) throw Error(`계산 옵션 ${key} 저장 구조 미확인`); setOption(...optionFields[key], project.options[key]); } - if (project.progress?.length || project.annual?.length || project.baseline) + if ( + project.progress?.length || + project.annual?.length || + project.baseline || + project.contractReview + ) throw Error( - "웹 기성/연차/당초 사본은 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.", + "웹 기성/연차/당초 사본/계약심사는 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.", ); if (!same(project.quantitySheets || [], base.quantitySheets || [])) project = updateQuantities(project, base, cp949, memory); diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 9d1fb4674..6b96c8bb2 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -16,6 +16,7 @@ + @@ -110,7 +111,7 @@ diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index acafbbf46..b1d0b6754 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -31,6 +31,7 @@ const EstApp = (() => { progress: "기성고", escalation: "물가변동", annual: "연차공사", + review: "계약심사", resources: "동원 집계", schedule: "예정공정표", drawing: "도면·문서", @@ -271,6 +272,7 @@ const EstApp = (() => { setProject(saved ? JSON.parse(saved) : await api("/api/sample")); EstActions.install(); EstQuantity.install(); + EstReview.install(); } catch (error) { byId("messages").hidden = false; byId("messages").textContent = `시작 실패: ${error.message}`; diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 64460b7d2..ca63a3650 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -12,6 +12,10 @@ const EstExtra = (() => { return `
${title}${tools}
${body}${note ? `

${note}

` : ""}`; } function render(state) { + if (state.view === "review") { + EstReview.render(state); + return; + } if (state.view === "schedule") { EstSchedule.render(); return; diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js index f642b027a..891e2ff69 100644 --- a/tools/est-test/native-model.js +++ b/tools/est-test/native-model.js @@ -299,6 +299,7 @@ const EstModel = (() => { } project.options ||= {}; EstSchedule.validate(project); + EstReview.validate(project); if (typeof project.options !== "object" || Array.isArray(project.options)) throw Error("계산 옵션 형식 오류"); const validNumber = (value, label) => { diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js new file mode 100644 index 000000000..6e95bb0dc --- /dev/null +++ b/tools/est-test/native-review.js @@ -0,0 +1,154 @@ +// 계약심사 입력은 작업본에 보관하고, 원래 내역은 사본을 열 때까지 유지한다. +const EstReview = (() => { + const fields = ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC"]; + const special = new Set([51, 52, 54, 55, 57, 58, 59, 62, 67, 68]); + const editable = (row) => !EstMath.num(row.Level) && !special.has(EstMath.num(row.LinkIndex)); + const fixed = (row) => [1, 3].includes(EstMath.num(row.CalcIndex)); + const allowed = (row) => ["GN", ...(fixed(row) ? EstMath.components : EstMath.units)]; + const rowKey = (row, index) => `row:${EstBusiness.rowKey(row, index)}`; + + function validate(project) { + const review = project.contractReview; + if (review == null) return; + if ( + typeof review !== "object" || + Array.isArray(review) || + !review.edits || + typeof review.edits !== "object" || + Array.isArray(review.edits) + ) + throw Error("계약심사 자료 형식 오류"); + if (Object.keys(review.edits).length > 100000) throw Error("계약심사 항목 수 초과"); + for (const [key, edit] of Object.entries(review.edits)) { + if (!key || key.length > 1000 || !edit || typeof edit !== "object" || Array.isArray(edit)) + throw Error("계약심사 입력 형식 오류"); + for (const [field, value] of Object.entries(edit)) { + if ( + !fields.includes(field) || + typeof value !== "number" || + !Number.isFinite(value) || + Math.abs(value) > Number.MAX_SAFE_INTEGER + ) + throw Error(`계약심사 입력 오류: ${field}`); + } + } + } + function calculate(project) { + validate(project); + const edits = project.contractReview?.edits || {}, + errors = [], + seen = new Set(); + const working = EstNative.empty(); + working.options = { ...project.options, SourceRefresh: false, TotalFix: false }; + working.tables.EstMainDat = structuredClone(project.tables.EstMainDat); + working.tables.Wonga = structuredClone(project.tables.Wonga); + const baseline = EstNative.calculate(working); + working.tables.EstMainDat.forEach((row, index) => { + if (!editable(row)) return; + const key = rowKey(row, index); + if (seen.has(key)) errors.push(`심사 내역 식별자 중복: ${key}`); + seen.add(key); + const edit = edits[key]; + if (!edit) return; + for (const field of Object.keys(edit)) { + if (!allowed(row).includes(field)) + errors.push(`${row.Name || key}: 심사할 수 없는 항목 ${field}`); + else row[field] = edit[field]; + } + if (fixed(row)) row.TC = EstMath.sum(row); + }); + for (const key of Object.keys(edits)) + if (!seen.has(key)) errors.push(`심사 대상 내역 없음: ${key}`); + const reviewed = EstNative.calculate(working); + errors.push(...baseline.calculation.errors, ...reviewed.calculation.errors); + const total = (p) => + p.tables.Wonga.length + ? EstMath.num(p.calculation.total) + : EstMath.num(p.calculation.direct.TC); + return { + baseline, + reviewed, + before: total(baseline), + after: total(reviewed), + errors: [...new Set(errors)], + }; + } + function copy(project) { + const output = calculate(project); + if (output.errors.length) throw Error(output.errors.join(" / ")); + const next = structuredClone(project); + delete next.id; + delete next.contractReview; + next.name = `${project.name} · 심사 내역`; + next.tables.EstMainDat = output.reviewed.tables.EstMainDat; + next.tables.Wonga = output.reviewed.tables.Wonga; + next.options.SourceRefresh = false; + next.calculation = output.reviewed.calculation; + return next; + } + function render(state) { + const { esc, number, byId } = EstApp, + project = state.project, + output = calculate(project); + const edits = project.contractReview?.edits || {}; + const rows = output.baseline.tables.EstMainDat.map((row, index) => ({ row, index })).filter( + ({ row }) => editable(row) && (row.Name || row.Code), + ); + byId("work-content").innerHTML = + `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액"].map((value) => ``).join("")}${rows + .map(({ row, index }) => { + const key = rowKey(row, index), + input = edits[key] || {}, + changed = output.reviewed.tables.EstMainDat[index]; + const cell = (field) => + ``; + return `${allowed( + row, + ) + .slice(1) + .map((field) => ``) + .join( + "", + )}`; + }) + .join("")}
${value}
${esc(row.Code)}${esc(row.Name)}${number(row.GN)}${cell("GN")}${cell(field)}${number(row.TC)}${number(changed.TC)}${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}
`; + } + function install() { + document.addEventListener("change", (event) => { + const node = event.target; + if (!node.dataset.review) return; + const value = Number(node.value), + field = node.dataset.review, + key = node.dataset.key; + if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) { + EstApp.state.notice = "유효한 심사 수치를 입력하세요."; + EstApp.render(); + return; + } + EstApp.change(() => { + const review = (EstApp.state.project.contractReview ||= { edits: {} }); + (review.edits[key] ||= {})[field] = value; + }, false); + }); + document.addEventListener("click", (event) => { + const project = EstApp.state.project; + try { + if (event.target.closest("[data-review-copy]")) { + const next = copy(project); + if (confirm("현재 작업 대신 심사 내역 사본을 여시겠습니까?")) { + EstApp.setProject(next); + EstApp.navigate("EstMainDat"); + } + } else if ( + event.target.closest("[data-review-clear]") && + confirm("심사 입력을 초기화하시겠습니까?") + ) + EstApp.change(() => delete project.contractReview, false); + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } + }); + } + return { validate, calculate, copy, render, install }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index bdb69198a..622afa543 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -23,6 +23,7 @@ const scripts = [ "native-escalation.js", "native-progress.js", "native-business.js", + "native-review.js", "native-resources.js", "native-schedule.js", "native-model.js", From f20cbe44d182eb89bc88ca1d626bdb22203838c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:00:37 +0900 Subject: [PATCH 15/76] feat(est): print contract review summary --- resources/tester/test_est_review_workflow.py | 18 +++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/native-forms.js | 28 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py index 935bbaca7..0bf0a45bc 100644 --- a/resources/tester/test_est_review_workflow.py +++ b/resources/tester/test_est_review_workflow.py @@ -43,3 +43,21 @@ def test_review_rejects_invalid_and_stale_inputs(): ) assert result["invalid"] == [True, True, True] assert result["rejected"] + + +def test_review_summary_print_contains_totals_and_item_differences(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'item',GN:2,GU:100}]; + p.contractReview={edits:{'row:1':{GN:1,GU:80}}};return EstForms.data(171,p); + })()""", + ) + assert result["errors"] == [] + assert [result["rows"][0][key] for key in ["originalTC", "TC", "difference"]] == [ + 200, + 80, + -120, + ] + assert result["rows"][1]["Name"] == "item" + assert result["rows"][1]["difference"] == -120 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 66d2801e8..af8c7b2f0 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -16,7 +16,7 @@ - **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. -- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. +- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index 100c39b88..bb3a97f67 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -1,6 +1,34 @@ const EstForms = (() => { const common = ["Code", "Name", "Size", "Unite"]; function data(id, project) { + if (id === 171) { + const output = EstReview.calculate(project); + const rows = output.baseline.tables.EstMainDat.map((row, index) => ({ + row, + result: output.reviewed.tables.EstMainDat[index], + })) + .filter(({ row }) => row.Code || row.Name) + .map(({ row, result }) => ({ + Code: row.Code, + Name: row.Name, + originalGN: row.GN, + GN: result.GN, + originalTC: row.TC, + TC: result.TC, + difference: EstMath.num(result.TC) - EstMath.num(row.TC), + })); + rows.unshift({ + Name: "심사 도급액", + originalTC: output.before, + TC: output.after, + difference: output.after - output.before, + }); + return { + columns: ["Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"], + rows, + errors: output.errors, + }; + } if (id === 109) return { columns: ["Code", "Name", "start", "end", "weight", "progress"], From a8c20c3f19707d836a34a3b6b385f4cdea4359c5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:07:11 +0900 Subject: [PATCH 16/76] feat(est): exchange quantity and schedule sheets --- resources/tester/test_est_exchange.py | 82 +++++++++++++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/exchange.cjs | 26 ++++++++- tools/est-test/native-exchange.js | 49 +++++++++++++++- 4 files changed, 154 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index e9ffdfd63..3ad33577f 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -86,3 +86,85 @@ def test_excel_price_edits_refresh_linked_values_and_keep_binary_fields(): assert result["rejected"] assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 300 assert result["next"]["tables"]["StuffcDat"][0]["Editor"]["binary"] == "AAAA" + + +def test_excel_exports_manual_quantities_and_schedule(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "quantities": [ + { + "Code": "Q1", + "Name": "quantity", + "formula": "[A]*3", + "digits": 2, + "TargetKey": "id:5", + } + ], + "schedule": [ + { + "id": "s1", + "SourceKey": "id:5", + "Code": "", + "Name": "work", + "start": "2026-01-01", + "end": "2026-01-10", + "weight": 100, + "progress": 25, + } + ], + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False) + assert list(workbook["Quantities"].values)[1] == ("Q1", "quantity", None, "id:5", "[A]*3", 2) + assert list(workbook["Schedule"].values)[1] == ( + "s1", + "id:5", + "", + "work", + "2026-01-01", + "2026-01-10", + 100, + 25, + ) + + +def test_excel_imports_manual_quantities_and_schedule_and_rejects_invalid(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',Name:'work',GN:1}]; + const qty={Code:'Q1',Name:'quantity',formula:'2*3',digits:2,TargetKey:'id:5',TargetCode:''}; + const sched={id:'s1',SourceKey:'id:5',Code:'',Name:'work',start:'2026-01-01',end:'2026-01-10',weight:100,progress:25}; + const q=EstExchange.apply(p,{Quantities:[qty],Schedule:[sched]}); + EstQuantity.applyTotals(q,EstQuantity.totals(q,'manual')); + let invalid=0;for(const bad of [{Quantities:[qty,qty]},{Quantities:[{...qty,digits:7}]}, + {Schedule:[{...sched,progress:101}]}])try{EstExchange.apply(p,bad)}catch{invalid++} + return {q,invalid,original:p}; + })()""", + ) + assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6 + assert result["q"]["schedule"][0]["SourceKey"] == "id:5" + assert result["q"]["schedule"][0]["progress"] == 25 + assert result["original"]["tables"]["EstMainDat"][0]["GN"] == 1 + assert result["invalid"] == 3 + + +def test_excel_writes_quantity_marker_controls_as_valid_xml(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {"CostMainDat": [{"PkID": 1, "LineData": "A=\x121.5\x12 _x0012_"}]}, + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["CostMainDat"]["B2"].value == "A=_x0012_1.5_x0012_ _x005F_x0012_" + assert ( + run("math", "EstExchange.decodeExcelText('A=_x0012_1.5_x0012_ _x005F_x0012_')") + == "A=\x121.5\x12 _x0012_" + ) diff --git a/tools/est-test/README.md b/tools/est-test/README.md index af8c7b2f0..735f279fa 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -10,7 +10,7 @@ ## 추가 입출력·문서 기능 (2026-11-06) -- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드를 숫자·문자·불리언으로 내보냅니다. 첫 행 필드명과 시트 이름을 유지한 파일을 다시 읽어 해당 표를 교체합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·업무 회차·당초 사본을 넣지는 않으므로 전체 백업은 JSON을 사용합니다. +- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드와 직접 작성 수식표·예정공정표를 내보내고 다시 읽어 해당 자료를 교체합니다. 수식표와 공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·기성 회차·당초 사본이나 산출서 문서 전체를 넣지는 않으므로 전체 백업은 JSON을 사용합니다. - **조달청 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 실행 검증은 못했습니다. diff --git a/tools/est-test/exchange.cjs b/tools/est-test/exchange.cjs index 0d405695f..d7c690a80 100644 --- a/tools/est-test/exchange.cjs +++ b/tools/est-test/exchange.cjs @@ -4,6 +4,13 @@ const xml = (value) => /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], ); +const excelText = (value) => + String(value) + .replace(/_x[\da-f]{4}_/gi, (match) => `_x005F_${match.slice(1)}`) + .replace( + /[\x00-\x08\x0b-\x0d\x0e-\x1f\ufffe\uffff]/g, + (char) => `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}_`, + ); const column = (index) => { let name = ""; for (index++; index; index = Math.floor((index - 1) / 26)) @@ -27,7 +34,7 @@ function sheet(rows) { if (typeof v === "boolean") return `${v ? 1 : 0}`; const text = typeof v === "object" ? JSON.stringify(v) : String(v); if (text.length > 32767) throw Error("Excel 셀 문자 한도 초과: " + ref); - return `${xml(text)}`; + return `${xml(excelText(text))}`; }) .join("")}`, ) @@ -59,6 +66,23 @@ function workbook(project) { ], }); } + for (const [name, rows, keys] of [ + [ + "Quantities", + project.quantities, + ["Code", "Name", "TargetCode", "TargetKey", "formula", "digits"], + ], + [ + "Schedule", + project.schedule, + ["id", "SourceKey", "Code", "Name", "start", "end", "weight", "progress"], + ], + ]) + if (Array.isArray(rows) && rows.length) + sheets.push({ + name, + rows: [keys, ...rows.map((row) => keys.map((key) => row[key] ?? null))], + }); sheets.unshift({ name: "공사정보", rows: [ diff --git a/tools/est-test/native-exchange.js b/tools/est-test/native-exchange.js index 49fbbcf9f..210076ddd 100644 --- a/tools/est-test/native-exchange.js +++ b/tools/est-test/native-exchange.js @@ -1,4 +1,6 @@ const EstExchange = (() => { + const decodeExcelText = (value) => + value.replace(/_x([\da-f]{4})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); function xmlText(buffer) { const bytes = new Uint8Array(buffer); if (bytes[0] === 0xff && bytes[1] === 0xfe) @@ -31,7 +33,11 @@ const EstExchange = (() => { const tables = {}; for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) { const name = sheet.getAttribute("name"); - if (!EstNative.empty().tables[name] || name.startsWith("Cad")) continue; + if ( + (!EstNative.empty().tables[name] && !["Quantities", "Schedule"].includes(name)) || + name.startsWith("Cad") + ) + continue; const id = sheet.getAttributeNS( "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "id", @@ -68,7 +74,7 @@ const EstExchange = (() => { value = Number(v); if (!Number.isFinite(value)) throw Error("Excel 숫자 오류"); } - values[at] = value; + values[at] = typeof value === "string" ? decodeExcelText(value) : value; } return values; }) @@ -96,6 +102,43 @@ const EstExchange = (() => { function apply(project, tables) { const next = structuredClone(project); for (const [table, rows] of Object.entries(tables)) { + if (table === "Quantities") { + const seen = new Set(); + next.quantities = rows.map((row) => { + if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code)) + throw Error("수식표 코드가 비어 있거나 중복됩니다."); + seen.add(row.Code); + for (const key of ["Name", "TargetCode", "TargetKey", "formula"]) + if (row[key] != null && typeof row[key] !== "string") + throw Error(`수식표 ${key} 형식 오류`); + const digits = row.digits ?? 2; + if (!Number.isInteger(digits) || digits < 0 || digits > 6) + throw Error("수식표 소수자리는 0~6입니다."); + return { + Code: row.Code, + Name: row.Name ?? "", + TargetCode: row.TargetCode ?? "", + TargetKey: row.TargetKey ?? "", + formula: row.formula ?? "0", + digits, + }; + }); + continue; + } + if (table === "Schedule") { + next.schedule = rows.map((row) => ({ + id: row.id, + SourceKey: row.SourceKey ?? "", + Code: row.Code ?? "", + Name: row.Name ?? "", + start: row.start ?? "", + end: row.end ?? "", + weight: row.weight ?? 0, + progress: row.progress ?? 0, + })); + EstSchedule.validate(next); + continue; + } const old = next.tables[table], byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r])); const keys = rows[0] ? Object.keys(rows[0]) : []; @@ -275,5 +318,5 @@ const EstExchange = (() => { } }); } - return { read, apply, exportExcel, exportNative, exportJdl, install }; + return { read, apply, decodeExcelText, exportExcel, exportNative, exportJdl, install }; })(); From ab5a07ff346746ea7e921aa613bc3fd684f90113 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:12:27 +0900 Subject: [PATCH 17/76] feat(est): exchange quantity documents in Excel --- resources/tester/test_est_exchange.py | 65 ++++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/exchange.cjs | 46 +++++++++++++ tools/est-test/native-exchange.js | 98 ++++++++++++++++++++++++++- 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index 3ad33577f..153511e65 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -168,3 +168,68 @@ def test_excel_writes_quantity_marker_controls_as_valid_xml(): run("math", "EstExchange.decodeExcelText('A=_x0012_1.5_x0012_ _x005F_x0012_')") == "A=\x121.5\x12 _x0012_" ) + + +def test_excel_exports_and_imports_quantity_documents_with_markers(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "quantitySheets": [ + { + "Code": "Q", + "Name": "sheet", + "options": {"digits": 2, "roundMode": 2}, + "rows": [{"LineData": "2*3 = \x120\x12", "markers": ["r1"]}], + "items": [{"Code": "r1", "Name": "item", "GN": 0, "TargetKey": "id:5"}], + } + ], + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["QDocuments"]["A2"].value == "Q" + assert workbook["QLines"]["C2"].value == "2*3 = _x0012_0_x0012_" + assert workbook["QItems"]["I2"].value == "id:5" + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',GN:1}]; + p.quantitySheets=[{Code:'Q',Name:'old',options:{digits:2,roundMode:2}, + rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',Name:'old',GN:0,Extra:'preserved'}]}]; + const tables={QDocuments:[{Code:'Q',Name:'edited',digits:2,roundMode:2}], + QLines:[{DocumentCode:'Q',Position:1,LineData:'2*3 = \\x120\\x12',Markers:'["r1"]'}], + QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',GN:0,TargetKey:'id:5'}]}; + const q=EstExchange.apply(p,tables),out=EstQuantity.calculate(q.quantitySheets[0]); + EstQuantity.applyTotals(q,EstQuantity.totals(q,'Q')); + let rejected=0;for(const bad of [{QDocuments:tables.QDocuments}, + {...tables,QLines:[{...tables.QLines[0],Markers:'not-json'}]}, + {...tables,QItems:[...tables.QItems,...tables.QItems]}]) + try{EstExchange.apply(p,bad)}catch{rejected++} + return {q,out,rejected,original:p.quantitySheets[0].Name}; + })()""", + ) + assert result["q"]["quantitySheets"][0]["Name"] == "edited" + assert result["q"]["quantitySheets"][0]["items"][0]["Extra"] == "preserved" + assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6 + assert result["out"]["errors"] == [] + assert result["rejected"] == 3 + assert result["original"] == "old" + + +def test_excel_quantity_document_roundtrip_keeps_optional_original_fields(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.quantitySheets=[{Code:'Q',Name:'sheet',options:{digits:2,roundMode:2}, + rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',Name:'item',Size:null,Unite:null,GN:null,LinkCode:null,Extra:'keep'}]}]; + const tables={QDocuments:[{Code:'Q',Name:'sheet',digits:2,roundMode:2}], + QLines:[{DocumentCode:'Q',Position:1,LineData:'1 = \\x120\\x12',Markers:'["r1"]'}], + QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',Size:null,Unite:null,GN:null,TargetCode:null,TargetKey:null}]}; + return {before:p.quantitySheets,after:EstExchange.apply(p,tables).quantitySheets}; + })()""", + ) + assert result["before"] == result["after"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 735f279fa..ed2036eb9 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -10,7 +10,7 @@ ## 추가 입출력·문서 기능 (2026-11-06) -- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드와 직접 작성 수식표·예정공정표를 내보내고 다시 읽어 해당 자료를 교체합니다. 수식표와 공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·기성 회차·당초 사본이나 산출서 문서 전체를 넣지는 않으므로 전체 백업은 JSON을 사용합니다. +- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표와 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems` 세 시트로 구성되며 함께 가져옵니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·기성 회차·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다. - **조달청 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 실행 검증은 못했습니다. diff --git a/tools/est-test/exchange.cjs b/tools/est-test/exchange.cjs index d7c690a80..31aa2d9ab 100644 --- a/tools/est-test/exchange.cjs +++ b/tools/est-test/exchange.cjs @@ -83,6 +83,52 @@ function workbook(project) { name, rows: [keys, ...rows.map((row) => keys.map((key) => row[key] ?? null))], }); + if (project.quantitySheets?.length) { + const docs = [["Code", "Name", "digits", "roundMode"]], + lines = [["DocumentCode", "Position", "LineData", "Markers"]], + items = [ + [ + "DocumentCode", + "Position", + "Code", + "Name", + "Size", + "Unite", + "GN", + "TargetCode", + "TargetKey", + ], + ]; + for (const document of project.quantitySheets) { + docs.push([ + document.Code, + document.Name, + document.options?.digits ?? 2, + document.options?.roundMode ?? 2, + ]); + document.rows.forEach((row, index) => + lines.push([document.Code, index + 1, row.LineData, JSON.stringify(row.markers || [])]), + ); + document.items.forEach((row, index) => + items.push([ + document.Code, + index + 1, + row.Code, + row.Name, + row.Size, + row.Unite, + row.GN, + row.TargetCode ?? row.LinkCode, + row.TargetKey, + ]), + ); + } + sheets.push( + { name: "QDocuments", rows: docs }, + { name: "QLines", rows: lines }, + { name: "QItems", rows: items }, + ); + } sheets.unshift({ name: "공사정보", rows: [ diff --git a/tools/est-test/native-exchange.js b/tools/est-test/native-exchange.js index 210076ddd..74b6855bc 100644 --- a/tools/est-test/native-exchange.js +++ b/tools/est-test/native-exchange.js @@ -34,7 +34,8 @@ const EstExchange = (() => { for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) { const name = sheet.getAttribute("name"); if ( - (!EstNative.empty().tables[name] && !["Quantities", "Schedule"].includes(name)) || + (!EstNative.empty().tables[name] && + !["Quantities", "Schedule", "QDocuments", "QLines", "QItems"].includes(name)) || name.startsWith("Cad") ) continue; @@ -99,9 +100,104 @@ const EstExchange = (() => { throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요."); return tables; } + function quantitySheets(project, tables) { + const names = ["QDocuments", "QLines", "QItems"]; + if (!names.some((name) => Object.hasOwn(tables, name))) return null; + if (!names.every((name) => Object.hasOwn(tables, name))) + throw Error("수량산출서는 QDocuments·QLines·QItems 시트가 모두 필요합니다."); + const old = new Map((project.quantitySheets || []).map((sheet) => [sheet.Code, sheet])); + const docs = new Map(); + for (const row of tables.QDocuments) { + if (typeof row.Code !== "string" || !row.Code || docs.has(row.Code)) + throw Error("수량산출서 코드가 비어 있거나 중복됩니다."); + if (row.Name != null && typeof row.Name !== "string") throw Error("수량산출서 이름 오류"); + const digits = row.digits ?? 2, + roundMode = row.roundMode ?? 2; + if ( + !Number.isInteger(digits) || + digits < 0 || + digits > 12 || + !Number.isInteger(roundMode) || + roundMode < 0 || + roundMode > 4 + ) + throw Error("수량산출서 자릿수·반올림 방식 오류"); + docs.set(row.Code, { + ...old.get(row.Code), + Code: row.Code, + Name: row.Name ?? "", + rows: [], + items: [], + options: { ...old.get(row.Code)?.options, digits, roundMode }, + }); + } + function grouped(rows) { + const groups = new Map(), + positions = new Set(); + for (const row of rows) { + if (!docs.has(row.DocumentCode)) throw Error(`수량산출서 코드 없음: ${row.DocumentCode}`); + if (!Number.isInteger(row.Position) || row.Position < 1) + throw Error("수량산출서 행 순서 오류"); + const key = JSON.stringify([row.DocumentCode, row.Position]); + if (positions.has(key)) throw Error("수량산출서 행 순서 중복"); + positions.add(key); + if (!groups.has(row.DocumentCode)) groups.set(row.DocumentCode, []); + groups.get(row.DocumentCode).push(row); + } + for (const group of groups.values()) group.sort((a, b) => a.Position - b.Position); + return groups; + } + const lines = grouped(tables.QLines), + items = grouped(tables.QItems); + for (const [code, doc] of docs) { + doc.rows = (lines.get(code) || []).map((row) => { + if (row.LineData != null && typeof row.LineData !== "string") + throw Error("산출근거 문자 오류"); + let markers; + try { + markers = JSON.parse(row.Markers || "[]"); + } catch { + throw Error("수량 결과 표지 형식 오류"); + } + if (!Array.isArray(markers) || markers.some((value) => typeof value !== "string")) + throw Error("수량 결과 표지 형식 오류"); + return { LineData: row.LineData ?? "", markers }; + }); + const prior = new Map((old.get(code)?.items || []).map((item) => [item.Code, item])); + const seen = new Set(); + doc.items = (items.get(code) || []).map((row) => { + if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code)) + throw Error("수량 항목 코드가 비어 있거나 중복됩니다."); + seen.add(row.Code); + for (const key of ["Name", "Size", "Unite", "TargetCode", "TargetKey"]) + if (row[key] != null && typeof row[key] !== "string") + throw Error(`수량 항목 ${key} 형식 오류`); + const previous = prior.get(row.Code), + next = { ...previous, Code: row.Code }; + for (const key of ["Name", "Size", "Unite"]) { + if (row[key] != null) next[key] = row[key]; + else if (!previous || previous[key] != null) next[key] = ""; + } + if (row.GN != null) { + if (typeof row.GN !== "number" || !Number.isFinite(row.GN)) + throw Error("수량 항목 수치 오류"); + next.GN = row.GN; + } else if (!previous || previous.GN != null) next.GN = 0; + if (row.TargetCode != null) next.TargetCode = row.TargetCode; + else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = ""; + if (row.TargetKey != null) next.TargetKey = row.TargetKey; + else if (previous?.TargetKey) next.TargetKey = ""; + return next; + }); + } + return [...docs.values()]; + } function apply(project, tables) { const next = structuredClone(project); + const sheets = quantitySheets(next, tables); + if (sheets) next.quantitySheets = sheets; for (const [table, rows] of Object.entries(tables)) { + if (["QDocuments", "QLines", "QItems"].includes(table)) continue; if (table === "Quantities") { const seen = new Set(); next.quantities = rows.map((row) => { From 1d02b0205af09de813904e3e1ddc381405ab8ee7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:14:32 +0900 Subject: [PATCH 18/76] fix(est): block missing quantity markers --- .../tester/test_est_quantity_workflow.py | 21 +++++++++++++++++++ tools/est-test/README.md | 2 ++ tools/est-test/native-quantity.cjs | 8 ++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index f09de4c74..bbe55e142 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -160,3 +160,24 @@ def test_target_row_ids_support_blank_and_duplicate_codes_for_both_modes(): assert result["manual"] == [1, 7, 4] assert result["sheet"] == [[3, 4], [2, 7], [1, 5]] assert result["error"] + + +def test_missing_or_extra_result_markers_block_quantity_application(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'X',GN:1}]; + p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'2*3 = \\x120\\x12',markers:['r1']}], + items:[{Code:'r1',GN:9,TargetKey:'id:1'}]}]; + const sheet=p.quantitySheets[0],valid=EstQuantity.calculate(sheet); + const bad=[];for(const line of ['2*3 = 0','2*3 = \\x120\\x12 + \\x120\\x12']){ + sheet.rows[0].LineData=line;let blocked=false; + try{EstQuantity.applyTotals(p,EstQuantity.totals(p,'Q'))}catch{blocked=true} + bad.push({blocked,errors:EstQuantity.calculate(sheet).errors,quantity:p.tables.EstMainDat[0].GN}); + } + return {valid,bad}; + })()""", + ) + assert result["valid"]["errors"] == [] + assert result["valid"]["items"][0]["GN"] == 6 + assert all(row["blocked"] and row["errors"] and row["quantity"] == 1 for row in result["bad"]) diff --git a/tools/est-test/README.md b/tools/est-test/README.md index ed2036eb9..4fa4b9024 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -34,6 +34,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량산출서 인쇄 서식에는 직접 작성한 수식표와 산출서의 산식, 계산 수량, 연결 내역이 함께 표시됩니다. 계산할 수 없는 수식은 오류를 표시합니다. +산출 행의 결과 표지 개수가 연결 정보와 다르면 계산 오류로 표시하며, 기존 저장 수량을 내역에 반영하지 않습니다. 화면이나 Excel에서 수식을 수정할 때 결과 표지와 연결 항목을 유지하세요. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 3abea1d08..85a409322 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -56,6 +56,12 @@ const EstQuantity = (() => { return { Code: code, Name: name, rows, items, options }; } function calculate(sheet) { + const markerErrors = sheet.rows.flatMap((row, index) => { + const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length; + return count === (row.markers || []).length + ? [] + : [`${index + 1}행: 결과 표지 수가 다릅니다.`]; + }); const input = [ { Index: 1, Code: sheet.Code, LineData: "" }, ...sheet.rows.map((row) => { @@ -110,7 +116,7 @@ const EstQuantity = (() => { rows: result.rows.slice(1), items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })), checks, - errors: result.errors, + errors: [...result.errors, ...markerErrors], }; } function manual(items) { From 2f20e29d31eb913d55e28ed3048edf3b1c0eaa8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:18:19 +0900 Subject: [PATCH 19/76] feat(est): record contract review reasons --- resources/tester/test_est_review_workflow.py | 5 +++-- tools/est-test/README.md | 2 +- tools/est-test/native-forms.js | 7 +++++-- tools/est-test/native-review.js | 22 ++++++++++++++------ 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py index 0bf0a45bc..9aa01c231 100644 --- a/resources/tester/test_est_review_workflow.py +++ b/resources/tester/test_est_review_workflow.py @@ -12,7 +12,7 @@ def test_review_recalculates_user_inputs_and_opens_independent_copy(): {LinkIndex:54}]; p.tables.Wonga=[{Gubun:'D',BaseField:'TC'}, {Gubun:'V',VSusic:'D*0.1'},{Gubun:'T',LinkIndex:55,VSusic:'D+V'}]; - const q=EstNative.calculate(p);q.contractReview={edits:{'row:1':{GN:2,GU:80},'row:2':{GC:20}}}; + const q=EstNative.calculate(p);q.contractReview={edits:{'row:1':{GN:2,GU:80,Reason:'checked'},'row:2':{GC:20}}}; const before=JSON.stringify(q),review=EstReview.calculate(q),copy=EstReview.copy(q); const same=JSON.stringify(q)===before;copy.tables.EstMainDat[0].GN=99; return {review,same,original:q.tables.EstMainDat[0].GN,copy}; @@ -50,7 +50,7 @@ def test_review_summary_print_contains_totals_and_item_differences(): "math", """(()=>{ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'',Name:'item',GN:2,GU:100}]; - p.contractReview={edits:{'row:1':{GN:1,GU:80}}};return EstForms.data(171,p); + p.contractReview={edits:{'row:1':{GN:1,GU:80,Reason:'quantity reviewed'}}};return EstForms.data(171,p); })()""", ) assert result["errors"] == [] @@ -61,3 +61,4 @@ def test_review_summary_print_contains_totals_and_item_differences(): ] assert result["rows"][1]["Name"] == "item" assert result["rows"][1]["difference"] == -120 + assert result["rows"][1]["Reason"] == "quantity reviewed" diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 4fa4b9024..ac7a538e0 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -16,7 +16,7 @@ - **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. -- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. +- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가·심사 사유를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교·사유와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index bb3a97f67..28cb291ec 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -5,10 +5,11 @@ const EstForms = (() => { const output = EstReview.calculate(project); const rows = output.baseline.tables.EstMainDat.map((row, index) => ({ row, + index, result: output.reviewed.tables.EstMainDat[index], })) .filter(({ row }) => row.Code || row.Name) - .map(({ row, result }) => ({ + .map(({ row, index, result }) => ({ Code: row.Code, Name: row.Name, originalGN: row.GN, @@ -16,6 +17,7 @@ const EstForms = (() => { originalTC: row.TC, TC: result.TC, difference: EstMath.num(result.TC) - EstMath.num(row.TC), + Reason: project.contractReview?.edits?.[EstReview.rowKey(row, index)]?.Reason || "", })); rows.unshift({ Name: "심사 도급액", @@ -24,7 +26,7 @@ const EstForms = (() => { difference: output.after - output.before, }); return { - columns: ["Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"], + columns: ["Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference", "Reason"], rows, errors: output.errors, }; @@ -176,6 +178,7 @@ const EstForms = (() => { formula: "산식", SheetName: "산출서", Error: "계산 오류", + Reason: "심사 사유", TargetCode: "연결 내역", TU: "단가", TC: "금액", diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js index 6e95bb0dc..d2b63b24f 100644 --- a/tools/est-test/native-review.js +++ b/tools/est-test/native-review.js @@ -23,6 +23,11 @@ const EstReview = (() => { if (!key || key.length > 1000 || !edit || typeof edit !== "object" || Array.isArray(edit)) throw Error("계약심사 입력 형식 오류"); for (const [field, value] of Object.entries(edit)) { + if (field === "Reason") { + if (typeof value !== "string" || value.length > 10000) + throw Error("계약심사 사유 형식 오류"); + continue; + } if ( !fields.includes(field) || typeof value !== "number" || @@ -51,6 +56,7 @@ const EstReview = (() => { const edit = edits[key]; if (!edit) return; for (const field of Object.keys(edit)) { + if (field === "Reason") continue; if (!allowed(row).includes(field)) errors.push(`${row.Name || key}: 심사할 수 없는 항목 ${field}`); else row[field] = edit[field]; @@ -95,7 +101,7 @@ const EstReview = (() => { ({ row }) => editable(row) && (row.Name || row.Code), ); byId("work-content").innerHTML = - `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액"].map((value) => ``).join("")}${rows + `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${value}
${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유"].map((value) => ``).join("")}${rows .map(({ row, index }) => { const key = rowKey(row, index), input = edits[key] || {}, @@ -109,7 +115,7 @@ const EstReview = (() => { .map((field) => ``) .join( "", - )}`; + )}`; }) .join("")}
${value}
${cell(field)}${number(row.TC)}${number(changed.TC)}${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}
${number(row.TC)}${number(changed.TC)}${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}
`; } @@ -117,10 +123,14 @@ const EstReview = (() => { document.addEventListener("change", (event) => { const node = event.target; if (!node.dataset.review) return; - const value = Number(node.value), - field = node.dataset.review, + const field = node.dataset.review, + value = field === "Reason" ? node.value : Number(node.value), key = node.dataset.key; - if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) { + if ( + field === "Reason" + ? value.length > 10000 + : !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER + ) { EstApp.state.notice = "유효한 심사 수치를 입력하세요."; EstApp.render(); return; @@ -150,5 +160,5 @@ const EstReview = (() => { } }); } - return { validate, calculate, copy, render, install }; + return { validate, calculate, copy, rowKey, render, install }; })(); From 50d66fae7805637793e84de15b35d1d9ab777b5b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:20:03 +0900 Subject: [PATCH 20/76] feat(est): exchange contract review inputs --- resources/tester/test_est_exchange.py | 30 +++++++++++++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/exchange.cjs | 12 +++++++++++ tools/est-test/native-exchange.js | 20 +++++++++++++++++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index 153511e65..d2601b600 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -233,3 +233,33 @@ def test_excel_quantity_document_roundtrip_keeps_optional_original_fields(): })()""", ) assert result["before"] == result["after"] + + +def test_excel_exports_and_imports_contract_review_inputs(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "contractReview": {"edits": {"row:1": {"GN": 2, "GU": 80, "Reason": "checked"}}}, + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["Review"]["A2"].value == "row:1" + assert workbook["Review"]["D2"].value == 80 + assert workbook["Review"]["K2"].value == "checked" + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:2,GU:100}]; + const row={RowKey:'row:1',GN:1,GU:80,Reason:'checked'}; + const q=EstExchange.apply(p,{Review:[row]});let rejected=0; + for(const bad of [[row,row],[{...row,GU:'bad'}],[{...row,RowKey:'not-key'}]]) + try{EstExchange.apply(p,{Review:bad})}catch{rejected++} + return {review:EstReview.calculate(q),edit:q.contractReview.edits['row:1'],rejected}; + })()""", + ) + assert result["review"]["after"] == 80 + assert result["edit"]["Reason"] == "checked" + assert result["rejected"] == 3 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index ac7a538e0..2d28f24f4 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -10,7 +10,7 @@ ## 추가 입출력·문서 기능 (2026-11-06) -- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표와 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems` 세 시트로 구성되며 함께 가져옵니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·기성 회차·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다. +- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems` 세 시트로 구성되며 함께 가져옵니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·기성 회차·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다. - **조달청 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 실행 검증은 못했습니다. diff --git a/tools/est-test/exchange.cjs b/tools/est-test/exchange.cjs index 31aa2d9ab..12e2257ca 100644 --- a/tools/est-test/exchange.cjs +++ b/tools/est-test/exchange.cjs @@ -129,6 +129,18 @@ function workbook(project) { { name: "QItems", rows: items }, ); } + if (project.contractReview && Object.keys(project.contractReview.edits || {}).length) { + const keys = ["RowKey", "GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"]; + sheets.push({ + name: "Review", + rows: [ + keys, + ...Object.entries(project.contractReview.edits).map(([key, edit]) => + keys.map((field) => (field === "RowKey" ? key : (edit[field] ?? null))), + ), + ], + }); + } sheets.unshift({ name: "공사정보", rows: [ diff --git a/tools/est-test/native-exchange.js b/tools/est-test/native-exchange.js index 74b6855bc..148548985 100644 --- a/tools/est-test/native-exchange.js +++ b/tools/est-test/native-exchange.js @@ -35,7 +35,7 @@ const EstExchange = (() => { const name = sheet.getAttribute("name"); if ( (!EstNative.empty().tables[name] && - !["Quantities", "Schedule", "QDocuments", "QLines", "QItems"].includes(name)) || + !["Quantities", "Schedule", "QDocuments", "QLines", "QItems", "Review"].includes(name)) || name.startsWith("Cad") ) continue; @@ -198,6 +198,24 @@ const EstExchange = (() => { if (sheets) next.quantitySheets = sheets; for (const [table, rows] of Object.entries(tables)) { if (["QDocuments", "QLines", "QItems"].includes(table)) continue; + if (table === "Review") { + const edits = {}; + for (const row of rows) { + if ( + typeof row.RowKey !== "string" || + !row.RowKey.startsWith("row:") || + Object.hasOwn(edits, row.RowKey) + ) + throw Error("계약심사 내역 식별자가 없거나 중복됩니다."); + const edit = {}; + for (const field of ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"]) + if (row[field] != null) edit[field] = row[field]; + edits[row.RowKey] = edit; + } + next.contractReview = { edits }; + EstReview.validate(next); + continue; + } if (table === "Quantities") { const seen = new Set(); next.quantities = rows.map((row) => { From bc41c8716f1dcbb78153f1c8874aaa9ecdb2de3b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:24:00 +0900 Subject: [PATCH 21/76] feat(est): exchange annual and draft progress --- resources/tester/test_est_exchange.py | 58 ++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/exchange.cjs | 46 +++++++++++++ tools/est-test/native-exchange.js | 98 ++++++++++++++++++++++++++- 4 files changed, 201 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index d2601b600..70dc32d36 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -263,3 +263,61 @@ def test_excel_exports_and_imports_contract_review_inputs(): assert result["review"]["after"] == 80 assert result["edit"]["Reason"] == "checked" assert result["rejected"] == 3 + + +def test_excel_exports_only_unlocked_progress_and_annual_stages(): + import openpyxl + + data = export_workbook( + { + "name": "project", + "tables": {}, + "annual": [{"name": "first", "formula": "[기준]*0.5"}], + "progress": [ + {"id": "locked", "name": "old", "locked": True, "inputs": {"1": {"Gum": 2}}}, + { + "id": "draft", + "name": "new", + "locked": False, + "inputs": {"1": {"Gum": 3, "GumMode": "quantity"}}, + }, + ], + } + ) + workbook = openpyxl.load_workbook(io.BytesIO(data)) + assert workbook["Annual"]["A2"].value == "first" + assert workbook["ProgressDrafts"]["A2"].value == "draft" + assert workbook["ProgressDrafts"].max_row == 2 + assert workbook["ProgressInputs"]["A2"].value == "draft" + assert workbook["ProgressInputs"]["D2"].value == 3 + + +def test_excel_imports_progress_drafts_without_changing_locked_rounds(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'A',GN:10,GU:100},{LinkIndex:54}]; + p.progress=[{id:'locked',name:'old',inputs:{'1':{Gum:2}}}]; + const q=EstProgress.lock(EstNative.calculate(p),'locked'); + q.progress.push({id:'draft',name:'new',locked:false,inputs:{'1':{Gum:1}}}); + const locked=JSON.stringify(q.progress[0]); + const tables={ProgressDrafts:[{id:'draft',name:'new',approved:false,separateComponents:true}], + ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'quantity',Gum:3}], + Annual:[{name:'first',formula:'[기준]*0.5'}]}; + const next=EstExchange.apply(q,tables),progress=EstProgress.calculate(next,'draft'); + let rejected=0;for(const bad of [ + {ProgressDrafts:tables.ProgressDrafts}, + {...tables,ProgressInputs:[{RoundId:'locked',RowKey:'1',Gum:9}]}, + {...tables,ProgressDrafts:[{id:'locked'}]}, + {...tables,ProgressInputs:[{RoundId:'draft',RowKey:'1',GumMode:'invalid'}]}, + {...tables,ProgressInputs:[{RoundId:'draft',RowKey:'missing',Gum:1}]}]) + try{EstExchange.apply(q,bad)}catch{rejected++} + return {same:JSON.stringify(next.progress[0])===locked,progress,annual:next.annual,rejected}; + })()""", + ) + assert result["same"] + assert result["progress"]["rows"][0]["JunTC"] == 200 + assert result["progress"]["rows"][0]["TotalTC"] == 500 + assert result["annual"] == [{"name": "first", "formula": "[기준]*0.5"}] + assert result["rejected"] == 5 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 2d28f24f4..f825e3d41 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -10,7 +10,7 @@ ## 추가 입출력·문서 기능 (2026-11-06) -- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems` 세 시트로 구성되며 함께 가져옵니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·기성 회차·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다. +- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력·연차 수식·미확정 기성과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems`, 미확정 기성은 `ProgressDrafts`, `ProgressInputs` 시트를 함께 가져옵니다. 확정 기성의 계약 자료와 누계는 Excel 가져오기 시 유지합니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·확정 기성 자료·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다. - **조달청 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 실행 검증은 못했습니다. diff --git a/tools/est-test/exchange.cjs b/tools/est-test/exchange.cjs index 12e2257ca..f4899efb2 100644 --- a/tools/est-test/exchange.cjs +++ b/tools/est-test/exchange.cjs @@ -141,6 +141,52 @@ function workbook(project) { ], }); } + if (project.annual?.length) { + const keys = ["name", "formula"]; + sheets.push({ + name: "Annual", + rows: [keys, ...project.annual.map((row) => keys.map((key) => row[key] ?? null))], + }); + } + if (project.progress?.length) { + const roundKeys = ["id", "name", "approved", "separateComponents", "amountDigits"], + inputKeys = [ + "RoundId", + "RowKey", + "GumMode", + "Gum", + "GumNC", + "GumGC", + "GumJC", + "GumSC", + "SaMode", + "Sa", + "SaNC", + "SaGC", + "SaJC", + "SaSC", + "QuantityDigits", + "AmountDigits", + ], + drafts = project.progress.filter((round) => !round.locked); + sheets.push({ + name: "ProgressDrafts", + rows: [roundKeys, ...drafts.map((row) => roundKeys.map((key) => row[key] ?? null))], + }); + sheets.push({ + name: "ProgressInputs", + rows: [ + inputKeys, + ...drafts.flatMap((round) => + Object.entries(round.inputs || {}).map(([key, input]) => + inputKeys.map((field) => + field === "RoundId" ? round.id : field === "RowKey" ? key : (input[field] ?? null), + ), + ), + ), + ], + }); + } sheets.unshift({ name: "공사정보", rows: [ diff --git a/tools/est-test/native-exchange.js b/tools/est-test/native-exchange.js index 148548985..ea1638b68 100644 --- a/tools/est-test/native-exchange.js +++ b/tools/est-test/native-exchange.js @@ -35,7 +35,17 @@ const EstExchange = (() => { const name = sheet.getAttribute("name"); if ( (!EstNative.empty().tables[name] && - !["Quantities", "Schedule", "QDocuments", "QLines", "QItems", "Review"].includes(name)) || + ![ + "Quantities", + "Schedule", + "QDocuments", + "QLines", + "QItems", + "Review", + "Annual", + "ProgressDrafts", + "ProgressInputs", + ].includes(name)) || name.startsWith("Cad") ) continue; @@ -196,8 +206,82 @@ const EstExchange = (() => { const next = structuredClone(project); const sheets = quantitySheets(next, tables); if (sheets) next.quantitySheets = sheets; + if (Object.hasOwn(tables, "ProgressDrafts") || Object.hasOwn(tables, "ProgressInputs")) { + if (!Object.hasOwn(tables, "ProgressDrafts") || !Object.hasOwn(tables, "ProgressInputs")) + throw Error("기성 Excel에는 ProgressDrafts와 ProgressInputs 시트가 모두 필요합니다."); + const old = next.progress || [], + locked = old.filter((round) => round.locked); + if (old.slice(0, locked.length).some((round) => !round.locked)) + throw Error("확정 기성 회차 순서 오류"); + const previous = new Map( + old.filter((round) => !round.locked).map((round) => [round.id, round]), + ); + const ids = new Set(locked.map((round) => round.id)), + drafts = new Map(); + for (const row of tables.ProgressDrafts) { + if (typeof row.id !== "string" || !row.id || ids.has(row.id)) + throw Error("기성 회차 ID가 없거나 중복됩니다."); + ids.add(row.id); + const round = { + ...previous.get(row.id), + id: row.id, + name: row.name ?? "", + locked: false, + approved: row.approved ?? false, + separateComponents: row.separateComponents ?? true, + inputs: Object.create(null), + }; + delete round.contract; + delete round.result; + if (row.amountDigits == null) delete round.amountDigits; + else round.amountDigits = row.amountDigits; + drafts.set(row.id, round); + } + const inputFields = [ + "GumMode", + "Gum", + "GumNC", + "GumGC", + "GumJC", + "GumSC", + "SaMode", + "Sa", + "SaNC", + "SaGC", + "SaJC", + "SaSC", + "QuantityDigits", + "AmountDigits", + ]; + for (const row of tables.ProgressInputs) { + const round = drafts.get(row.RoundId); + if ( + !round || + typeof row.RowKey !== "string" || + !row.RowKey || + Object.hasOwn(round.inputs, row.RowKey) + ) + throw Error("기성 입력의 회차·행 식별자가 없거나 중복됩니다."); + const prior = previous.get(row.RoundId)?.inputs?.[row.RowKey] || {}; + const input = Object.fromEntries( + Object.entries(prior).filter(([key]) => !inputFields.includes(key)), + ); + for (const field of inputFields) if (row[field] != null) input[field] = row[field]; + round.inputs[row.RowKey] = input; + } + next.progress = [...locked, ...drafts.values()]; + } for (const [table, rows] of Object.entries(tables)) { - if (["QDocuments", "QLines", "QItems"].includes(table)) continue; + if (["QDocuments", "QLines", "QItems", "ProgressDrafts", "ProgressInputs"].includes(table)) + continue; + if (table === "Annual") { + next.annual = rows.map((row) => { + if (typeof row.name !== "string" || typeof row.formula !== "string") + throw Error("연차 이름·수식 형식 오류"); + return { name: row.name, formula: row.formula }; + }); + continue; + } if (table === "Review") { const edits = {}; for (const row of rows) { @@ -300,6 +384,16 @@ const EstExchange = (() => { return merged; }); } + if (Object.hasOwn(tables, "ProgressDrafts")) { + const counts = new Map(); + next.tables.EstMainDat.forEach((row, index) => { + const key = EstBusiness.rowKey(row, index); + counts.set(key, (counts.get(key) || 0) + 1); + }); + for (const round of next.progress.filter((item) => !item.locked)) + for (const key of Object.keys(round.inputs)) + if (counts.get(key) !== 1) throw Error(`기성 입력 내역이 없거나 중복됩니다: ${key}`); + } return EstModel.validate(next); } async function exportExcel() { From a0461bf042719349ec8f7b91bc23cfee2dc2c0bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:25:13 +0900 Subject: [PATCH 22/76] fix(est): retain legacy quantity links in Excel --- resources/tester/test_est_exchange.py | 4 ++-- tools/est-test/native-exchange.js | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index 70dc32d36..4d153e615 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -225,10 +225,10 @@ def test_excel_quantity_document_roundtrip_keeps_optional_original_fields(): """(()=>{ const p=EstNative.empty();p.quantitySheets=[{Code:'Q',Name:'sheet',options:{digits:2,roundMode:2}, rows:[{LineData:'1 = \\x120\\x12',markers:['r1']}], - items:[{Code:'r1',Name:'item',Size:null,Unite:null,GN:null,LinkCode:null,Extra:'keep'}]}]; + items:[{Code:'r1',Name:'item',Size:null,Unite:null,GN:null,LinkCode:'X',Extra:'keep'}]}]; const tables={QDocuments:[{Code:'Q',Name:'sheet',digits:2,roundMode:2}], QLines:[{DocumentCode:'Q',Position:1,LineData:'1 = \\x120\\x12',Markers:'["r1"]'}], - QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',Size:null,Unite:null,GN:null,TargetCode:null,TargetKey:null}]}; + QItems:[{DocumentCode:'Q',Position:1,Code:'r1',Name:'item',Size:null,Unite:null,GN:null,TargetCode:'X',TargetKey:null}]}; return {before:p.quantitySheets,after:EstExchange.apply(p,tables).quantitySheets}; })()""", ) diff --git a/tools/est-test/native-exchange.js b/tools/est-test/native-exchange.js index ea1638b68..e0e5a665f 100644 --- a/tools/est-test/native-exchange.js +++ b/tools/est-test/native-exchange.js @@ -193,8 +193,14 @@ const EstExchange = (() => { throw Error("수량 항목 수치 오류"); next.GN = row.GN; } else if (!previous || previous.GN != null) next.GN = 0; - if (row.TargetCode != null) next.TargetCode = row.TargetCode; - else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = ""; + if (row.TargetCode != null) { + if ( + !previous || + Object.hasOwn(previous, "TargetCode") || + row.TargetCode !== previous.LinkCode + ) + next.TargetCode = row.TargetCode; + } else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = ""; if (row.TargetKey != null) next.TargetKey = row.TargetKey; else if (previous?.TargetKey) next.TargetKey = ""; return next; From 35d3cc936c91dc274755ea1ea8384456920d66d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:28:29 +0900 Subject: [PATCH 23/76] fix(est): validate imported review targets --- resources/tester/test_est_exchange.py | 5 +++-- tools/est-test/native-exchange.js | 20 ++++++++++++++++++++ tools/est-test/native-review.js | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index 4d153e615..1d6716dd0 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -255,14 +255,15 @@ def test_excel_exports_and_imports_contract_review_inputs(): const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:2,GU:100}]; const row={RowKey:'row:1',GN:1,GU:80,Reason:'checked'}; const q=EstExchange.apply(p,{Review:[row]});let rejected=0; - for(const bad of [[row,row],[{...row,GU:'bad'}],[{...row,RowKey:'not-key'}]]) + for(const bad of [[row,row],[{...row,GU:'bad'}],[{...row,RowKey:'not-key'}], + [{...row,RowKey:'row:missing'}],[{RowKey:'row:1',GC:4}]]) try{EstExchange.apply(p,{Review:bad})}catch{rejected++} return {review:EstReview.calculate(q),edit:q.contractReview.edits['row:1'],rejected}; })()""", ) assert result["review"]["after"] == 80 assert result["edit"]["Reason"] == "checked" - assert result["rejected"] == 3 + assert result["rejected"] == 5 def test_excel_exports_only_unlocked_progress_and_annual_stages(): diff --git a/tools/est-test/native-exchange.js b/tools/est-test/native-exchange.js index e0e5a665f..813fe6409 100644 --- a/tools/est-test/native-exchange.js +++ b/tools/est-test/native-exchange.js @@ -390,6 +390,26 @@ const EstExchange = (() => { return merged; }); } + if (Object.hasOwn(tables, "Review")) { + const targets = new Map(); + next.tables.EstMainDat.forEach((row, index) => { + if (!EstReview.editable(row)) return; + const key = EstReview.rowKey(row, index), + matches = targets.get(key) || []; + matches.push(row); + targets.set(key, matches); + }); + for (const [key, edit] of Object.entries(next.contractReview.edits)) { + const matches = targets.get(key) || []; + if (matches.length !== 1) throw Error(`심사 내역이 없거나 중복됩니다: ${key}`); + if ( + Object.keys(edit).some( + (field) => field !== "Reason" && !EstReview.allowed(matches[0]).includes(field), + ) + ) + throw Error(`해당 내역에 사용할 수 없는 심사 입력: ${key}`); + } + } if (Object.hasOwn(tables, "ProgressDrafts")) { const counts = new Map(); next.tables.EstMainDat.forEach((row, index) => { diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js index d2b63b24f..a18a516be 100644 --- a/tools/est-test/native-review.js +++ b/tools/est-test/native-review.js @@ -160,5 +160,5 @@ const EstReview = (() => { } }); } - return { validate, calculate, copy, rowKey, render, install }; + return { validate, calculate, copy, rowKey, editable, allowed, render, install }; })(); From 3a10256e690de91ef12ed84f732f869af9fb3935 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:30:16 +0900 Subject: [PATCH 24/76] feat(est): reset individual review entries --- resources/tester/test_est_review_workflow.py | 18 ++++++++++++++++++ tools/est-test/README.md | 2 +- tools/est-test/native-review.js | 14 +++++++++++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py index 9aa01c231..39e2cf319 100644 --- a/resources/tester/test_est_review_workflow.py +++ b/resources/tester/test_est_review_workflow.py @@ -62,3 +62,21 @@ def test_review_summary_print_contains_totals_and_item_differences(): assert result["rows"][1]["Name"] == "item" assert result["rows"][1]["difference"] == -120 assert result["rows"][1]["Reason"] == "quantity reviewed" + + +def test_review_reset_one_row_keeps_other_inputs(): + result = run( + "math", + """(()=>{ + const handlers={},p=EstNative.empty(); + p.contractReview={edits:{'row:1':{GU:80,Reason:'first'},'row:2':{GN:3,Reason:'second'}}}; + globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + globalThis.EstApp={state:{project:p},change:fn=>fn(),render:()=>{}}; + EstReview.install(); + const click=key=>handlers.click({target:{closest:s=>s==='[data-review-reset]'?{dataset:{reviewReset:key}}:null}}); + click('row:1');const after=structuredClone(p.contractReview);click('row:2'); + return {after,removed:!Object.hasOwn(p,'contractReview')}; + })()""", + ) + assert result["after"]["edits"] == {"row:2": {"GN": 3, "Reason": "second"}} + assert result["removed"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index f825e3d41..780a61b09 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -16,7 +16,7 @@ - **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 내역 행을 공정으로 일괄 추가하고 내역 금액으로 가중치를 갱신할 수 있습니다. 코드가 없거나 같은 코드가 반복된 행은 내역의 행 식별자로 구분합니다. 기존 공정의 기간·진도·이름은 유지합니다. 공정별 기간 막대와 입력 진도의 가중평균을 표시하며, 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. -- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가·심사 사유를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교·사유와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. +- **계약심사**: 작업모드의 계약심사 화면에서 심사 수량과 비목별 단가·심사 사유를 직접 입력하고 기존액·심사액·차액을 비교합니다. 고정금액 행은 비목별 금액을 입력하며, 원가계산서도 다시 계산합니다. 항목별 입력을 초기화하거나 실행 취소할 수 있습니다. 심사 입력은 JSON에 저장하고, 확인한 결과는 원래 작업과 별도인 심사 내역 사본으로 열 수 있습니다. 심사결과요약서에는 내역별 비교·사유와 도급액 차액을 출력합니다. 심사율이나 법적 기준은 자동으로 정하지 않습니다. Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js index a18a516be..9a6c26d5a 100644 --- a/tools/est-test/native-review.js +++ b/tools/est-test/native-review.js @@ -101,7 +101,7 @@ const EstReview = (() => { ({ row }) => editable(row) && (row.Name || row.Code), ); byId("work-content").innerHTML = - `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유"].map((value) => ``).join("")}${rows + `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${value}
${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => ``).join("")}${rows .map(({ row, index }) => { const key = rowKey(row, index), input = edits[key] || {}, @@ -115,7 +115,7 @@ const EstReview = (() => { .map((field) => ``) .join( "", - )}`; + )}`; }) .join("")}
${value}
${cell(field)}${number(row.TC)}${number(changed.TC)}${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}
${number(row.TC)}${number(changed.TC)}${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}
`; } @@ -143,7 +143,15 @@ const EstReview = (() => { document.addEventListener("click", (event) => { const project = EstApp.state.project; try { - if (event.target.closest("[data-review-copy]")) { + const reset = event.target.closest("[data-review-reset]"); + if (reset && project.contractReview?.edits) { + const key = reset.dataset.reviewReset; + if (Object.hasOwn(project.contractReview.edits, key)) + EstApp.change(() => { + delete project.contractReview.edits[key]; + if (!Object.keys(project.contractReview.edits).length) delete project.contractReview; + }, false); + } else if (event.target.closest("[data-review-copy]")) { const next = copy(project); if (confirm("현재 작업 대신 심사 내역 사본을 여시겠습니까?")) { EstApp.setProject(next); From b3bd093d427ea1fc9ba179b7ef8fd5873d108a9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:45:49 +0900 Subject: [PATCH 25/76] fix(est): preserve fixed review amounts --- resources/tester/test_est_review_workflow.py | 14 ++++++++++++++ tools/est-test/native-review.js | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_review_workflow.py b/resources/tester/test_est_review_workflow.py index 39e2cf319..28a021bc9 100644 --- a/resources/tester/test_est_review_workflow.py +++ b/resources/tester/test_est_review_workflow.py @@ -80,3 +80,17 @@ def test_review_reset_one_row_keeps_other_inputs(): ) assert result["after"]["edits"] == {"row:2": {"GN": 3, "Reason": "second"}} assert result["removed"] + + +def test_fixed_review_preserves_amount_for_reason_or_quantity_only(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Name:'fixed',GN:1,TC:100,CalcIndex:1}]; + return [{Reason:'checked'},{GN:3},{GC:20}].map(edit=>{ + p.contractReview={edits:{'row:1':edit}}; + return EstReview.calculate(p).reviewed.tables.EstMainDat[0].TC; + }); + })()""", + ) + assert result == [100, 100, 20] diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js index 9a6c26d5a..e3fd711cc 100644 --- a/tools/est-test/native-review.js +++ b/tools/est-test/native-review.js @@ -61,7 +61,8 @@ const EstReview = (() => { errors.push(`${row.Name || key}: 심사할 수 없는 항목 ${field}`); else row[field] = edit[field]; } - if (fixed(row)) row.TC = EstMath.sum(row); + if (fixed(row) && EstMath.components.some((field) => Object.hasOwn(edit, field))) + row.TC = EstMath.sum(row); }); for (const key of Object.keys(edits)) if (!seen.has(key)) errors.push(`심사 대상 내역 없음: ${key}`); From cffeb84ce42b0301d7e8661c73bc31b9fad70fcb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 08:48:25 +0900 Subject: [PATCH 26/76] feat(est): print progress and price adjustments --- resources/tester/test_est_documents.py | 25 ++++++++++ tools/est-test/native-forms.js | 64 +++++++++++++++++++++++++- tools/est-test/native-report.js | 15 ++++-- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_est_documents.py b/resources/tester/test_est_documents.py index 77fd19b20..df5c810cd 100644 --- a/resources/tester/test_est_documents.py +++ b/resources/tester/test_est_documents.py @@ -1,6 +1,31 @@ from test_est_rea_parity import run +def test_progress_and_escalation_print_use_selected_calculations(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.EscDesignTotal=1000; + p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'work',GN:10,GU:100,AGU:100,EscActual:3},{LinkIndex:54}]; + p.progress=[{id:'one',name:'first',inputs:{'1':{Gum:2}}}]; + const q=EstProgress.lock(EstNative.calculate(p),'one'); + q.tables.EstMainDat[0].GU=200; + q.progress.push({id:'two',name:'second',inputs:{'1':{Gum:3}}}); + return {locked:EstForms.data('progress-summary',q,{roundId:'one'}), + draft:EstForms.data('progress-summary',q,{roundId:'two'}), + escalation:EstForms.data('escalation-summary',p), + empty:EstForms.data('progress-summary',EstNative.empty())}; + })()""", + ) + assert result["locked"]["rows"][0]["GumTC"] == 200 + assert result["locked"]["rows"][0]["GU"] == 100 + assert result["draft"]["rows"][0]["GumTC"] == 600 + assert result["draft"]["rows"][0]["TotalTC"] == 800 + assert result["escalation"]["rows"][0]["EscExcluded"] == 3 + assert result["escalation"]["rows"][0]["EscApplied"] == 7 + assert result["empty"]["errors"] + + def test_resource_report_separates_each_work_item_without_changing_aggregate(): result = run( "math", diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index 28cb291ec..b4b6fdf1f 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -1,6 +1,54 @@ const EstForms = (() => { const common = ["Code", "Name", "Size", "Unite"]; - function data(id, project) { + function data(id, project, state = {}) { + if (id === "progress-summary") { + const round = + project.progress.find((item) => item.id === state.roundId) || project.progress.at(-1); + if (!round) return { columns: ["Name"], rows: [], errors: ["기성 회차가 없습니다."] }; + const result = + round.locked && round.result ? round.result : EstProgress.calculate(project, round.id); + return { + columns: [ + "Code", + "Name", + "Unite", + "GN", + "TC", + "JunGN", + "JunTC", + "GumGN", + "GumTC", + "SaGN", + "SaTC", + "TotalGN", + "TotalTC", + ], + rows: result.rows, + errors: result.errors, + detail: `${round.name || "기성"} (${round.locked ? "확정" : "작성 중"})`, + }; + } + if (id === "escalation-summary") { + const result = EstEscalation.calculate(project); + return { + columns: [ + "Code", + "Name", + "Unite", + "GN", + "EscExcluded", + "EscApplied", + "TC", + "ENC", + "EGC", + "EJC", + "ESC", + ], + rows: result.rows, + errors: result.errors, + detail: `도급액 ${result.contractTotal} · 조정액 ${result.adjustment} · 조정률 ${result.rate}%`, + }; + } if (id === 171) { const output = EstReview.calculate(project); const rows = output.baseline.tables.EstMainDat.map((row, index) => ({ @@ -179,6 +227,20 @@ const EstForms = (() => { SheetName: "산출서", Error: "계산 오류", Reason: "심사 사유", + JunGN: "전회 수량", + JunTC: "전회 금액", + GumGN: "금회 수량", + GumTC: "금회 금액", + SaGN: "사정 수량", + SaTC: "사정 금액", + TotalGN: "누계 수량", + TotalTC: "누계 금액", + EscExcluded: "제외 수량", + EscApplied: "적용 수량", + ENC: "노무비 증감", + EGC: "재료비 증감", + EJC: "경비 증감", + ESC: "산출경비 증감", TargetCode: "연결 내역", TU: "단가", TC: "금액", diff --git a/tools/est-test/native-report.js b/tools/est-test/native-report.js index 025d64d67..b3cb70ccb 100644 --- a/tools/est-test/native-report.js +++ b/tools/est-test/native-report.js @@ -20,7 +20,7 @@ const EstReport = (() => { const { state, esc } = EstApp; const settings = setting(), form = catalog.find((row) => String(row.Index) === settings.form), - output = form ? EstForms.data(form.Index, state.project) : null; + output = form ? EstForms.data(form.Index, state.project, state) : null; const number = (value) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(Number(value) || 0); const root = document.createElement("div"); @@ -76,15 +76,18 @@ const EstReport = (() => { .forEach((node) => node.removeAttribute("contenteditable")); const title = form?.Name2 || EstApp.titles[state.view] || state.view; const detail = - state.view === "quantity" + output?.detail || + (state.view === "quantity" ? state.project.quantitySheets?.find((sheet) => sheet.Code === state.quantitySheet)?.Name : state.view === "progress" ? state.project.progress.find((round) => round.id === state.roundId)?.name : state.group ? EstNative.groups(state.project.tables[state.view] || []).get(state.group)?.rows[0] ?.Name - : ""; - const totals = state.project.calculation; + : ""); + const totals = ["progress-summary", "escalation-summary"].includes(form?.Index) + ? null + : state.project.calculation; const errors = [...(totals?.errors || []), ...(output?.errors || [])]; const source = `${esc(state.project.name)} · ${esc(title)}

${esc(state.project.name)}

${esc(title)}

${detail ? `

${esc(detail)}

` : ""}${totals ? `
순공사비 ${number(totals.direct?.TC)}원 · 도급액 ${number(totals.total)}원
` : ""} - ${paginate(root, settings)}${errors.length ? `

계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}

` : ""}`; + ${paginate(root, settings)}${output && settings.notes ? `
${esc(settings.notes)}
` : ""}${errors.length ? `

계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}

` : ""}`; EstApp.byId("print-frame").srcdoc = source; } function paginate(root, settings) { @@ -279,7 +279,7 @@ const EstReport = (() => { name: state.project.name, columns, rows, - detail: output.detail || "", + detail: [output.detail, setting().notes].filter(Boolean).join("\n"), errors: output.errors || [], }), }); From f915e0c33b19c528199d4bc1ca127a8e1fc3f9de Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 11:31:12 +0900 Subject: [PATCH 54/76] feat(est): inspect standard price usage --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_documents.py | 8 +++- resources/tester/test_est_standard.py | 24 ++++++++++ tools/est-test/PLAN.md | 3 ++ tools/est-test/README.md | 2 + tools/est-test/index.html | 2 + tools/est-test/native-actions.js | 1 + tools/est-test/native-app.js | 1 + tools/est-test/native-extra.js | 4 ++ tools/est-test/native-forms.js | 18 +++++++ tools/est-test/native-model.js | 1 + tools/est-test/native-report.js | 1 + tools/est-test/native-standard.js | 65 ++++++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 14 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_est_standard.py create mode 100644 tools/est-test/native-standard.js diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 5165e54c7..1cf5887aa 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -20,6 +20,7 @@ for (const file of [ "native-schedule.js", "native-issues.js", "native-attachments.js", + "native-standard.js", "native-model.js", "native-c3r.js", "native-exchange.js", diff --git a/resources/tester/test_est_documents.py b/resources/tester/test_est_documents.py index c011b954d..88f54bd4d 100644 --- a/resources/tester/test_est_documents.py +++ b/resources/tester/test_est_documents.py @@ -5,13 +5,17 @@ def test_custom_print_form_documents_are_saved_and_unknown_keys_rejected(): result = run( "math", """(()=>{ - const p=EstNative.empty();p.documents={'annual-items':'stage note','progress-costs':'progress note'}; + const p=EstNative.empty();p.documents={'annual-items':'stage note','progress-costs':'progress note','standard-usage':'standard note'}; const valid=EstModel.validate(p).documents;let rejected=false; const q=EstNative.empty();q.documents={'invalid-key':'bad'};try{EstModel.validate(q)}catch{rejected=true} return {valid,rejected}; })()""", ) - assert result["valid"] == {"annual-items": "stage note", "progress-costs": "progress note"} + assert result["valid"] == { + "annual-items": "stage note", + "progress-costs": "progress note", + "standard-usage": "standard note", + } assert result["rejected"] diff --git a/resources/tester/test_est_standard.py b/resources/tester/test_est_standard.py new file mode 100644 index 000000000..d0529bfe1 --- /dev/null +++ b/resources/tester/test_est_standard.py @@ -0,0 +1,24 @@ +from test_est_rea_parity import run + + +def test_standard_usage_reports_applied_and_source_prices_without_mutation(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.SilJukDat=[{Code:'A',Name:'source',ValueIndex:4,InputTC:120}, + {Code:'B',ValueIndex:4,InputTC:50},{Code:'B',ValueIndex:4,InputTC:60}]; + p.tables.EstMainDat=[{Code:'A',Name:'work',LinkIndex:21,GN:2,TU:100,TC:200}, + {Code:'B',Name:'duplicate',LinkIndex:21,GN:1,TU:45,TC:45}, + {Code:'C',Name:'missing',LinkIndex:21,GN:1,TU:20,TC:20}, + {Code:'A',Name:'material',LinkIndex:5,GN:1,TU:10}]; + const before=JSON.stringify(p), rows=EstStandard.usage(p), print=EstForms.data('standard-usage',p); + return {rows,print,same:before===JSON.stringify(p)}; + })()""", + ) + assert result["same"] + assert [row["status"] for row in result["rows"]] == ["연결됨", "코드 중복", "연결 없음"] + assert result["rows"][0]["sourcePrice"] == 120 + assert result["rows"][0]["appliedPrice"] == 100 + assert result["rows"][0]["difference"] == -20 + assert result["rows"][1]["sourcePrice"] is None + assert result["print"]["rows"] == result["rows"] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 186a88a9e..71c6a50b8 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,7 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] 표준시장단가 사용 내역·기초 단가 비교 화면·출력 구현 → 연결·중복·누락·행 이동 시험. + - 자체 검증: 연결 단가·중복·누락·문서 시험 12개 통과, Edge에서 화면·인쇄·해당 내역 이동 확인. + - [x] 추가 인쇄 서식의 본문 저장·미리보기·Excel 연결 보완 → 서식 문서 검증·재열기 시험. - 자체 검증: 문서·Excel 시험 24개 통과, Edge에서 본문 저장·재열기·인쇄·Excel 요약 확인. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 9a0cd43e3..8ad2ab1a4 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -6,6 +6,8 @@ 작업모드의 `자료 오류 검색`은 코드 중복, 연결 자료 누락, 연결된 품명·규격 차이와 계산 오류를 표시합니다. 항목의 `행 열기`로 해당 자료 위치로 이동할 수 있습니다. 품명·규격 차이는 사용자가 확인할 항목으로 표시합니다. +작업모드의 `표준시장단가 사용 내역`은 표준시장단가에 연결된 내역별 적용 단가와 현재 기초 단가, 차이와 연결 상태를 보여 줍니다. 원래 내역으로 이동하거나 해당 표를 인쇄·Excel로 출력할 수 있습니다. + 행을 선택한 뒤 편집 메뉴의 `선택 행 자릿수 설정`에서 처리방식과 소수 자릿수를 일괄 적용할 수 있습니다. 기존 계산 설정은 실행 취소로 되돌릴 수 있습니다. 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 9de76a575..24d9adcdb 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -21,6 +21,7 @@ + @@ -114,6 +115,7 @@ 작업모드(M)