From fb1ddb1be687814fb161de1563caec36cc7dd11e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 17:55:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=ED=98=84=EC=9E=AC=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=83=80=20=EA=B0=B1=EC=8B=A0(Main=20Data=C2=B7?= =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=8C=8C=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 0x10e12c8 규칙: 노임·자재·유류·표준·기계·환율, 품명/규격/단위 맞춤, 단가미갱신 표시 - 서버 /api/main-data 가 원본 설치 main.estx 를 원천으로 제공 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_data_update.py | 61 ++++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 4 +- tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-data-update.js | 251 ++++++++++++++++++++++ tools/est-test/server.cjs | 24 +++ 6 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_est_data_update.py create mode 100644 tools/est-test/native-data-update.js diff --git a/resources/tester/test_est_data_update.py b/resources/tester/test_est_data_update.py new file mode 100644 index 000000000..323ec0486 --- /dev/null +++ b/resources/tester/test_est_data_update.py @@ -0,0 +1,61 @@ +"""EST 테스트 앱 현재데이타 갱신(원본 TFRMDATAUPDATE → 0x10e12c8 — 노임·자재·유류·표준·기계·환율).""" + +import json +import subprocess +from pathlib import Path + +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-data-update.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_update_rules(): + out = run_js( + r"""(() => { + const U = EstDataUpdate; + const src = { tables: { + LaborcDat: [{ Code: 'X1', Name: '보 통 인 부', Size: '', TC: 200, Cost1: 200, Bimog: 2 }], + StuffcDat: [{ Code: 'S1', Name: '시멘트', Size: '40kg', Unite: '포당', TC: 6000, Cost1: 6000, Page1: '12', Ratio: 5 }, + { Code: 'S2', Name: '경유', TC: 1700, Cost1: 1700 }], + SilJukDat: [{ Code: 'C1', Name: '새 이름', Size: '', Unite: 'm', TC: 99, NC: 50 }], + MachGijunDat: [{ Index: 36, Name: '달러($)', TC: 1400, Bigo: '새' }, { Index: 1, Name: '계수', TC: 0.3 }], + MachTitleDat: [], MachMainDat: [] } }; + const mk = () => ({ tables: { + LaborcDat: [{ Code: 'L1', Name: '보통인부', TC: 100, Cost1: 100, Cost1_: 7 }, { Code: 'L2', Name: '없는직종', TC: 1 }], + StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40 kg', Unite: '[포]', TC: 5000, Cost1: 5000, Ratio: 1 }, + { Code: 'M2', Name: '경 유', TC: 1600, Cost1: 1600 }], + SilJukDat: [{ Code: 'C1', Name: '옛 이름', TC: 1 }], + MachGijunDat: [{ Index: 36, Name: '달러($)', TC: 1300, Bigo: '옛', TC_: 9 }], + MachTitleDat: [], MachMainDat: [] } }); + const a = mk(), ra = U.apply(a, src, { labor: 1, stuff: 1, siljuk: 1, mach: 1, fuel: 1, rate: 1 }); + const b = mk(); U.apply(b, src, { stuff: 1 }); + const c = mk(); U.apply(c, src, { mach: 1 }); + const l = a.tables.LaborcDat; + return { + labor: [l[0].TC, l[0].Code, l[0].Cost1_, l[0].Checked, l[0].Bimog, l[1].Checked], + stuff: [a.tables.StuffcDat[0].TC, a.tables.StuffcDat[0].Page1, a.tables.StuffcDat[0].Code, a.tables.StuffcDat[1].TC], + stuffOnly: [b.tables.StuffcDat[0].TC, b.tables.StuffcDat[0].Ratio, b.tables.StuffcDat[1].TC], + siljuk: [a.tables.SilJukDat[0].Code, a.tables.SilJukDat[0].Name, a.tables.SilJukDat[0].NC], + gijun: a.tables.MachGijunDat.map((r) => [r.Index, r.TC, r.TC_ ?? null]), + rateKept: c.tables.MachGijunDat.find((r) => r.Index === 36).TC, + notices: ra.notices, + fuel: [U.isFuel({ Name: '휘 발 유' }), U.isFuel({ Name: '선박용경유' })], + }; + })()""" + ) + assert out["labor"] == [200, "L1", 7, False, 2, True] + assert out["stuff"] == [6000, "12", "M1", 1700] + assert out["stuffOnly"] == [6000, 1, 1600] + assert out["siljuk"] == ["C1", "새 이름", 50] + assert out["gijun"] == [[36, 1400, 9], [1, 0.3, None]] + assert out["rateKept"] == 1300 + assert out["notices"][0].startswith("노임단가에서 1 개의 찾지못한 데이타가 있습니다.") + assert out["fuel"] == [True, False] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 36f9ae411..970b7c90a 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] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음). + - 검증완료: `test_est_data_update.py`(노임 갱신·당초 칸 유지·못 찾음 표시와 문구, 자재 규격·단위 정리 맞춤·쪽수, 자재만 갱신 때 유류·비율 제외, 표준 코드 맞춤·이름 교체, 손료 계수 표 교체·당초 칸 유지, 환율 끔 유지) 통과. 샘플을 원본 main.estx 로 갱신하면 보통인부 172,068→172,698(2026 하반기)·못 찾음 0(145ms). ORCA 브라우저에서 같은 결과, 샘플 복구. - [x] 견적서 엑셀변환(원본 TFRMEXCELCONVERTESTIPRN btnConvert) — 견적서 창 「엑셀변환」: 머리(제목·공사명·귀하·견적일자·합계금액 한글·공급자)와 레벨에 맞춘 내역 줄(품명·규격·단위·수량·재료/노무/경비/합계 단가·금액·비고)을 .xlsx 로 내려받기. 원본의 인쇄비율·셀 수식은 하지 않음(값만). - 검증완료: 견적서·바인딩 시험 통과. ORCA 브라우저에서 「EST REA 보관 샘플_견적서.xlsx」(7,490 바이트) 내려받기, 샘플 복구. - [x] 품셈 보기·품셈자료 붙여넣기(원본 barbtnBook → ProductBook.exe/TfrmBookViewer, popupPasteBook 10d6e28 — spec_book) — 서버 `book-store.cjs` 가 원본 설치 BookData(7권, DBISAM 암호 표: MD5 키 Blowfish-CBC, BLOB zlib RTF)를 읽음(Node 에 Blowfish 가 없어 π 자리로 상수 만들어 직접 구현, `EST_BOOK_DIR` 로 경로 변경). 화면: 품셈 고르기·목차 트리(펼치기·접기)·본문(표 칸 나눔)·검색(제목 / 제목+본문 / 키워드, 검색어 빈칸·'|' 지움)·본문 검색 강조·복사하기. 붙여넣기(복사한 뒤만): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리, 둘째 줄부터 아래 새 줄, 내역·일위·기계는 마지막 칸에 마지막 줄(수치 칸 검사). 사용자 결정: 원본처럼 잠긴 품셈 파일을 읽음. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index efcaa1d4b..43f5f8115 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -66,6 +66,7 @@ + @@ -151,7 +152,8 @@ > + >
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 1dbe6be79..bdcce47e8 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -637,7 +637,8 @@ const EstBusinessActions = (() => { (typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) || (typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) || (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) || - (typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) + (typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) || + (typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/native-data-update.js b/tools/est-test/native-data-update.js new file mode 100644 index 000000000..569eefa32 --- /dev/null +++ b/tools/est-test/native-data-update.js @@ -0,0 +1,251 @@ +// 원본 「현재데이타 갱신」(TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천 공사(Main Data = 원본 main.estx, +// 또는 고른 .estx)의 단가로 현재 공사를 갱신. 항목: 노임(LaborcDat) · 자재(StuffcDat, 유류 제외) · 유류(이름이 휘발유·경유·중유 등 8종) · +// 표준(SilJukDat, 코드 일치) · 기계(MachGijunDat 통째 교체 + MachTitleDat 이름·규격 일치 + 그 산출근거 줄 교체) · 환율(MachGijunDat 36·89·69·77·70·76). +// 맞추기: 코드(표준만) 또는 품명 / 품명+규격 / 품명+규격+단위(빈칸·|·(·) 제거, 단위는 첫 「당」·「[」·「]」 제거, 노임·기계는 단위 무시). +// 찾으면 단가 칸을 원천 값으로(코드·품명·규격·단위·Index·낙찰률·「_」 당초 칸 제외), 단가미갱신(Checked) 끔. 못 찾으면 Checked 켬. +// 행을 더하거나 지우지 않음(기계 산출근거 줄만 교체). 최신(웹접속)은 원본 회사 서버·유지보수 인증이 필요해 두지 않음. +// 원본 결함(환율 끔 때 파운드(70) 환율을 76 값으로 덮음)은 재현하지 않음. 자재 단가열 출처 재배치(OtherTables)는 같은 열 순서로 가정. +const EstDataUpdate = (() => { + const num = (v) => Number(v) || 0; + const norm = (s) => String(s ?? "").replace(/[\s|()]/g, ""); + const unit = (s) => norm(s).replace("당", "").replace("[", "").replace("]", ""); + const FUEL = ["휘발유", "경유", "중유", "가솔린", "디젤", "diesel", "gasoline", "heavyoil"]; + const isFuel = (row) => + FUEL.includes( + String(row.Name ?? "") + .replace(/[\s|]/g, "") + .toLowerCase(), + ); + const RATES = [36, 89, 69, 77, 70, 76]; + const SKIP = new Set([ + "Code", + "Name", + "Size", + "Unite", + "Index", + "NachalRatio", + "ErrorCheck", + "PkID", + "Checked", + ]); + + function finder(source, kind) { + return (row) => { + if (kind === 21) { + const code = norm(row.Code); + return code ? source.find((s) => norm(s.Code) === code) : null; + } + const size = norm(row.Size), + u = kind === 6 || kind === 105 ? "" : unit(row.Unite); + return source.find((s) => { + if (norm(s.Name) !== norm(row.Name)) return false; + const sSize = kind === 6 ? "" : norm(s.Size); + if (!size && !u) return true; + if (sSize !== size) return false; + if (!u) return true; + return (kind === 105 ? "" : unit(s.Unite)) === u; + }); + }; + } + function copy(target, src, skipRatio) { + for (const [key, value] of Object.entries(src)) { + if (SKIP.has(key) || key.endsWith("_") || typeof value === "boolean") continue; + if (skipRatio && key === "Ratio") continue; + if (typeof value === "string" && !/Page|^Bigo$|^EXPLEAN$|^Date$/.test(key)) continue; + target[key] = structuredClone(value); + } + } + // 표 하나 갱신 → 못 찾은 수 + function table(rows, source, kind, filter = () => true, skipRatio = false) { + const find = finder(source, kind); + let missing = 0; + for (const row of rows || []) { + if (!norm(row.Name) && !norm(row.Size)) continue; + if (!filter(row)) continue; + const src = find(row); + if (!src) { + missing++; + row.Checked = true; + continue; + } + row.Checked = false; + const code = row.Code; + copy(row, src, skipRatio); + if (kind === 21) + Object.assign(row, { Code: code, Name: src.Name, Size: src.Size, Unite: src.Unite }); + } + return missing; + } + + function groups(rows) { + const out = new Map(); + let current = null; + (rows || []).forEach((row, i) => { + if (num(row.Index) > 0) { + current = { head: i, body: [] }; + out.set(row.Code, current); + } else if (current) current.body.push(i); + }); + return out; + } + function machine(project, source, notices) { + const t = project.tables, + s = source.tables; + // 손료 계수·환율 표 통째 교체, 「_」 당초 칸은 같은 Index 행의 옛 값 유지 + const old = new Map((t.MachGijunDat || []).map((r) => [num(r.Index), r])); + t.MachGijunDat = structuredClone(s.MachGijunDat || []).map((r) => { + const prev = old.get(num(r.Index)); + if (prev) for (const k of Object.keys(prev)) if (k.endsWith("_")) r[k] = prev[k]; + return r; + }); + if (project.baseline) { + notices.push( + "설계변경이 이루어진 데이타의 기계경비는적용기준(환율,노임,유류대)만 갱신됩니다.", + ); + return 0; + } + const find = finder(s.MachTitleDat || [], 105); + let missing = 0; + const tg = groups(t.MachMainDat), + sg = groups(s.MachMainDat); + const replaced = []; + for (const title of t.MachTitleDat || []) { + if (!norm(title.Name) && !norm(title.Size)) continue; + const src = find(title); + if (!src) { + missing++; + title.Checked = true; + continue; + } + title.Checked = false; + copy(title, src); + const mine = tg.get(title.Code), + theirs = sg.get(src.Code); + if (mine && theirs) { + copy(t.MachMainDat[mine.head], s.MachMainDat[theirs.head]); + replaced.push([mine, theirs.body.map((i) => structuredClone(s.MachMainDat[i]))]); + } + } + // 산출근거 줄 교체(뒤에서부터) + 자재·노임 연결은 현재 공사의 같은 이름 자료로 다시 잇기(못 찾으면 코드 비우고 211/212) + replaced.sort((a, b) => b[0].head - a[0].head); + for (const [mine, body] of replaced) { + for (const row of body) { + const li = num(row.LinkIndex); + if (li !== 5 && li !== 6) continue; + const list = li === 5 ? t.StuffcDat : t.LaborcDat, + hit = (list || []).find( + (r) => norm(r.Name) === norm(row.Name) && (li === 6 || norm(r.Size) === norm(row.Size)), + ); + if (hit) row.Code = hit.Code; + else Object.assign(row, { Code: "", LinkIndex: li === 5 ? 212 : 211 }); + } + const start = mine.head + 1, + count = mine.body.length; + t.MachMainDat.splice(start, count, ...body); + } + return missing; + } + + // options = { labor, stuff, siljuk, mach, fuel, rate } + function apply(project, source, o) { + const t = project.tables, + s = source.tables, + notices = []; + const keep = new Map( + (t.MachGijunDat || []) + .filter((r) => RATES.includes(num(r.Index))) + .map((r) => [num(r.Index), { TC: r.TC, Bigo: r.Bigo }]), + ); + const miss = { 노임단가: 0, 자재단가: 0, 표준시장단가: 0, 기계경비: 0 }; + if (o.labor) miss.노임단가 = table(t.LaborcDat, s.LaborcDat || [], 6); + if (o.stuff && o.fuel) miss.자재단가 = table(t.StuffcDat, s.StuffcDat || [], 5); + else if (o.fuel) table(t.StuffcDat, s.StuffcDat || [], 5, isFuel, true); + else if (o.stuff) table(t.StuffcDat, s.StuffcDat || [], 5, (r) => !isFuel(r), true); + if (o.siljuk) miss.표준시장단가 = table(t.SilJukDat, s.SilJukDat || [], 21); + if (o.mach) miss.기계경비 = machine(project, source, notices); + const rows = t.MachGijunDat || []; + if (o.rate && !o.mach) + for (const i of RATES) { + const mine = rows.find((r) => num(r.Index) === i), + theirs = (s.MachGijunDat || []).find((r) => num(r.Index) === i); + if (mine && theirs) Object.assign(mine, { TC: theirs.TC, Bigo: theirs.Bigo }); + } + if (!o.rate) + for (const [i, v] of keep) { + const mine = rows.find((r) => num(r.Index) === i); + if (mine) Object.assign(mine, v); + } + const parts = Object.entries(miss) + .filter(([, n]) => n > 0) + .map(([k, n]) => `${k}에서 ${n} 개`); + if (parts.length) + notices.push( + `${parts.join(", ")}의 찾지못한 데이타가 있습니다.\n\n단가미갱신란에 체크된 데이타를 검토하세요.`, + ); + return { missing: miss, notices }; + } + + // 화면 + function dialog(state) { + const opts = [ + ["labor", "노임단가"], + ["stuff", "자재단가"], + ["siljuk", "표준단가"], + ["mach", "기계경비"], + ["fuel", "유류대"], + ["rate", "환율"], + ]; + EstApp.showDialog( + "현재데이타 갱신", + `



갱신시 적용할 항목 : ${opts.map(([k, l]) => ``).join(" ")}

※주의사항 — 현재데이타 갱신후 이전 상태로 복원할 수 없습니다(저장 전에는 ↶ 취소로 되돌림).

`, + ); + } + async function source(kind) { + if (kind === "main") return EstApp.api("/api/main-data"); + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".estx"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) throw Error("갱신할 파일을 선택하지 않았습니다."); + return EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, { + method: "POST", + body: await file.arrayBuffer(), + }); + } + async function run(state) { + const kind = document.querySelector('#dialog-content [name="du-src"]:checked')?.value, + o = Object.fromEntries( + [...document.querySelectorAll('#dialog-content [name="du-opt"]')].map((n) => [ + n.value, + n.checked, + ]), + ); + if (!Object.values(o).some(Boolean)) throw Error("갱신할 항목을 고르세요."); + const src = await source(kind); + if ( + !confirm( + `현재 작업데이타를 "${kind === "main" ? "Main" : src.name}"의 단가로 갱신하시겠습니까?`, + ) + ) + return; + let result; + EstApp.byId("dialog").close(); + EstApp.change((project) => (result = apply(project, src, o))); + state.notice = result.notices.join(" / ") || "현재데이타를 갱신했습니다."; + EstApp.render(); + } + function handle(name, state) { + if (name === "data-update") dialog(state); + else if (name === "data-update-run") + run(state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + else return false; + return true; + } + return { apply, isFuel, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index e062da7f3..9a171af68 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -7,6 +7,9 @@ const http = require("node:http"), const { importEstx, table: dbisamTable } = require("./import-estx.cjs"); const { readXls } = require("./xls-read.cjs"); const bookStore = require("./book-store.cjs"); +const mainDataPath = + process.env.EST_MAIN_DATA || "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx"; +let mainData = null; const exchange = require("./exchange.cjs"); const { exportEstx } = require("./export-estx.cjs"); const jdl = require("./jdl.cjs"); @@ -89,6 +92,7 @@ const allowed = new Set([ "native-global-vars.js", "native-new-estimate.js", "native-book.js", + "native-data-update.js", "native-wonga-tools.js", "native-estimate-print.js", "native-xml-check.js", @@ -251,6 +255,26 @@ const server = http.createServer(async (req, res) => { reply(res, 200, importEstx(bytes, name)); return; } + if (req.method === "GET" && url.pathname === "/api/main-data") { + // 현재데이타 갱신 원천 Main Data(원본 설치 main.estx = BufDat\Main\Main_1) — 갱신에 쓰는 표만 + if (!mainData) { + const project = importEstx(fs.readFileSync(mainDataPath), "Main"); + const keys = [ + "LaborcDat", + "StuffcDat", + "SilJukDat", + "MachTitleDat", + "MachMainDat", + "MachGijunDat", + ]; + mainData = { + name: "Main", + tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])), + }; + } + reply(res, 200, mainData); + return; + } if (req.method === "GET" && url.pathname.startsWith("/api/book")) { // 품셈 보기(원본 BookData) — /api/book · /api/book/<책>/tree · /text/ · /search?q=&mode=title|body|keyword const [, book, what, id] = url.pathname.slice(1).split("/").slice(1);