diff --git a/resources/tester/test_est_find.py b/resources/tester/test_est_find.py
new file mode 100644
index 000000000..5c2cc80d3
--- /dev/null
+++ b/resources/tester/test_est_find.py
@@ -0,0 +1,78 @@
+"""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-find.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_next_walks_matches_in_order_and_stops_at_end():
+ result = run_js("""(() => {
+ const rows = [{Name:'터파기'},{Name:'되메우기'},{Name:'잔토 터파기',Size:'백호'},{Name:'포장'}];
+ const first = EstFind.next(rows, '터파기', 0);
+ const second = EstFind.next(rows, '터파기', first + 1);
+ const end = EstFind.next(rows, '터파기', second + 1);
+ const spec = EstFind.next(rows, '백호', 0);
+ const loose = EstFind.next(rows, ' 잔토 ', 0);
+ let empty = '';
+ try { EstFind.next(rows, ' ', 0); } catch (e) { empty = e.message; }
+ return {first, second, end, spec, loose, empty};
+ })()""")
+ assert result == {
+ "first": 0,
+ "second": 2,
+ "end": -1,
+ "spec": 2,
+ "loose": 2,
+ "empty": "검색어를 입력하세요.",
+ }
+
+
+def test_search_reads_cost_line_text_and_skips_previous_values():
+ result = run_js("""(() => {
+ const rows = [
+ {LineData:'\\x0e콘크리트\\x0e \\x13타설\\x13', Name_:'옛이름'},
+ {Name:'다른 행'}
+ ];
+ return {
+ line: EstFind.next(rows, '타설', 0),
+ old: EstFind.next(rows, '옛이름', 0)
+ };
+ })()""")
+ assert result == {"line": 0, "old": -1}
+
+
+def test_group_position_for_head_rows():
+ result = run_js("""(() => {
+ const rows = project.tables.HeadMainDat;
+ const titles = rows.map((r, i) => [i, r]).filter(([, r]) => Number(r.Index) > 0);
+ const [start, title] = titles[1];
+ const hit = EstFind.groupOf(rows, start + 1);
+ return {code: hit.code === title.Code, position: hit.position};
+ })()""")
+ assert result == {"code": True, "position": 1}
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 9138ff8e8..eea4daade 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -432,9 +432,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 수량 항목 끝수 처리(원본 TFRMMULLANG mnuAuto·mnuTrunc·mnuUp·mnuRound 0~5) — 결과 코드 뒤 방식+자리수(1 반올림·2 절사·3 올림)로 항목·결과 표지 함께 바꿈, 중복 코드 거부. 새 항목 코드가 끝수 붙은 코드의 앞 7자리와 겹치던 오류 수정. ESTX 수정 저장은 명확한 문구로 거부. 연결 내역코드 칸의 선택 상자가 옆 칸을 덮던 배치 수정.
- 검증완료: `test_est_quantity_format.py` 4개(1.2345 → AUTO 1.23·올림1 1.3·반올림3 1.235·AUTO 복귀, 거부 3종, 코드 생성, 설치 main.estx 저장 거부 문구), 수량·ESTX 저장 시험 포함 50개 통과. ORCA 브라우저에서 선택 상자로 올림 1자리 → 재계산 1.3·코드 r00000031 확인, 샘플 다시 열어 원상 복구.
- 남음: 끝수 변경의 ESTX 저장(원본 항목 코드 키 변경 — 인덱스 재생성 필요).
+ - [x] 찾기 창(원본 TFRMESTSERCH·TFRMCOSTWORDCHANGE) — 검색어·처음부터/현재위치부터·다음찾기로 행 이동(호표 자동 전환·쪽 이동), 단가산출 한 건씩 바꾸기 후 다음 위치, Ctrl+F. 이전 값(`*_`) 필드는 찾지 않음.
+ - 검증완료: `test_est_find.py` 3개, 단추·단가 고르기·행 동작 포함 24개 통과. ORCA 브라우저에서 Ctrl+F로 창 열기, 내역 「합판거푸집」 8·15·22·26행 순서 이동, 단가산출 마지막 호표 행 찾기(호표 D0000000006 전환·선택 표시), 바꾸기 후 다음 위치(323행)·되돌리기 원상 확인.
+ - 남음: 통합검색(TFRMMULTISERCH — 다른 공사·폴더 자료 검색).
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
- 1. 찾기 — 다음 찾기·한 건씩 바꾸기·전체 자료 통합검색(지금은 현재 표 걸러 보기만).
+ 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
2. 공내역 파일로 저장(단가 비운 사본).
3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄.
4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 4a3112584..b273285e3 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
+`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
+
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 9a01f9acc..9112f8474 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -38,6 +38,7 @@
+
@@ -88,7 +89,7 @@
>
-
+
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js
index 8e9861ff1..fa70740ff 100644
--- a/tools/est-test/native-actions.js
+++ b/tools/est-test/native-actions.js
@@ -227,7 +227,7 @@ const EstActions = (() => {
EstApp.calculate();
EstApp.cache();
EstApp.render();
- } else if (name === "find") EstApp.byId("search").focus();
+ } else if (name === "find") EstFind.handle(name, EstApp.state);
else if (name === "level") {
if (!selected().length) throw Error("레벨을 바꿀 행을 선택하세요.");
const level = Number(node.dataset.level);
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index df7548127..6be4570c1 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -599,7 +599,8 @@ const EstBusinessActions = (() => {
);
} else if (
EstBlockActions.handle(name, state) ||
- (typeof EstPick !== "undefined" && EstPick.handle(name, state))
+ (typeof EstPick !== "undefined" && EstPick.handle(name, state)) ||
+ (typeof EstFind !== "undefined" && EstFind.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-find.js b/tools/est-test/native-find.js
new file mode 100644
index 000000000..bd5193de2
--- /dev/null
+++ b/tools/est-test/native-find.js
@@ -0,0 +1,121 @@
+// 원본 찾기(TFRMESTSERCH: 검색어·처음부터/현재위치부터·다음찾기)와
+// 산출근거 바꾸기(TFRMCOSTWORDCHANGE: 다음찾기·바꾸기)를 옮긴 찾기 창. 모두 바꾸기는 기존 `산출근거 문구 바꾸기`.
+const EstFind = (() => {
+ const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"];
+ const fold = (value) =>
+ String(value ?? "")
+ .normalize("NFKC")
+ .toLowerCase();
+
+ function text(row) {
+ return Object.entries(row)
+ .filter(([key, value]) => typeof value === "string" && !key.endsWith("_"))
+ .map(([key, value]) => (key === "LineData" ? EstText.clean(value) : value))
+ .join("\n");
+ }
+
+ function next(rows, query, from = 0) {
+ const q = fold(query).trim();
+ if (!q) throw Error("검색어를 입력하세요.");
+ for (let index = Math.max(0, from); index < rows.length; index++)
+ if (fold(text(rows[index])).includes(q)) return index;
+ return -1;
+ }
+
+ function groupOf(rows, index) {
+ for (const [code, group] of EstNative.groups(rows))
+ if (index >= group.start && index < group.start + group.rows.length)
+ return { code, position: index - group.start };
+ return { code: "", position: index };
+ }
+
+ function open(state) {
+ const rows = state.project.tables[state.view];
+ if (!Array.isArray(rows)) throw Error("표 화면에서 찾기를 사용하세요.");
+ state.find ||= { query: "", replacement: "", from: "current" };
+ const { esc } = EstApp,
+ cost = state.view === "CostMainDat";
+ EstApp.showDialog(
+ "찾기(Ctrl+F)",
+ `${cost ? `` : ""}
${cost ? ' ' : ""}`,
+ );
+ document.querySelector('#dialog-content [name="find-query"]').focus();
+ }
+
+ function read(state) {
+ const value = (name) => document.querySelector(`#dialog-content [name="${name}"]`)?.value;
+ state.find.query = value("find-query") ?? "";
+ state.find.replacement = value("find-replacement") ?? state.find.replacement;
+ state.find.from = value("find-from") || "current";
+ }
+
+ function show(state, index) {
+ const rows = state.project.tables[state.view],
+ { code, position } = grouped.includes(state.view)
+ ? groupOf(rows, index)
+ : { code: state.group, position: index };
+ state.group = code;
+ state.query = "";
+ EstApp.byId("search").value = "";
+ state.page = Math.floor(position / 100);
+ state.selection = new Set([index]);
+ EstApp.render();
+ }
+
+ function findNext(state) {
+ read(state);
+ const rows = state.project.tables[state.view],
+ start =
+ state.find.from === "first"
+ ? 0
+ : Math.max(-1, ...[...state.selection].map(Number).filter(Number.isFinite)) + 1;
+ const index = next(rows, state.find.query, start);
+ const result = EstApp.byId("find-result");
+ if (index < 0) {
+ result.textContent = "끝까지 찾았습니다. 더 찾을 항목이 없습니다.";
+ return;
+ }
+ state.find.from = "current";
+ document.querySelector('#dialog-content [name="find-from"]').value = "current";
+ show(state, index);
+ result.textContent = `${index + 1}번째 행을 찾았습니다.`;
+ }
+
+ function replaceCurrent(state) {
+ read(state);
+ if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 바꾸기를 사용하세요.");
+ const rows = state.project.tables.CostMainDat,
+ index = [...state.selection][0];
+ if (state.selection.size !== 1 || !rows[index]) throw Error("먼저 다음찾기로 행을 찾으세요.");
+ const updates = EstText.replaceWords(
+ [{ row: rows[index], index }],
+ state.find.query,
+ state.find.replacement,
+ );
+ if (!updates.length) throw Error("이 행에는 바꿀 문구가 없습니다. 다음찾기를 누르세요.");
+ EstApp.change((project) => (project.tables.CostMainDat[index].LineData = updates[0].value));
+ findNext(state);
+ }
+
+ function handle(name, state) {
+ if (name === "find") open(state);
+ else if (name === "find-next") findNext(state);
+ else if (name === "find-replace") replaceCurrent(state);
+ else return false;
+ return true;
+ }
+
+ if (typeof document !== "undefined" && document.addEventListener)
+ document.addEventListener("keydown", (event) => {
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "f") return;
+ event.preventDefault();
+ try {
+ open(EstApp.state);
+ } catch (error) {
+ EstApp.state.notice = error.message;
+ EstApp.render();
+ }
+ });
+
+ return { text, next, groupOf, handle };
+})();
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 87a9a3c9c..a206156f0 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -58,6 +58,7 @@ const allowed = new Set([
"native-business-actions.js",
"native-block-actions.js",
"native-pick.js",
+ "native-find.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",