diff --git a/resources/tester/test_est_multi_search.py b/resources/tester/test_est_multi_search.py new file mode 100644 index 000000000..69928c982 --- /dev/null +++ b/resources/tester/test_est_multi_search.py @@ -0,0 +1,36 @@ +"""EST 테스트 앱 통합 검색(원본 TFRMMULTISERCH, 검색위치 = 저장된 작업).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_search_finds_words_in_selected_kinds(tmp_path): + sample = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8")) + sample["name"] = "검색 시험 공사" + (tmp_path / ("a" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8") + (tmp_path / ("b" * 24 + ".json")).write_text("{bad json", encoding="utf-8") + (tmp_path / "forms.json").write_text("[]", encoding="utf-8") + script = """ + const s = require('./tools/est-test/search-store.cjs'), dir = process.argv[1]; + const out = {}; + out.labor = s.search(dir, '건설기계 운전사', ['6']); + out.none = s.search(dir, '건설기계', ['5']); + out.excluded = s.search(dir, '건설기계', ['6'], 'a'.repeat(24)); + const err = []; + for (const args of [['', ['6']], ['x', ['999']]]) { try { s.search(dir, ...args); } catch (e) { err.push(e.message.slice(0, 4)); } } + out.err = err; + process.stdout.write(JSON.stringify(out)); + """ + result = json.loads( + subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8") + ) + hit = result["labor"]["hits"][0] + assert result["labor"]["total"] >= 1 + assert hit["project"] == "검색 시험 공사" and hit["kind"] == 6 and hit["label"] == "노임단가" + assert "건설기계운전사" in hit["Name"].replace(" ", "") + assert result["none"]["total"] == 0 + assert result["excluded"]["total"] == 0 + assert result["err"] == ["검색어를", "검색할 "] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 42c7e961c..7232d7ce2 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -467,9 +467,10 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 정렬·데이타 위치정리·호표 정리·근거(메모)(원본 BarSort 0x110ADEC·TfrmSiljukSort, mnuTableSort 0x10CCAEC·TFRMTABLESORT, popTitleSort 0x10D88EC, mnuGunguEdit/Del·DifineMemo — 디스어셈블 근거) — 관급구분별 가나다·Index 재번호·빈 행 제외(30줄 채우기는 안 함), 품명 오름/내림·시작번호·묶음 통째 이동, 번호만 재매김·제목표 동기화, 메모 WPTools 글자 읽기 전용 보기·삭제. 원본 위치정리 콤보 글자 뒤바뀜은 웹에서 실제 순서대로 표기. - 검증완료: `test_est_tidy.py` 2개(관급구분·빈 행·실적 코드 정렬·화면 거부, 위치정리·번호정렬·제목 동기화·도급액 불변·오류 0) 통과. ORCA 브라우저에서 설치 main.estx 메모를 넣어 보기 → 「2015년 건설표준품셈 … 잔디블록」 글자 표시, 삭제 → null, 자재 정렬 → 빈 행 12개 제외, 샘플 복구. - 남음: 메모 편집(원본 서식 저장), 위치정리 드래그 이동. + - [x] 통합 검색(원본 TFRMMULTISERCH 자료 7종·검색어·검색위치) — 검색위치는 이 앱 저장 작업(`search-store.cjs`, `/api/search`), 고른 자료를 하위 연결째 현재 공사로 가져옴(EstTransfer). 원본 「Main Data에서 검색」·폴더 고르기는 웹 서버에 원본 자료 폴더가 없어 저장 작업으로 대신함. + - 검증완료: `test_est_multi_search.py`(낱말 모두 포함·자료 종류·제외 작업·깨진 파일 무시·빈 검색어/종류 거부) 통과. ORCA 브라우저에서 저장 작업 59개 대상 「콘크리트」 단가산출 85건 → 없는 D0000000140 가져오기 → 연결 자료 3건·단가산출 21행 추가·단가 28,878 원본과 같음·오류 0, 샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): - 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. 7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리. 8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index d6283418c..34334f9c3 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -24,6 +24,8 @@ `편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다. +`편집 → 통합 검색`은 원본 통합 검색처럼 일위대가·단가산출·기계경비·견적·실적·자재·노임 중 고른 자료를 검색어로 찾습니다. 검색 위치는 이 앱에 저장된 다른 작업입니다. 찾은 자료를 고르면 하위 연결 자료와 함께 현재 공사로 가져오고(같은 코드는 현재 자료 유지), 단가 고르기로 행에 넣습니다. + `편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다. `편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index d95acf973..bc17909b4 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -49,6 +49,7 @@ + @@ -105,6 +106,7 @@ >
+ diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 6a2575497..56b2871da 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -610,7 +610,8 @@ const EstBusinessActions = (() => { (typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) || (typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) || (typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) || - (typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) + (typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) || + (typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-multi-search.js b/tools/est-test/native-multi-search.js new file mode 100644 index 000000000..0825a5727 --- /dev/null +++ b/tools/est-test/native-multi-search.js @@ -0,0 +1,79 @@ +// 원본 통합 검색(TFRMMULTISERCH) 화면 — 저장된 다른 작업에서 단가·호표를 찾아, 고른 자료를 하위 연결 자료와 함께 +// 현재 공사로 가져온다(EstTransfer). 같은 코드는 현재 공사 자료를 그대로 둔다. 가져온 뒤 단가 고르기로 행에 넣는다. +const EstMultiSearch = (() => { + const kinds = [ + [103, "일위대가"], + [104, "단가산출"], + [105, "기계경비"], + [101, "견적단가"], + [21, "실적단가"], + [5, "자재단가"], + [6, "노임단가"], + ]; + + function form(state) { + const { esc } = EstApp, + saved = state.multiSearch || { query: "", kinds: kinds.map(([k]) => k), hits: [], total: 0 }; + state.multiSearch = saved; + const rows = saved.hits + .map( + (h, i) => + `${esc(h.project)}${esc(h.label)}${esc(h.Code)}${esc(h.Name)}${esc(h.Size)}${esc(h.Unite)}${EstApp.number(h.price)}`, + ) + .join(""); + EstApp.showDialog( + "통합 검색", + `

검색위치: 이 앱에 저장된 다른 작업

${kinds.map(([k, label]) => ``).join("")}
${rows}
작업구분코드품명규격단위단가

찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}

`, + ); + } + + async function run(name, state) { + if (name === "multi-search") form(state); + else if (name === "multi-search-run") { + const saved = state.multiSearch; + saved.query = document.querySelector('#dialog-content [name="ms-query"]').value; + saved.kinds = [...document.querySelectorAll('#dialog-content [name="ms-kind"]:checked')].map( + (n) => Number(n.value), + ); + const params = new URLSearchParams({ q: saved.query, kinds: saved.kinds.join(",") }); + if (state.project.id) params.set("exclude", state.project.id); + Object.assign(saved, await EstApp.api(`/api/search?${params}`)); + form(state); + } else if (name === "multi-search-take") { + const picked = [...document.querySelectorAll('#dialog-content [name="ms-hit"]:checked')].map( + (n) => state.multiSearch.hits[Number(n.value)], + ); + if (!picked.length) throw Error("가져올 자료를 고르세요."); + const projects = new Map(); + for (const hit of picked) + if (!projects.has(hit.id)) + projects.set(hit.id, await EstApp.api(`/api/projects/${hit.id}`)); + const deps = picked.map((hit) => + EstTransfer.collect(projects.get(hit.id), [{ LinkIndex: hit.kind, Code: hit.Code }]), + ); + let added = 0, + kept = 0; + EstApp.change((project) => { + for (const dep of deps) { + const result = EstTransfer.merge(project, dep); + added += result.added.length; + kept += result.kept.length; + } + }); + EstApp.byId("dialog").close(); + state.notice = `자료 ${added}건을 가져왔습니다${kept ? `, 같은 코드 ${kept}건은 이 공사 자료를 그대로 씀` : ""}. 단가 고르기로 행에 넣으세요.`; + EstApp.render(); + } + } + const names = new Set(["multi-search", "multi-search-run", "multi-search-take"]); + function handle(name, state) { + if (!names.has(name)) return false; + run(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + + return { handle }; +})(); diff --git a/tools/est-test/search-store.cjs b/tools/est-test/search-store.cjs new file mode 100644 index 000000000..6823841e1 --- /dev/null +++ b/tools/est-test/search-store.cjs @@ -0,0 +1,69 @@ +// 원본 통합 검색(TFRMMULTISERCH: 일위대가·단가산출·기계경비·견적·실적·자재·노임 고르기, 검색어, 검색위치) — +// 웹은 이 앱에 저장된 작업(data/*.json)을 검색 위치로 쓴다. 검색어 낱말이 모두 코드·품명·규격에 들어 있으면 찾음. +const fs = require("node:fs"); +const path = require("node:path"); + +const sources = { + 103: ["HeadTitleDat", "일위대가"], + 104: ["CostTitleDat", "단가산출"], + 105: ["MachTitleDat", "기계경비"], + 101: ["EstiMateDat", "견적단가"], + 21: ["SilJukDat", "실적단가"], + 5: ["StuffcDat", "자재단가"], + 6: ["LaborcDat", "노임단가"], +}; +const LIMIT = 300; +const fold = (value) => + String(value ?? "") + .normalize("NFKC") + .toLowerCase() + .replace(/\s+/g, ""); + +function search(directory, query, kinds, exclude = "") { + const words = String(query ?? "") + .split(/\s+/) + .map(fold) + .filter(Boolean); + if (!words.length) throw Error("검색어를 입력하세요."); + const wanted = (kinds?.length ? kinds : Object.keys(sources)) + .map(Number) + .filter((k) => sources[k]); + if (!wanted.length) throw Error("검색할 자료를 하나 이상 고르세요."); + if (!fs.existsSync(directory)) return { hits: [], total: 0 }; + const hits = []; + let total = 0; + for (const name of fs.readdirSync(directory).filter((n) => /^[a-f0-9]{24}\.json$/.test(n))) { + const id = name.slice(0, -5); + if (id === exclude) continue; + let project; + try { + project = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8")); + } catch { + continue; + } + for (const kind of wanted) { + const [table, label] = sources[kind]; + for (const row of project.tables?.[table] || []) { + if (!row?.Code) continue; + const text = fold(`${row.Code} ${row.Name} ${row.Size}`); + if (!words.every((w) => text.includes(w))) continue; + total++; + if (hits.length < LIMIT) + hits.push({ + id, + project: project.name || id, + kind, + label, + Code: row.Code, + Name: row.Name ?? "", + Size: row.Size ?? "", + Unite: row.Unite ?? "", + price: Number(row.TC) || 0, + }); + } + } + } + return { hits, total }; +} + +module.exports = { search, sources }; diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index fe208131c..678d80657 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -10,6 +10,7 @@ const { exportEstx } = require("./export-estx.cjs"); const jdl = require("./jdl.cjs"); const { inspectAccom } = require("./inspect-accom.cjs"); const forms = require("./forms-store.cjs"); +const searchStore = require("./search-store.cjs"); const base = __dirname, port = Number(process.env.EST_TEST_PORT || 5175), host = process.env.EST_TEST_HOST || "100.65.141.15"; @@ -70,6 +71,7 @@ const allowed = new Set([ "native-cleanup.js", "native-name-reg.js", "native-tidy.js", + "native-multi-search.js", "native-issues.js", "native-attachments.js", "native-standard.js", @@ -163,6 +165,20 @@ const server = http.createServer(async (req, res) => { reply(res, 200, input ? forms.save(dataDirectory, input) : forms.read(dataDirectory)); return; } + if (req.method === "GET" && url.pathname === "/api/search") { + const kinds = (url.searchParams.get("kinds") || "").split(",").filter(Boolean); + reply( + res, + 200, + searchStore.search( + dataDirectory, + url.searchParams.get("q"), + kinds, + url.searchParams.get("exclude") || "", + ), + ); + return; + } if (req.method === "POST" && url.pathname === "/api/forms/delete") { const input = JSON.parse((await body(req)).toString("utf8")); reply(res, 200, forms.remove(dataDirectory, input.kind, input.name));