From 86a1ad31446686ef01f51d5883768cb09d3448c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 13:25:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EB=B9=84=EB=AA=A9=EB=B3=84?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?=C2=B7SUM=20=EC=83=89=EC=9D=B8=EC=A7=80=EC=A0=95=C2=B7=ED=95=98?= =?UTF-8?q?=EC=9C=84=EB=8D=B0=EC=9D=B4=ED=83=80=20=EC=97=B0=EA=B2=B0/?= =?UTF-8?q?=EB=81=8A=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_row_links.py | 53 +++++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 6 +- tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-row-links.js | 160 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 6 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_est_row_links.py create mode 100644 tools/est-test/native-row-links.js diff --git a/resources/tester/test_est_row_links.py b/resources/tester/test_est_row_links.py new file mode 100644 index 000000000..15fb0d47c --- /dev/null +++ b/resources/tester/test_est_row_links.py @@ -0,0 +1,53 @@ +"""EST 테스트 앱 줄 연결 도구(원본 비목별 단가 불러오기 · SUM 색인지정 · 하위데이타 연결/끊기).""" + +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 }}); + for (const f of ['native-autolink.js', 'native-row-links.js']) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + 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_cost_part_sum_index_and_toggle(): + out = run_js( + r"""(() => { + const L = EstRowLinks, p = { tables: { + CostTitleDat: [{ Code: 'D1', Name: '거푸집', Size: '합판', Unite: 'm2', TC: 60, NC: 40, GC: 15, JC: 5, SC: 0 }], + StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40kg', Unite: '포', TC: 5000, GC: 5000 }], + LaborcDat: [], HeadTitleDat: [], MachTitleDat: [], SilJukDat: [], EstiMateDat: [] } }; + const r = { LinkIndex: 104, Code: 'D1', NU: 1, GU: 1, JU: 1, SU: 1, TU: 9 }; + L.costPart(p, r, 3); + const jc = [r.NU, r.GU, r.JU, r.SU, r.OCheck, r.TU]; + L.costPart(p, r, 0); + const all = [r.NU, r.GU, r.JU, r.OCheck]; + let refused = ''; try { L.costPart(p, { LinkIndex: 5 }, 0); } catch (e) { refused = e.message; } + const est = { Code: '', Name: '시멘트', Size: '40kg', Unite: '포', LinkIndex: 0 }; + const linked = [L.toggle(p, 'EstMainDat', est), est.Code, est.LinkIndex, est.GU, est.TU]; + const unlinked = [L.toggle(p, 'EstMainDat', est), est.LinkIndex, est.Code]; + let none = ''; try { L.toggle(p, 'EstMainDat', { Name: '없음', LinkIndex: 0 }); } catch (e) { none = e.message; } + const skip = L.toggle(p, 'EstMainDat', { LinkIndex: 54 }); + const s = { LinkIndex: 0, Index: 0 }; L.sumIndex('EstMainDat', s, 3); + const bad = []; for (const row of [{ Level: 1, GN: 0 }, { LinkIndex: 53 }, { Index: 1 }]) + try { L.sumIndex('EstMainDat', row, 1); } catch (e) { bad.push(e.message.slice(0, 6)); } + return { jc, all, refused, linked, unlinked, none, skip, skind: s.SKind, bad }; + })()""" + ) + assert out["jc"] == [0, 0, 5, 0, 3, 9] + assert out["all"] == [40, 15, 5, 0] + assert out["refused"] == "노임,자재,표준시장단가는 적용할수 없습니다." + assert out["linked"] == ["link", "M1", 5, 5000, 5000] + assert out["unlinked"] == ["unlink", 0, "M1"] + assert out["none"] == "해당 하위data를 찾지못해 연결시키지 못했습니다." + assert out["skip"] == "skip" + assert out["skind"] == 3 + assert out["bad"] == ["레벨지정한 ", "현재라인의 ", "현재라인의 "] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 5f8404ec5..7cd49ce93 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] 줄 연결 도구(원본 TfrmHead popupCostNC·GC·JC·All 0xf1b54c · popMainSumIndex · mnuDataLink 0xf00e6c — 디스어셈블 근거) — 비목별 단가 불러오기(일위대가 줄: 하위 견적·일위·단산·기계에서 고른 비목 단가만 남기고 나머지 0, 기준비목 지정, 노임·자재·표준 거부), SUM 색인지정(해제·A~Z → 구간 SKind, 레벨·소계·도급액·조달수수료·호표머리 줄 거부), 하위데이타 연결/끊기(연결 줄은 끊고 값 유지, 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임 차례로 코드 또는 품명·규격·단위 맞춤으로 연결, 자재는 재료단가·노임은 노무단가 = 원본 TC). 원본은 초점 한 줄, 웹은 고른 줄 모두. + - 검증완료: `test_est_row_links.py`(경비만 5·전체 40/15/5, 거부, 자재 연결 M1·5000·끊기, 못 찾음 문구, 색인 3·거부 3종) 통과. ORCA 브라우저에서 일위대가 합판거푸집 노무비만 → 0, 재료비만 → 재료단가 34,666·금액 13,173, 내역 일위 연결 줄 끊기 → 다시 연결 103·도급액 불변, SUM 색인 B, 샘플 복구. - [x] 묶음 인쇄 간지 설정값 일괄적용·기본양식에저장·전체선택/선택해제(원본 TFRMESTPRINTPRIVATE btnGangiAllSet·btnSaveToMain, TFRMESTPRINT2 btnNoselect) — 간지 인쇄·간지 겹테두리를 모든 서식에 같게, 고른 서식·간지 설정을 「묶음기본양식」(양식 보관함 종류 print)으로 저장해 묶음 설정 없는 공사에서 열 때 씀. - 검증완료: 묶음 인쇄·바인딩 시험 통과. ORCA 브라우저에서 선택해제 0 · 전체선택 40, 겹테두리 끔 → 미리보기 간지 테두리 없음, 기본양식 저장 뒤 설정 지우고 다시 열면 1개·겹테두리 끔 복원, 양식 지우고 샘플 복구. - [x] 갑지 공사금액 자동변경(원본 TFRMGABGI popupCostChange) — 「공사금액 자동변경」 체크 시 고른 원가 비목·방식을 기억해 인쇄 때마다 현재 원가 금액으로 공사금액 글을 다시 만듦(다시 열면 고른 비목·방식 그대로). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 104e034f8..8816534ac 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -60,6 +60,7 @@ + @@ -139,7 +140,10 @@ > + >
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 3b3a696b1..18a431da2 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -631,7 +631,8 @@ const EstBusinessActions = (() => { (typeof EstDocForms !== "undefined" && EstDocForms.handle(name, state, node)) || (typeof EstBlockFile !== "undefined" && EstBlockFile.handle(name, state)) || (typeof EstDisplay !== "undefined" && EstDisplay.handle(name, state)) || - (typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state)) + (typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state)) || + (typeof EstRowLinks !== "undefined" && EstRowLinks.handle(name, state)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/native-row-links.js b/tools/est-test/native-row-links.js new file mode 100644 index 000000000..254fff34a --- /dev/null +++ b/tools/est-test/native-row-links.js @@ -0,0 +1,160 @@ +// 원본 줄 단위 연결 도구(spec_part 1~3): +// 비목별 단가 불러오기(TfrmHead popupCostNC·GC·JC·All 0xf1b54c) — 일위대가 줄의 하위 자료(견적·일위·단산·기계)에서 한 비목 단가만 +// 남기고 나머지 단가 0, 기준비목(OCheck) := 고른 비목(0 전체·1 재료·2 노무·3 경비). 노임·자재·표준 줄은 거부. +// SUM 색인지정(popMainSumIndex, TfrmSetSumIndex): 고른 줄 SKind := 해제(0)·A(1)…Z(26) — 비율 줄(62)이 코드 글자로 모아 더함. +// 하위데이타 연결/끊기(mnuDataLink 0xf00e6c): 연결 줄은 끊고(LinkIndex 0, 값은 남김), 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임 +// 차례로 코드(없으면 품명·규격·단위 맞춤)를 찾아 코드·명칭·규격·단위·단가를 가져와 연결. 원본은 초점 한 줄, 웹은 고른 줄 모두. +const EstRowLinks = (() => { + const num = (v) => Number(v) || 0; + const SRC = { + 101: "EstiMateDat", + 103: "HeadTitleDat", + 104: "CostTitleDat", + 105: "MachTitleDat", + 21: "SilJukDat", + 5: "StuffcDat", + 6: "LaborcDat", + }; + const ORDER = [103, 104, 105, 21, 101, 5, 6]; + const LINKED = new Set([5, 6, 21, 101, 103, 104, 105, 211, 212, 213, 214, 215]); + const norm = (s) => String(s ?? "").replace(/\s/g, ""); + function byCode(project, kind, code) { + const c = norm(code); + return c ? (project.tables[SRC[kind]] || []).find((r) => norm(r.Code) === c) : null; + } + + function costPart(project, row, tag) { + const kind = num(row.LinkIndex); + if ([5, 6, 21].includes(kind)) throw Error("노임,자재,표준시장단가는 적용할수 없습니다."); + const src = SRC[kind] ? byCode(project, kind, row.Code) : null, + v = (k) => num(src?.[k]); + row.OCheck = tag; + row.NU = row.GU = row.JU = row.SU = 0; + if (tag === 0) Object.assign(row, { NU: v("NC"), GU: v("GC"), JU: v("JC"), SU: v("SC") }); + else if (tag === 2) row.NU = v("NC"); + else if (tag === 1) row.GU = v("GC"); + else Object.assign(row, { JU: v("JC"), SU: v("SC") }); + } + + function sumIndex(view, row, k) { + if (view === "EstMainDat") { + if (num(row.Level) > 0 && !num(row.GN)) + throw Error("레벨지정한 라인의 값을 더할값으로 지정할수없습니다."); + if ([52, 53, 55, 57].includes(num(row.LinkIndex)) || num(row.Index) !== 0) + throw Error("현재라인의 값은 더할값으로 지정할수없습니다."); + } else if (num(row.LinkIndex) === 53 || num(row.Index) > 0) + throw Error("현재라인의 값은 더할값으로 지정할수없습니다."); + row.SKind = k; + } + + function toggle(project, view, row) { + const li = num(row.LinkIndex); + if (li) { + if (LINKED.has(li)) row.LinkIndex = 0; + return li && LINKED.has(li) ? "unlink" : "skip"; + } + const tag = { EstMainDat: 1, HeadMainDat: 2, MachMainDat: 4 }[view] ?? 0; + for (const kind of ORDER) { + const src = norm(row.Code) + ? byCode(project, kind, row.Code) + : EstAutoLink.find(project, kind, EstAutoLink.key(row, tag)); + if (!src) continue; + Object.assign(row, { + Code: src.Code, + Name: src.Name ?? "", + Size: src.Size ?? "", + Unite: src.Unite ?? "", + LinkIndex: kind, + NU: num(src.NC), + GU: num(src.GC), + JU: num(src.JC), + SU: num(src.SC), + }); + if (kind === 5) row.GU = num(src.TC); + if (kind === 6) row.NU = num(src.TC); + row.TU = row.NU + row.GU + row.JU + row.SU; + return "link"; + } + throw Error("해당 하위data를 찾지못해 연결시키지 못했습니다."); + } + + function picked(state, views) { + const rows = state.project.tables[state.view] || [], + list = [...state.selection].filter((i) => rows[i]).sort((a, b) => a - b); + if (!views.includes(state.view) || !list.length) + throw Error( + `${views.length > 2 ? "내역서·일위대가·단가산출·기계경비" : "내역서·일위대가"}에서 줄을 고르세요.`, + ); + return list; + } + + function handle(name, state) { + if (name === "cost-part") { + if (state.view !== "HeadMainDat") throw Error("일위대가 화면에서 쓰세요."); + const list = picked(state, ["HeadMainDat"]); + EstActions.form( + "비목별 단가 불러오기", + [ + { + name: "tag", + label: "불러올 비목", + value: 0, + choices: [ + [0, "전체비목 불러오기"], + [2, "노무비만 불러오기"], + [1, "재료비만 불러오기"], + [3, "경비만 불러오기"], + ], + }, + ], + (values) => { + const probe = structuredClone(state.project); + list.forEach((i) => costPart(probe, probe.tables.HeadMainDat[i], Number(values.tag))); + EstApp.change((project) => + list.forEach((i) => + costPart(project, project.tables.HeadMainDat[i], Number(values.tag)), + ), + ); + }, + ); + } else if (name === "sum-index") { + const list = picked(state, ["EstMainDat", "HeadMainDat"]); + EstActions.form( + "Sum 색인", + [ + { + name: "k", + label: "색인", + value: 1, + choices: [ + [0, "해제"], + ...Array.from({ length: 26 }, (_, i) => [i + 1, String.fromCharCode(65 + i)]), + ], + }, + ], + (values) => { + const rows = state.project.tables[state.view]; + list.forEach((i) => sumIndex(state.view, structuredClone(rows[i]), 0)); + EstApp.change((project) => + list.forEach((i) => + sumIndex(state.view, project.tables[state.view][i], Number(values.k)), + ), + ); + }, + ); + } else if (name === "data-link") { + const list = picked(state, ["EstMainDat", "HeadMainDat", "CostMainDat", "MachMainDat"]); + const probe = structuredClone(state.project); + list.forEach((i) => toggle(probe, state.view, probe.tables[state.view][i])); + let out = []; + EstApp.change((project) => { + out = list.map((i) => toggle(project, state.view, project.tables[state.view][i])); + }); + const linked = out.filter((r) => r === "link").length, + unlinked = out.filter((r) => r === "unlink").length; + state.notice = `연결 ${linked}줄 · 끊기 ${unlinked}줄`; + } else return false; + return true; + } + return { costPart, sumIndex, toggle, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 62924800f..c3735b188 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -81,6 +81,7 @@ const allowed = new Set([ "native-block-file.js", "native-display.js", "native-cell-clip.js", + "native-row-links.js", "native-wonga-tools.js", "native-estimate-print.js", "native-xml-check.js",