From f7b68be72af7b84e1bcebc62cc0403ae09624f78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 19:35:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=ED=92=88=EB=AA=85=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=EC=9E=90=EC=9E=AC=C2=B7=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 처리 근거대로 커서 자리 품명 나누기, 삭제할 칸, 빈 단위만 채움 - 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 연결, 중복·호표 거부 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_name_reg.py | 102 +++++++++++++++++ tools/est-test/PLAN.md | 2 + tools/est-test/README.md | 2 + tools/est-test/index.html | 4 + tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-name-reg.js | 131 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 7 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_name_reg.py create mode 100644 tools/est-test/native-name-reg.js diff --git a/resources/tester/test_est_name_reg.py b/resources/tester/test_est_name_reg.py new file mode 100644 index 000000000..ba807b62b --- /dev/null +++ b/resources/tester/test_est_name_reg.py @@ -0,0 +1,102 @@ +"""EST 테스트 앱 품명정리와 자재·견적단가 등록(원본 TfrmNameDevide·popMainStuffcReg·popMainEstimateReg).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-name-reg.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + 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_divide_splits_at_position_and_syncs_title(): + result = run_js( + """(() => { + const p = { tables: { + HeadMainDat: [{ Index: 1, Code: 'U1', Name: '거푸집 설치 합판', Size: '3회', Unite: '' }, { Code: 'L1', LinkIndex: 6 }, + { Index: 2, Code: 'U2', Name: '거푸집 해체 강재', Size: '', Unite: '㎡' }], + HeadTitleDat: [{ Code: 'U1', Name: '거푸집 설치 합판' }, { Code: 'U2', Name: '거푸집 해체 강재' }] } }; + const n = EstNameReg.divide(p, [0, 1, 2], 4, 0, '㎡'); + const c = { tables: { HeadMainDat: [{ Index: 1, Code: 'X', Name: 'AB-CD', Size: '' }] } }; + EstNameReg.divide(c, [0], 2, 1, ''); + let bad = ''; try { EstNameReg.divide(p, [1], 4, 0, ''); } catch (e) { bad = e.message.slice(0, 6); } + const r = p.tables.HeadMainDat; + return { n, a: [r[0].Name, r[0].Size, r[0].Unite], b: [r[2].Name, r[2].Size, r[2].Unite], + title: p.tables.HeadTitleDat[0].Size, bad, cut: c.tables.HeadMainDat[0].Size }; + })()""" + ) + assert result["n"] == 2 + assert result["a"] == ["거푸집 ", "설치 합판3회", "㎡"] + assert result["b"] == ["거푸집 ", "해체 강재", "㎡"] + assert result["title"] == "설치 합판3회" + assert result["bad"] == "품명을 정리" + assert result["cut"] == "CD" + + +def test_register_stuff_and_estimate_link_row_and_keep_price(): + result = run_js( + r"""(() => { + const calc = (x) => EstNative.calculate(EstModel.validate(x)); + const p = calc(structuredClone(source)); + const i = p.tables.EstMainDat.findIndex((r) => Number(r.GU) > 0 && !Number(r.Level) && Number(r.LinkIndex) === 5); + p.tables.EstMainDat[i].Name = '시험 자재 등록품'; + const before = p.tables.EstMainDat[i].TC; + const code = EstNameReg.register(p, 'EstMainDat', i, 'stuff'); + const rec = p.tables.StuffcDat.find((r) => r.Code === code); + const j = p.tables.EstMainDat.findIndex((r, k) => k !== i && Number(r.JU) + Number(r.NU) > 0 && !Number(r.Level) && r.Name); + p.tables.EstMainDat[j].Name = '시험 견적 등록품'; + const w = EstNameReg.register(p, 'EstMainDat', j, 'estimate'); + const est = p.tables.EstiMateDat.find((r) => r.Code === w); + const after = calc(p); + const errors = []; + for (const fn of [() => EstNameReg.register(p, 'EstMainDat', i, 'stuff'), + () => EstNameReg.register(p, 'HeadMainDat', p.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0), 'stuff')]) + try { fn(); } catch (e) { errors.push(e.message.slice(0, 6)); } + return { code: /^M\d{10}$/.test(code), price: rec.Cost1 === Number(p.tables.EstMainDat[i].GU) && rec.TC === rec.Cost1, + link: [p.tables.EstMainDat[i].LinkIndex, p.tables.EstMainDat[i].Code === code], + same: after.tables.EstMainDat[i].TC === before, w: /^W\d{10}$/.test(w), + est: est.TC === est.NC + est.GC + est.JC && est.ONC === est.NC, estLink: p.tables.EstMainDat[j].LinkIndex, + errors, calcErrors: after.calculation.errors.length }; + })()""" + ) + assert result == { + "code": True, + "price": True, + "link": [5, True], + "same": True, + "w": True, + "est": True, + "estLink": 101, + "errors": ["같은 명칭의", "호표는 자재"], + "calcErrors": 0, + } diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 5f59b317d..89091a08d 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -462,6 +462,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 당초분·정산분 색상 옵션. - [x] 무관자료 삭제(원본 mnuNotLinkDelete·DelSort → f039f0, MulDelete → 1116db0 — 디스어셈블 근거) — 내역서에서 LinkIndex 5·6·21·101·103·104·105·Code(설계변경 Code_ 포함)로 하위까지 따라가 쓰는 자료만 남김, 묶음 머리 앞 빈 줄 정리, 내역서순 재배열(코드 유지), 내역 미연결 수량산출서 삭제. 원본의 한 번 훑기 누락(기계경비 안에서만 쓰인 일위대가 하위)은 웹에서 끝까지 따라가 막음. LinkIndex 211–218 코드 비우기는 뜻 미확인으로 하지 않음. - 검증완료: `test_est_cleanup.py` 3개(안 쓰는 자재·일위 삭제·합계 불변·오류 0, 내역서순 순서·코드 유지, 빈 줄·수량산출서) 통과. 설치 main·sample·산림토목 ESTX에서 정리 전후 도급액·오류 수 같음. ORCA 브라우저에서 내역서순 정리 → 자재 30 → 17·도급액 79,603,000 유지·되살리기 복원. + - [x] 품명정리·자재/견적단가 등록(원본 ActionNameDeivde 0xf5a2b4·TfrmNameDevide 0xf595c0/0xf59640, popMainStuffcReg/EstimateReg → c_f00264/c_f00670 — 디스어셈블 근거) — 커서 자리 나누기·삭제할 칸·빈 단위만 채움·호표 목록 동기화, 등록 시 같은 품명·규격 거부·호표 거부·M/W+PkID 10자리 코드·자재 Cost1=TC=재료단가·견적 NC/GC/JC·원래 행 연결. 원본은 품명정리를 2개 이상 선택 때만 돌리나 웹은 1개도 허용. + - 검증완료: `test_est_name_reg.py` 2개(나누기·삭제할 칸·제목 동기화·호표 아님 거부, 자재/견적 등록 코드·가격·연결·금액 불변·중복/호표 거부·계산 오류 0) 통과. ORCA 브라우저에서 내역 행 자재 등록 → M0000000038·연결 5, 일위대가 「레미콘타설(…)」 3자리 나누기 → 품명 「레미콘」·규격 「타설(인력운반타설)철근구조물」, 샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 7e7eb7693..52d612101 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다. + `편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다. `원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 378f79f3a..8282dd3c4 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -47,6 +47,7 @@ + @@ -103,6 +104,9 @@ >
+ diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index de8ed3fc8..4565a8894 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -608,7 +608,8 @@ const EstBusinessActions = (() => { (typeof EstBid !== "undefined" && EstBid.handle(name, state)) || (typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) || (typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) || - (typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) + (typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) || + (typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-name-reg.js b/tools/est-test/native-name-reg.js new file mode 100644 index 000000000..8e0cb7faa --- /dev/null +++ b/tools/est-test/native-name-reg.js @@ -0,0 +1,131 @@ +// 원본 품명정리(TfrmHead ActionNameDeivde 0xf5a2b4·TfrmNameDevide 적용 0xf595c0·규격으로지정 0xf59640)와 +// 자재단가·견적단가 등록(popMainStuffcReg·popMainEstimateReg → c_f00264·c_f00670). +// 품명정리: 기존명칭에서 고른 자리 앞 = 품명(다듬지 않음), 뒤에서 「삭제할 칸」만큼 떼고 기존 규격을 붙여 다듬은 값 = 규격, +// 단위는 비어 있을 때만 채움. 고른 일위대가 호표마다 같은 자리로 나누고 HeadTitleDat 의 같은 코드도 맞춘다. +// 등록: 같은 품명·규격(빈칸·| 무시) 있으면 거부, 코드 = 머리글자(M·W) + PkID 10자리(쓰인 코드면 하나씩 올림), +// 자재 Cost1 = TC = 행 재료단가 · 견적 NC/GC/JC = 행 노무/재료/경비단가, 원래 행은 새 코드·연결(5·101)로 바꾼다. +const EstNameReg = (() => { + const num = (value) => Number(value) || 0; + const strip = (value) => String(value ?? "").replace(/[\s|]/g, ""); + + function divide(project, indices, position, cut, unit) { + if (!Number.isInteger(position) || position < 0) throw Error("나눌 자리를 고르세요."); + if (!Number.isInteger(cut) || cut < 0) throw Error("삭제할 칸은 0 이상의 정수입니다."); + const rows = project.tables.HeadMainDat, + titles = project.tables.HeadTitleDat || []; + const picked = indices.filter((i) => num(rows[i]?.Index) > 0); + if (!picked.length) throw Error("품명을 정리할 일위대가 호표(제목 줄)를 고르세요."); + for (const i of picked) { + const row = rows[i], + name = String(row.Name ?? ""); + const rest = name.slice(position).slice(cut); + row.Name = name.slice(0, position); + row.Size = `${rest}${row.Size ?? ""}`.trim(); + if (!strip(row.Unite) && unit) row.Unite = unit; + const title = titles.find((t) => t.Code === row.Code); + if (title) Object.assign(title, { Name: row.Name, Size: row.Size, Unite: row.Unite }); + } + return picked.length; + } + + const targets = { + stuff: { table: "StuffcDat", kind: 5, letter: "M", label: "자재단가" }, + estimate: { table: "EstiMateDat", kind: 101, letter: "W", label: "견적단가" }, + }; + + function register(project, view, index, which) { + const target = targets[which], + row = project.tables[view]?.[index]; + if (!target || !row) throw Error("등록할 행을 고르세요."); + if (view === "HeadMainDat" && num(row.Index) > 0) + throw Error(`호표는 ${target.label}에 등록할수 없습니다.`); + if (!String(row.Name ?? "").trim()) throw Error("명칭을 입력하세요."); + const rows = (project.tables[target.table] ||= []); + const same = rows.find( + (r) => strip(r.Name) === strip(row.Name) && strip(r.Size) === strip(row.Size), + ); + if (same) + throw Error(`같은 명칭의 데이타가 있습니다 . ${target.label} (${num(same.Index)} 번)`); + let pk = Math.max(0, ...rows.map((r) => num(r.PkID))) + 1; + const used = new Set(rows.map((r) => r.Code)); + let code = `${target.letter}${String(pk).padStart(10, "0").slice(-10)}`; + while (used.has(code)) code = `${target.letter}${String(++pk).padStart(10, "0").slice(-10)}`; + const schema = rows[0] ? Object.keys(rows[0]) : ["Name", "Size", "Unite"]; + const record = {}; + for (const key of schema) + if (!["PkID", "Index", "Code"].includes(key) && Object.hasOwn(row, key)) + record[key] = row[key]; + Object.assign(record, { PkID: pk, Code: code, Index: rows.length + 1 }); + if (which === "stuff") + Object.assign(record, { Cost1: num(row.GU), TC: num(row.GU), OTC: num(row.GU) }); + else { + const [nc, gc, jc] = [num(row.NU), num(row.GU), num(row.JU)]; + Object.assign(record, { + NC: nc, + GC: gc, + JC: jc, + TC: nc + gc + jc, + ONC: nc, + OGC: gc, + OJC: jc, + OTC: nc + gc + jc, + }); + } + rows.push(record); + row.Code = code; + row.LinkIndex = target.kind; + return code; + } + + function divideDialog(state) { + if (state.view !== "HeadMainDat") throw Error("일위대가 화면에서 품명을 정리하세요."); + const rows = state.project.tables.HeadMainDat, + picked = [...state.selection].sort((a, b) => a - b).filter((i) => num(rows[i]?.Index) > 0); + if (!picked.length) throw Error("품명을 정리할 일위대가 호표(제목 줄)를 고르세요."); + const { esc } = EstApp; + EstApp.showDialog( + "품명정리", + `

기존명칭에서 품명과 규격을 나눌 자리에 커서를 두고 「규격으로 지정」을 누르세요. 고른 ${picked.length}개 호표에 같은 자리로 적용합니다.

`, + ); + state.nameDivide = { picked, position: null }; + } + function field(name) { + return document.querySelector(`#dialog-content [name="${name}"]`); + } + + function handle(name, state) { + if (name === "name-divide") divideDialog(state); + else if (name === "name-divide-set") { + const all = field("nd-all"), + position = all.selectionStart ?? 0; + state.nameDivide.position = position; + field("nd-name").value = all.value.slice(0, position); + field("nd-size").value = all.value.slice(position); + } else if (name === "name-divide-apply") { + const info = state.nameDivide; + if (info?.position == null) throw Error("먼저 「규격으로 지정」으로 나눌 자리를 정하세요."); + const cut = Number(field("nd-cut").value), + unit = field("nd-unit").value.trim(); + EstApp.byId("dialog").close(); + let count; + EstApp.change((project) => (count = divide(project, info.picked, info.position, cut, unit))); + state.notice = `${count}개 호표의 품명을 정리했습니다.`; + } else if (name === "register-stuff" || name === "register-estimate") { + const which = name === "register-stuff" ? "stuff" : "estimate", + index = [...state.selection][0], + row = state.project.tables[state.view]?.[index]; + if (!["EstMainDat", "HeadMainDat"].includes(state.view) || state.selection.size !== 1 || !row) + throw Error("내역서·일위대가에서 등록할 행 하나를 고르세요."); + const label = targets[which].label; + register(structuredClone(state.project), state.view, index, which); + if (!confirm(`"${row.Name ?? ""}" ${row.Size ?? ""} 를 ${label}에 등록하시겠습니까?`)) + return true; + let code; + EstApp.change((project) => (code = register(project, state.view, index, which))); + state.notice = `${label}에 ${code}로 등록하고 이 행을 연결했습니다.`; + } else return false; + return true; + } + + return { divide, register, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index b0cd4d7c9..2d301643a 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -68,6 +68,7 @@ const allowed = new Set([ "native-bid.js", "native-forms-library.js", "native-cleanup.js", + "native-name-reg.js", "native-issues.js", "native-attachments.js", "native-standard.js",