From c05916369ce670616293c92f9ec048923a3b11e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:40:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC(=EC=A4=91=EB=B3=B5=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=C2=B7=EB=8B=A8=EA=B0=80=EC=A0=95=EC=9D=98=20=EC=97=91=EC=85=80?= =?UTF-8?q?=C2=B7=ED=95=A0=EC=A6=9D/=EB=82=99=EC=B0=B0/=EB=B3=B5=EC=9B=90?= =?UTF-8?q?=C2=B7=EB=B0=94=EA=BE=B8=EA=B8=B0=C2=B7SUM)=EC=99=80=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=84=A0=ED=83=9D=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=83=89=EC=9D=B8=20=EB=B2=88=ED=98=B8=20=EC=9D=BD=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_price_tools.py | 61 +++++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 8 + tools/est-test/native-actions-more.js | 6 +- tools/est-test/native-data-update.js | 2 +- tools/est-test/native-price-tools.js | 211 +++++++++++++++++++++++ tools/est-test/native-rows.js | 11 +- tools/est-test/server.cjs | 1 + 8 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_est_price_tools.py create mode 100644 tools/est-test/native-price-tools.js diff --git a/resources/tester/test_est_price_tools.py b/resources/tester/test_est_price_tools.py new file mode 100644 index 000000000..45cfbca5b --- /dev/null +++ b/resources/tester/test_est_price_tools.py @@ -0,0 +1,61 @@ +"""EST 테스트 앱 단가 도구(원본 중복 항목 정리 · 단가정의 엑셀 · 할증/낙찰/복원 · 항목선택계산 SUM 줄).""" + +import json +import subprocess +from pathlib import Path + +from test_est_rea_parity import run + +ROOT = Path(__file__).resolve().parents[2] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone }}); + vm.runInContext(fs.readFileSync('tools/est-test/native-price-tools.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_price_tools_rules(): + out = run_js( + r"""(() => { + const P = EstPriceTools; + const same = P.samecheck([{ Name: '철 근', Size: 'D10', Unite: 'ton', TC: 5 }, { Name: '철근', Size: 'D10', Unite: 'ton', TC: 9 }, + { Name: '철근|', Size: 'D10', Unite: 'ton', TC: 9 }, { Name: '모래', TC: 1 }]); + const g = P.gungu([{ Code: 'C1', Index: 3, Name: '포장', Size: 't=5', Unite: 'm2', EXPLEAN: '포함: 재료\n제외: 운반' }, { Code: 'C2', Name: '빈' }]); + const rows = [{ Name: 'a', TC: 100 }, { Name: 'b', TC: 200, OTC: 210 }, { Name: '', TC: 0 }]; + const n = P.setRate(rows, [], 'NachalRatio', 88); + const one = [{ Name: 'x', TC: 1 }, { Name: 'y', TC: 2 }]; P.setRate(one, [1], 'Ratio', 5); + const r = { Name: '', Size: '', LinkIndex: 0 }; + P.sumRow('HeadMainDat', r, { name: '기구손료', size: '', rate: 2, base: 2, target: 3, picked: [1, 3] }); + const errs = []; + for (const [row, o] of [[{ Index: 1 }, {}], [{}, { base: 0, target: 2, picked: [1] }], [{}, { base: 1, target: 1, picked: [] }], + [{}, { base: 1, target: 1, picked: [1, 2, 3, 4, 5, 6] }]]) + try { P.sumRow('HeadMainDat', row, { name: '', size: '', rate: 1, ...o }); } catch (e) { errs.push(e.message.slice(0, 8)); } + return { same: [same.removed, same.rows.map((x) => [x.Name, x.TC])], g, n, rows: rows.map((x) => [x.NachalRatio ?? null, x.OTC ?? null]), + one: one.map((x) => x.Ratio ?? null), r: [r.Code, r.LinkIndex, r.GN, r.Biul, r.Unite], errs }; + })()""" + ) + assert out["same"] == [2, [["철근|", 9], ["모래", 1]]] + assert out["g"] == [["C1", "3. 포장 (t=5) [m2]"], ["", "포함: 재료"], ["", "제외: 운반"], ["", ""]] + assert out["n"] == 3 + assert out["rows"] == [[88, 100], [88, 210], [None, None]] + assert out["one"] == [5, 5] + assert out["r"] == ["A0103", 62, 2, 0.02, "%"] + assert out["errs"] == ["현재 라인에는 ", "비목별항목은 비", "더할항목의 색인", "더할항목의 색인"] + + +def test_sum_row_code_numbers_select_groups(): + result = run( + "math", + """(()=>{ + const rows=[{Index:1,Code:'H'},{Code:'a',GN:1,NC:100,NU:100,SKind:1},{Code:'b',GN:1,NC:50,NU:50,SKind:3}, + {Code:'c',GN:1,NC:7,NU:7,SKind:2},{Code:'A0103',LinkIndex:62,GN:10,OCheck:2,SCheck:2}]; + const out=EstRows.head(rows,{HeadDigits:0}); + return out.rows?out.rows[4].NC:out[4].NC; + })()""", + ) + assert result == 15 diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 673b9f8c7..7dd2cba17 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음). + - 검증완료: `test_est_price_tools.py` 2개(중복 2개 지움·단가정의 줄·율 쓰기 범위·SUM Code A0103·거부 4종, 엔진 A0103 → 색인 1·3 합 150×10% = 15) 및 REA 대조 39개 통과. ORCA 브라우저에서 보통인부 낙찰 90% 172,068→154,861, 복원 →172,068, 바꾸기(Main) → 2026 하반기 값, 샘플 복구. - [x] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT 을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤. - 검증완료: `test_est_quantity_table.py` 2개(표 읽기·문단 G 표지 무시·아래 합 SUM(B2:B3)=1,002.5·수량 값·병합/분리·설치 표 5개) 및 수량·ESTX 내보내기 회귀 49개 통과. ORCA 브라우저에서 보차도경계석 보수 표 35칸, 수량 1.0→2.5 항목 수량 반영, 아래 합 SUM(D2:D6)=3.923, 수량 불러오기 75개·문단 가운데·산출근거 양식·쪽 삽입 45줄·삭제, 공종명칭 「보차도경계석 보수(L=1m)」, 품명지정 ⟦품명⟧, 수량목록 복사 → 내역 붙여넣기 1·0.19·연결 id:36, 샘플 복구. - 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 0d0212ecb..f7a997cad 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -17,6 +17,7 @@ + @@ -309,6 +310,13 @@ > { - const modules = () => [typeof EstQuantityList !== "undefined" && EstQuantityList].filter(Boolean); + const modules = () => + [ + typeof EstQuantityList !== "undefined" && EstQuantityList, + typeof EstPriceTools !== "undefined" && EstPriceTools, + ].filter(Boolean); function handle(name, state, node) { for (const module of modules()) if (module.handle(name, state, node)) return true; return false; diff --git a/tools/est-test/native-data-update.js b/tools/est-test/native-data-update.js index 569eefa32..736d047e9 100644 --- a/tools/est-test/native-data-update.js +++ b/tools/est-test/native-data-update.js @@ -247,5 +247,5 @@ const EstDataUpdate = (() => { else return false; return true; } - return { apply, isFuel, handle }; + return { apply, isFuel, table, handle }; })(); diff --git a/tools/est-test/native-price-tools.js b/tools/est-test/native-price-tools.js new file mode 100644 index 000000000..c045102c5 --- /dev/null +++ b/tools/est-test/native-price-tools.js @@ -0,0 +1,211 @@ +// 단가 목록 도구(spec_pb2): 중복 항목 정리(TfrmStuffc popupSamecheck 0xf61d94), 단가정의 엑셀변환(TfrmSiljuk popupGunguToExcel), +// 할증·낙찰·복원(TfrmSetDiget 모드 51·52, btnRestor 0x110919c), 바꾸기(barbtnChange → TfrmChangeValue: Main Data 로 이 표만 갱신), +// SUM(barbtnSum → 항목선택계산 LinkIndex 62 줄, TfrmSetGabBiIndex SUM 모드). +const EstPriceTools = (() => { + const num = (v) => Number(v) || 0; + const key = (s) => String(s ?? "").replace(/[ |]/g, ""); + const TABLE_KIND = { StuffcDat: 5, LaborcDat: 6, SilJukDat: 21, EstiMateDat: 101 }; + + // 같은 품명·규격·단위(빈칸·| 무시) 묶음에서 TC 가장 큰 행 하나만(같으면 뒤 행) 남김. 묻지 않음, 다시 셈 없음. + function samecheck(rows) { + const keep = new Map(); + rows.forEach((row, i) => { + const k = `${key(row.Name)}\u0000${key(row.Size)}\u0000${key(row.Unite)}`, + prev = keep.get(k); + if (prev == null || !(num(rows[prev].TC) > num(row.TC))) keep.set(k, i); + }); + const kept = new Set(keep.values()); + const removed = rows.length - kept.size; + return { rows: rows.filter((_, i) => kept.has(i)), removed }; + } + + // 표준시장단가 EXPLEAN 이 있는 행만 「코드 / 번호. 품명 (규격) [단위]」 + 설명 줄 + function gungu(rows) { + const out = []; + for (const r of rows) { + const text = String(r.EXPLEAN ?? "").trim(); + if (!text) continue; + out.push([ + String(r.Code ?? ""), + `${r.Index ?? ""}. ${r.Name ?? ""} (${r.Size ?? ""}) [${r.Unite ?? ""}]`, + ]); + for (const line of text.split(/\r?\n/)) out.push(["", line]); + out.push(["", ""]); + } + return out; + } + + // 할증(Ratio)·낙찰(NachalRatio) 율 쓰기. v = 100 이면 원래값(그 율 칸 100, 자리 해제), 복원 = 0. + function setRate(rows, indices, field, v) { + const list = indices.length >= 2 ? indices : rows.map((_, i) => i); + for (const i of list) { + const row = rows[i]; + if (!num(row.TC) && !String(row.Name ?? "").trim()) continue; + if (!num(row.OTC)) row.OTC = row.TC; + for (const f of [field].flat()) row[f] = v; + if (v === 100) Object.assign(row, { Diget: 0, DigetIndex: 0 }); + } + return list.length; + } + + // 항목선택계산(62) 줄 + function sumRow(view, row, o) { + const banned = + view === "EstMainDat" + ? [ + 5, 6, 21, 51, 52, 53, 54, 56, 57, 58, 59, 61, 101, 103, 104, 105, 211, 212, 213, 214, + 215, 217, 218, + ] + : [5, 6, 21, 51, 52, 53, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218]; + if ( + (view === "EstMainDat" && num(row.Level) > 0) || + (view === "HeadMainDat" && num(row.Index) > 0) || + (banned.includes(num(row.LinkIndex)) && + (String(row.Name ?? "").trim() || String(row.Size ?? "").trim())) + ) + throw Error("현재 라인에는 합계(SUM)를 할수가 없습니다."); + if ((o.base === 0) !== (o.target === 0)) throw Error("비목별항목은 비목별적용만 가능합니다."); + if (!o.picked.length) throw Error("더할항목의 색인들을 선택하세요"); + if (o.picked.length > 5) throw Error("더할항목의 색인들은 6개이상 선택할수없습니다"); + Object.assign(row, { + Name: o.name, + Size: o.size, + LinkIndex: 62, + Code: `A${o.picked.map((n) => String(n).padStart(2, "0")).join("")}`, + GN: o.rate, + Biul: o.rate / 100, + Unite: "%", + OCheck: o.base, + SCheck: o.target, + }); + } + function nextSKind(rows, at, view) { + let start = at, + end = at; + const head = (r) => (view === "EstMainDat" ? num(r.Level) > 0 : num(r.Index) > 0); + while (start > 0 && !head(rows[start - 1])) start--; + while (end + 1 < rows.length && !head(rows[end + 1])) end++; + return Math.max(0, ...rows.slice(start, end + 1).map((r) => num(r.SKind))) + 1; + } + + async function run(name, state) { + const view = state.view, + rows = state.project.tables[view], + picked = [...state.selection].filter((i) => rows?.[i]).sort((a, b) => a - b); + if (name === "price-samecheck") { + if (view !== "StuffcDat") throw Error("자재단가 화면에서 쓰세요."); + let removed = 0; + EstApp.change((project) => { + const out = samecheck(project.tables.StuffcDat); + project.tables.StuffcDat = out.rows; + removed = out.removed; + }); + state.notice = `중복 항목 ${removed}개를 지웠습니다.`; + } else if (name === "price-gungu-excel") { + const list = gungu(state.project.tables.SilJukDat || []); + if (!list.length) throw Error("단가정의(설명)가 있는 표준시장단가가 없습니다."); + const response = await fetch("/api/export-report-xlsx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "단가정의", + columns: ["코드", "단가정의"], + rows: list, + errors: [], + }), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name || "공사"}_단가정의.xlsx`, + await response.blob(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + } else if (name === "price-ratio" || name === "price-nachal" || name === "price-restore") { + if (!TABLE_KIND[view]) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요."); + // 복원(원본은 할증·낙찰 창 안 단추): 두 율 모두 0 + const field = + name === "price-nachal" + ? "NachalRatio" + : name === "price-ratio" + ? "Ratio" + : ["Ratio", "NachalRatio"]; + let v = 0; + if (name !== "price-restore") { + const input = prompt( + name === "price-nachal" ? "낙찰율을 입력하세요(%)" : "할증율을 입력하세요(%)", + "", + ); + if (input == null) return; + v = Number(input); + if (!Number.isFinite(v)) throw Error("율은 수치로 입력하세요."); + } + if ( + !confirm( + v === 100 || v === 0 ? "원래값으로 복원하시겠습니까?" : "설정값을 적용하시겠습니까?", + ) + ) + return; + let n = 0; + EstApp.change((project) => (n = setRate(project.tables[view], picked, field, v))); + state.notice = `${n}개 행에 적용했습니다.`; + } else if (name === "price-change") { + const kind = TABLE_KIND[view]; + if (!kind) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요."); + if ( + !confirm( + "선택한 Main데이타의 단가로 바꾸시겠습니까?\n바꾸기후 전체재계산을 해야 금액이 바뀝니다.", + ) + ) + return; + const source = await EstApp.api("/api/main-data"); + let missing = 0; + EstApp.change((project) => { + missing = EstDataUpdate.table(project.tables[view], source.tables[view] || [], kind); + }); + state.notice = missing + ? `${missing} 개의 찾지못한 데이타가 있습니다. 확인란에 체크된 데이타를 검토하세요.` + : "Main 데이타의 단가로 바꿨습니다."; + } else if (name === "price-sum") { + if (!["EstMainDat", "HeadMainDat"].includes(view) || !picked.length) + throw Error("내역서·일위대가에서 SUM 줄로 바꿀 행을 고르세요."); + const row = rows[picked[0]]; + const letters = prompt("더할 색인(A~Z, 최대 5개, 예 AC)", "")?.toUpperCase(); + if (letters == null) return; + const rate = Number(prompt("적용율(%)", "100")); + const o = { + name: prompt("명칭", row.Name || "잡재료비") ?? "", + size: row.Size ?? "", + rate: Number.isFinite(rate) ? rate : 0, + base: num(row.OCheck), + target: num(row.SCheck), + picked: [...new Set(letters.replace(/[^A-Z]/g, ""))].map((c) => c.charCodeAt(0) - 64), + }; + sumRow(view, structuredClone(row), o); + EstApp.change((project) => { + const list = project.tables[view]; + sumRow(view, list[picked[0]], o); + list[picked[0]].SKind = nextSKind(list, picked[0], view); + }); + } else return false; + EstApp.render(); + return true; + } + const names = new Set([ + "price-samecheck", + "price-gungu-excel", + "price-ratio", + "price-nachal", + "price-restore", + "price-change", + "price-sum", + ]); + function handle(name, state) { + if (!names.has(name)) return false; + run(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { samecheck, gungu, setRate, sumRow, handle }; +})(); diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js index 7a4d9f387..8520c15f3 100644 --- a/tools/est-test/native-rows.js +++ b/tools/est-test/native-rows.js @@ -13,13 +13,18 @@ const EstRows = (() => { } function percent(row, preceding, digits = 0) { if ([1, 3].includes(num(row.CalcIndex))) return row; - // f1a8c4 matches Chr(64 + source.SKind) against the percentage row's Code. - const selectedGroups = String(row.Code || "").toUpperCase(); + // 항목선택계산(62) Code = 'A' + 두 자리 색인 번호들(원본 SUM effc54, 예 A0103 = A·C); 그 밖 꼴은 글자 Chr(64+SKind) 로. + const selectedGroups = String(row.Code || "").toUpperCase(), + numbered = /^A((?:\d\d)+)$/.exec(selectedGroups), + groups = numbered ? new Set(numbered[1].match(/\d\d/g).map(Number)) : null; const eligible = num(row.LinkIndex) === 62 ? preceding.filter( (r) => - num(r.SKind) > 0 && selectedGroups.includes(String.fromCharCode(64 + num(r.SKind))), + num(r.SKind) > 0 && + (groups + ? groups.has(num(r.SKind)) + : selectedGroups.includes(String.fromCharCode(64 + num(r.SKind)))), ) : preceding; const base = baseKind(row), diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index e5be16fe4..6a53aa440 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -97,6 +97,7 @@ const allowed = new Set([ "native-data-update.js", "native-quantity-table.js", "native-quantity-list.js", + "native-price-tools.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js",