From d25183e89d962c5a200c561737a16ef52baeb5d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:36:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EB=82=B4=EC=97=AD=C2=B7?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 검색 창(TFRMSERCHPROJECT)처럼 7종 자료를 검색어로 찾아 여러 개 골라 넣기 - 원본 데이타 변경 확인(TFRMDBLCLICKRTN)처럼 선택 행 연결 바꾸기, 같은 코드 전체 변경 선택 - 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림 - 시험 test_est_pick.py 추가 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_pick.py | 136 +++++++++++++ tools/est-test/PLAN.md | 3 + tools/est-test/README.md | 2 + tools/est-test/index.html | 4 + tools/est-test/native-business-actions.js | 5 +- tools/est-test/native-pick.js | 238 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 7 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_pick.py create mode 100644 tools/est-test/native-pick.js diff --git a/resources/tester/test_est_pick.py b/resources/tester/test_est_pick.py new file mode 100644 index 000000000..282bbc673 --- /dev/null +++ b/resources/tester/test_est_pick.py @@ -0,0 +1,136 @@ +"""EST 테스트 앱 단가 고르기: 검색·넣기·바꾸기와 계산 연결.""" + +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-pick.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.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + out = subprocess.check_output(["node", "-e", runner], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +def test_search_filters_by_kind_and_all_words(): + result = run_js("""(() => { + const labor = project.tables.LaborcDat.find((r) => r.Code && r.Name); + const word = String(labor.Name).replace(/\\s+/g, "").slice(0, 2); + const one = EstPick.search(project, 6, word); + const all = EstPick.search(project, 0, labor.Code); + const none = EstPick.search(project, 6, word + " 없는말없는말"); + return { + kinds: [...new Set(one.rows.map((r) => r.kind))], + hit: one.rows.some((r) => r.Code === labor.Code), + exact: all.rows.filter((r) => r.Code === labor.Code).length, + none: none.total, + }; + })()""") + assert result == {"kinds": [6], "hit": True, "exact": 1, "none": 0} + + +def test_insert_links_rows_and_engine_prices_them(): + result = run_js("""(() => { + const labor = project.tables.LaborcDat.find((r) => r.Code && Number(r.TC) > 0); + const pick = EstPick.items(project, 6).find((r) => r.Code === labor.Code); + const rows = project.tables.EstMainDat, before = rows.length; + const added = EstPick.insert(project, "EstMainDat", 0, "", [pick, pick]); + const done = EstNative.calculate(EstModel.validate(project)); + const row = done.tables.EstMainDat[added[0]]; + return { + added, grew: done.tables.EstMainDat.length - before, + link: row.LinkIndex, code: row.Code === labor.Code, name: row.Name === labor.Name, + priced: Number(row.NU) > 0 && Number(row.NU) === Number(pick.price), + }; + })()""") + assert result == { + "added": [1, 2], + "grew": 2, + "link": 6, + "code": True, + "name": True, + "priced": True, + } + + +def test_replace_current_row_or_all_same_code(): + result = run_js("""(() => { + const rows = project.tables.EstMainDat; + const idx = rows.findIndex((r) => r.Code && !(Number(r.Index) > 0) && [5, 6, 103, 104].includes(Number(r.LinkIndex))); + const target = rows[idx]; + const twin = { ...target, PkID: 999999 }; + rows.splice(idx + 1, 0, twin); + const pick = EstPick.items(project, 5).find((r) => r.Code !== target.Code); + const one = EstPick.replace(structuredClone(project), "EstMainDat", idx, "", pick, false); + const copy = structuredClone(project); + const all = EstPick.replace(copy, "EstMainDat", idx, "", pick, true); + return { + one: one.length, all: all.length >= 2, + moved: copy.tables.EstMainDat[idx + 1].Code === pick.Code, + kind: copy.tables.EstMainDat[idx].LinkIndex, + untouched: project.tables.EstMainDat[idx].Code === target.Code, + }; + })()""") + assert result == {"one": 1, "all": True, "moved": True, "kind": 5, "untouched": True} + + +def test_guards_reject_wrong_view_self_reference_and_title_rows(): + result = run_js("""(() => { + const out = []; + const tryIt = (fn) => { try { fn(); out.push("ok"); } catch (e) { out.push(e.message.slice(0, 12)); } }; + const head = EstPick.items(project, 103)[0]; + tryIt(() => EstPick.insert(project, "CostMainDat", 0, "", [head])); + tryIt(() => EstPick.insert(project, "HeadMainDat", 0, head.Code, [head])); + tryIt(() => EstPick.insert(project, "EstMainDat", 0, "", [])); + const title = project.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0); + tryIt(() => EstPick.replace(project, "HeadMainDat", title, "", EstPick.items(project, 6)[0], false)); + return out; + })()""") + expected = [ + "내역서·일위대가 화면", + "일위대가 자신을 하위", + "넣을 자료를 하나 이상", + "일반 내역 행 하나를", + ] + assert len(result) == len(expected) + assert all(message.startswith(head[:8]) for message, head in zip(result, expected)) + + +def test_insert_into_head_group_prices_from_source(): + result = run_js("""(() => { + const rows = project.tables.HeadMainDat; + const title = rows.findIndex((r) => Number(r.Index) > 0); + const stuff = EstPick.items(project, 5).find((r) => r.price > 0); + const added = EstPick.insert(project, "HeadMainDat", title, rows[title].Code, [stuff]); + const row = project.tables.HeadMainDat[added[0]]; + return { at: added[0] === title + 1, link: row.LinkIndex, unit: Number(row.GU) === stuff.price }; + })()""") + assert result == {"at": True, "link": 5, "unit": True} diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index d772aa9f3..f5736b2cf 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -426,6 +426,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [ ] 원본 메뉴·단추 대조(CAD 제외 약 880개)에서 나온 핵심 미구현 처리. 대조표 = 작업 폴더 `tmp/est-audit/gap_*.md`. - [x] 처리기만 있고 단추가 없던 동작 4개 연결 — 레벨 일괄 증가·감소(LEVEL 메뉴), 원가계산서 초기화(원가계산서 메뉴), 설계변경 초기화(설계변경 화면). 처리기마다 단추가 있는지 보는 지킴 시험 `test_est_action_bindings.py` 추가. - 검증완료: 옛 코드에서 시험 실패(4개 검출)·새 코드 통과, `test_est_row_actions.py` 포함 16개 통과. ORCA 브라우저에서 두 행 레벨 2→3→2 왕복·되돌리기 복원, 원가 초기화 확인창, 당초 없음 차단 문구 확인. + - [x] 단가 고르기(원본 TFRMSERCHPROJECT·TFRMDBLCLICKRTN) — 내역서·일위대가에서 7종 자료 검색·여러 개 선택·선택 행 아래 넣기, 선택 행 연결 바꾸기(변경 전후 확인·같은 코드 전체 변경). 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림. 자기 일위대가 순환·제목·요율 행 차단. + - 검증완료: `test_est_pick.py` 5개 통과(검색·넣기·단가 반영·바꾸기·차단·일위대가 경로). ORCA 브라우저에서 보통인부 넣기 → 노무단가 172,068 반영, 경유로 바꾸기 → 재료단가 1,376 반영, 되돌리기 2회로 76행·도급액 79,603,000 복원. + - 남음: 단가산출 산출근거(LineData) 행 넣기, 원본 「Main값우선」 선택, 기계경비 상세 행. - [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다. - 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 1bfd25b02..acc1c0e7a 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -20,6 +20,8 @@ 단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다. +내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. + 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. 원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 1b966ed3f..9a01f9acc 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -37,6 +37,7 @@ + @@ -88,6 +89,9 @@ >
+ diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 2e6fcd5a8..df7548127 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -597,7 +597,10 @@ const EstBusinessActions = (() => { }); }, ); - } else if (EstBlockActions.handle(name, state)) { + } else if ( + EstBlockActions.handle(name, state) || + (typeof EstPick !== "undefined" && EstPick.handle(name, state)) + ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { purpose = name; diff --git a/tools/est-test/native-pick.js b/tools/est-test/native-pick.js new file mode 100644 index 000000000..8a92ae53d --- /dev/null +++ b/tools/est-test/native-pick.js @@ -0,0 +1,238 @@ +// 원본 TFRMSERCHPROJECT(검색어·여러개선택·동시검색·적용)와 TFRMDBLCLICKRTN(데이타 변경 확인)을 옮긴 단가 고르기. +// 내역서·일위대가 행에 기초자료를 골라 넣거나 선택 행의 연결을 바꾼다. 단가산출 산출근거(LineData)는 대상이 아니다. +const EstPick = (() => { + const kinds = [ + [5, "StuffcDat", "자재"], + [6, "LaborcDat", "노임"], + [105, "MachTitleDat", "기계경비"], + [103, "HeadTitleDat", "일위대가"], + [104, "CostTitleDat", "단가산출"], + [101, "EstiMateDat", "견적"], + [21, "SilJukDat", "실적"], + ]; + const views = ["EstMainDat", "HeadMainDat"]; + const limit = 300; + const text = (value) => + String(value ?? "") + .normalize("NFKC") + .toLowerCase() + .replace(/\s+/g, ""); + const amount = (row) => { + const total = Number(row.TC) || 0; + return total || ["NC", "GC", "JC", "SC"].reduce((sum, key) => sum + (Number(row[key]) || 0), 0); + }; + + function items(project, kind) { + const list = []; + for (const [k, table, label] of kinds) { + if (kind && k !== kind) continue; + for (const row of project.tables[table] || []) + if (row.Code && Number(row.Index ?? 1) >= 0) + list.push({ + kind: k, + label, + Code: row.Code, + Name: row.Name ?? "", + Size: row.Size ?? "", + Unite: row.Unite ?? "", + price: amount(row), + }); + } + return list; + } + + function search(project, kind, query) { + const words = String(query || "") + .split(/\s+/) + .map(text) + .filter(Boolean); + const found = items(project, kind).filter((item) => { + const haystack = text(`${item.Code} ${item.Name} ${item.Size}`); + return words.every((word) => haystack.includes(word)); + }); + return { rows: found.slice(0, limit), total: found.length }; + } + + function guard(view, group, picks) { + if (!views.includes(view)) + throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다."); + if (!picks.length) throw Error("넣을 자료를 하나 이상 고르세요."); + if (view === "HeadMainDat" && picks.some((p) => p.kind === 103 && p.Code === group)) + throw Error("일위대가 자신을 하위 자료로 넣을 수 없습니다."); + } + + function linked(base, pick) { + return { + ...base, + Code: pick.Code, + Name: pick.Name, + Size: pick.Size, + Unite: pick.Unite, + LinkIndex: pick.kind, + }; + } + + function insert(project, view, at, group, picks) { + guard(view, group, picks); + const rows = project.tables[view]; + const added = []; + for (const pick of picks) { + const row = linked(EstModel.newRow(view, rows, project), pick); + rows.splice(at + 1 + added.length, 0, row); + added.push(at + 1 + added.length); + } + try { + refresh(project, view, added); + } catch (error) { + rows.splice(at + 1, added.length); + throw error; + } + return added; + } + + function refresh(project, view, indices) { + // 내역·일위대가 행은 요청한 행만 원천 단가를 다시 읽는다(행 비목 지정과 같은 경로). + let failures = null; + const done = EstNative.calculate( + EstModel.validate(structuredClone(project)), + view === "HeadMainDat" + ? { refreshHeadRows: indices, onHeadRefresh: (errors) => (failures = errors) } + : { refreshEstimateRows: indices, onEstimateRefresh: (errors) => (failures = errors) }, + ); + if (!failures) throw Error("고른 자료의 단가를 다시 읽지 못했습니다."); + if (failures.length) throw Error(failures.join(" / ")); + for (const index of indices) + for (const key of EstMath.units) + project.tables[view][index][key] = done.tables[view][index][key]; + } + + function replaceable(target) { + if ( + !target || + Number(target.Index) > 0 || + [54, 55, 57, 58, 61, 62, 67, 68].includes(Number(target.LinkIndex)) + ) + throw Error("일반 내역 행 하나를 선택하세요. 제목·요율·순공사비 행은 바꿀 수 없습니다."); + } + + function replace(project, view, index, group, pick, all) { + guard(view, group, [pick]); + const rows = project.tables[view], + target = rows[index]; + replaceable(target); + const old = { kind: Number(target.LinkIndex) || 0, Code: target.Code }; + const changed = [], + backup = structuredClone(rows); + rows.forEach((row, i) => { + const same = + i === index || + (all && + old.Code && + row.Code === old.Code && + Number(row.LinkIndex) === old.kind && + Number(row.Index ?? 0) <= 0); + if (!same) return; + Object.assign(row, linked({}, pick)); + changed.push(i); + }); + try { + refresh(project, view, changed); + } catch (error) { + project.tables[view] = backup; + throw error; + } + return changed; + } + + function cell(value) { + return EstApp.esc(value); + } + + function results(state) { + const pick = state.pick, + { rows, total } = search(state.project, pick.kind, pick.query); + pick.rows = rows; + const body = rows + .map( + (item, i) => + `${cell(item.label)}${cell(item.Code)}${cell(item.Name)}${cell(item.Size)}${cell(item.Unite)}${EstApp.number(item.price)}`, + ) + .join(""); + return `
${body}
구분코드품명규격단위단가

찾은 자료 ${total}건${total > limit ? ` 중 앞 ${limit}건 표시 — 검색어를 더 넣으세요` : ""}

`; + } + + function open(state) { + if (!views.includes(state.view)) + throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다."); + state.pick ||= { kind: 0, query: "" }; + const options = [[0, "전체 동시검색"], ...kinds.map(([k, , label]) => [k, label])] + .map( + ([k, label]) => + ``, + ) + .join(""); + EstApp.showDialog( + "단가 고르기", + `
${results(state)}
`, + ); + } + + function chosen(state) { + return [...document.querySelectorAll('#dialog-content [name="pick-row"]:checked')].map( + (node) => state.pick.rows[Number(node.value)], + ); + } + + function confirmReplace(target, pick) { + const line = (row) => + `${row.Name || ""} / ${row.Size || ""} / ${row.Unite || ""}${row.Code ? ` [${row.Code}]` : ""}`; + return confirm( + `데이타 변경 확인\n변경 전: ${line(target)}\n변경 후: ${line(pick)}\n\n변경하시겠습니까?`, + ); + } + + function handle(name, state) { + if (name === "pick-open") open(state); + else if (name === "pick-search") { + state.pick.kind = Number(document.querySelector('#dialog-content [name="pick-kind"]').value); + state.pick.query = document.querySelector('#dialog-content [name="pick-query"]').value; + EstApp.byId("pick-results").innerHTML = results(state); + } else if (name === "pick-insert") { + const picks = chosen(state), + rows = state.project.tables[state.view]; + guard(state.view, state.group, picks); + const group = EstNative.groups(rows).get(state.group); + const at = + [...state.selection].sort((a, b) => a - b).at(-1) ?? + (group ? group.start + group.rows.length - 1 : rows.length - 1); + EstApp.byId("dialog").close(); + EstApp.change((project) => { + state.selection = new Set(insert(project, state.view, at, state.group, picks)); + }); + state.notice = `${picks.length}개 자료를 넣었습니다.`; + } else if (name === "pick-replace") { + const picks = chosen(state), + index = [...state.selection][0]; + if (picks.length !== 1 || state.selection.size !== 1) + throw Error("바꿀 행 하나와 자료 하나를 고르세요."); + const target = state.project.tables[state.view][index]; + guard(state.view, state.group, picks); + replaceable(target); + if (!confirmReplace(target, picks[0])) return true; + const all = + Boolean(target.Code) && + confirm( + `같은 코드(${target.Code})를 쓰는 이 표의 다른 행도 함께 바꾸시겠습니까?\n[취소] 현재 행만 변경`, + ); + EstApp.byId("dialog").close(); + let changed = []; + EstApp.change((project) => { + changed = replace(project, state.view, index, state.group, picks[0], all); + }); + state.notice = `${changed.length}개 행의 연결을 바꿨습니다.`; + } else return false; + return true; + } + + return { kinds, items, search, insert, replace, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 60968a389..87a9a3c9c 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -57,6 +57,7 @@ const allowed = new Set([ "native-actions.js", "native-business-actions.js", "native-block-actions.js", + "native-pick.js", "native-issues.js", "native-attachments.js", "native-standard.js",