diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py
index 7383ceb32..aac015d41 100644
--- a/M01_MasterData/M01_MasterData_Store_Make.py
+++ b/M01_MasterData/M01_MasterData_Store_Make.py
@@ -283,6 +283,12 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
"""`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) ·
자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}."""
revise.guard(batch, confirmed)
+ for part in batch: # 별칭(815-2) = 글 그대로 · 앞뒤 빈칸만 뗌 · 빈 글이면 칸 없음
+ for ch in part.get("changes") or []:
+ row = ch.get("row")
+ if isinstance(row, dict) and "별칭" in row:
+ text = str(row["별칭"] or "").strip()
+ row.update(별칭=text) if text else row.pop("별칭")
fresh = False
for part in batch:
if part.get("file") != OWN:
diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts
index a2ea6a063..89ec75704 100644
--- a/M01_MasterData/M01_MasterData_UI_Rows.ts
+++ b/M01_MasterData/M01_MasterData_UI_Rows.ts
@@ -37,6 +37,9 @@ const MARKET = "재료_자재품목.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
+// 별칭(815-2 · 요구 764) = 비고 앞 한 열 · 콤마로 여러 개 · 빈 글이면 칸을 지움 · 이 넷만
+const ALIAS = "별칭";
+const ALIASED = ["인력.json", "기계.json", MARKET, "재료_유가전력.json"];
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
@@ -44,16 +47,16 @@ function blank(sample: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(sample)) {
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
- else out[k] = typeof v === "string" && k !== "값" ? "" : null;
+ else if (k !== ALIAS) out[k] = typeof v === "string" && k !== "값" ? "" : null;
}
return out;
}
-/** 비고 칸 고침 — 빈 글이면 칸을 지움 */
-function withNote(row: Row, text: string): Row {
+/** 비고 · 별칭 칸 고침 — 빈 글이면 칸을 지움(별칭은 앞뒤 빈칸 뗌) */
+function withNote(row: Row, text: string, col = NOTE): Row {
const out = { ...row };
- if (text.trim()) out[NOTE] = text;
- else delete out[NOTE];
+ if (text.trim()) out[col] = col === ALIAS ? text.trim() : text;
+ else delete out[col];
return out;
}
@@ -117,8 +120,9 @@ export function renderRows(
const cols: string[] = [];
for (const row of [...adds, ...d.rows]) {
for (const k of Object.keys(flatten(row)))
- if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k);
+ if (!hidden(file, k) && k !== NOTE && k !== ALIAS && !cols.includes(k)) cols.push(k);
}
+ const aliased = ALIASED.includes(file);
const heads = (HEADS[file] ?? []).map((h) =>
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
);
@@ -127,6 +131,7 @@ export function renderRows(
el("th"),
...cols.map((c) => el("th", { text: c })),
...(JOB.includes(file) ? [] : heads),
+ ...(aliased ? [el("th", { text: ALIAS })] : []),
el("th", { text: NOTE }),
...(JOB.includes(file) ? heads : []),
],
@@ -145,12 +150,13 @@ export function renderRows(
}),
);
}
- tr.append(
- buildCell(show(row[NOTE]), {
- changed: true,
- onEdit: (text) => setAdd(file, i, withNote(row, text)),
- }),
- );
+ for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
+ tr.append(
+ buildCell(show(row[col]), {
+ changed: true,
+ onEdit: (text) => setAdd(file, i, withNote(row, text, col)),
+ }),
+ );
if (JOB.includes(file)) tr.append(el("td"));
body.append(tr);
});
@@ -186,15 +192,16 @@ export function renderRows(
? undefined
: (next: Row): void => editRow(file, d.version, key, row, next);
if (file === LINKED) tr.append(linkedCell(row, cur, put));
- tr.append(
- buildCell(show(cur[NOTE]), {
- changed: !same(cur[NOTE], row[NOTE]),
- onEdit:
- deleted || !isScalar(row[NOTE])
- ? undefined
- : (text) => editRow(file, d.version, key, row, withNote(cur, text)),
- }),
- );
+ for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
+ tr.append(
+ buildCell(show(cur[col]), {
+ changed: !same(cur[col], row[col]),
+ onEdit:
+ deleted || !isScalar(row[col])
+ ? undefined
+ : (text) => editRow(file, d.version, key, row, withNote(cur, text, col)),
+ }),
+ );
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
body.append(tr);
}
diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py
index 656407821..fb7f260e1 100644
--- a/resources/master_data/scripts/check_master.py
+++ b/resources/master_data/scripts/check_master.py
@@ -149,6 +149,11 @@ COLS = {
),
}
COLS["한국은행환율"] = COLS["유가전력"]
+ALIAS = "별칭" # 요소 줄 덧칸(815-2) — 열 한 벌 밖 · 있으면 빈 글 아님
+
+
+def _cols_of(row: dict) -> tuple:
+ return tuple(k for k in row if k != ALIAS)
def _detail(name: str) -> str:
@@ -310,7 +315,9 @@ def check_form(name: str, data: dict) -> list[str]:
out.append(f"{where} · 출처 모양 「{row['출처']}」")
if data.get("그룹") == "인력":
out += _check_labor(where, row)
- if cols and tuple(row) != cols:
+ if ALIAS in row and not (isinstance(row[ALIAS], str) and row[ALIAS].strip()):
+ out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
+ if cols and _cols_of(row) != cols:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
if kind == "품셈재료":
out += _check_linked(where, row)
@@ -328,7 +335,7 @@ def check_form(name: str, data: dict) -> list[str]:
def _check_labor(where: str, row: dict) -> list[str]:
"""인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록."""
out = []
- if tuple(row) != LABOR_COLS:
+ if _cols_of(row) != LABOR_COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」")
if row.get("상태") not in LABOR_STATUS:
out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님")
diff --git a/resources/master_data/기계.json b/resources/master_data/기계.json
index a9c562fa7..0377d698a 100644
--- a/resources/master_data/기계.json
+++ b/resources/master_data/기계.json
@@ -12574,7 +12574,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
- "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
+ "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
+ "별칭": "아스팔트페이버(피니셔)"
},
{
"키": "EQ000193",
@@ -12635,7 +12636,8 @@
"조종원": "건설품셈 공통 8-4-4",
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
},
- "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
+ "비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
+ "별칭": "아스팔트페이버(피니셔)"
},
{
"키": "EQ000194",
@@ -16031,9 +16033,8 @@
"조종원직종": "건설품셈 공통 8-1-3 5"
},
"비고": "규격 단위: (m/m) · 주연료종류: 원문 글을 가름",
- "값원문": {
- "8-4-5 주연료": "휘발유1.0"
- }
+ "값원문": { "8-4-5 주연료": "휘발유1.0" },
+ "별칭": "바이브레이트 봉상후렉시블"
},
{
"키": "EQ000249",
@@ -25212,7 +25213,8 @@
"조종원": "건설품셈 공통 8-4-7",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "주연료종류: 원문 글을 가름"
+ "비고": "주연료종류: 원문 글을 가름",
+ "별칭": "워터제트"
},
{
"키": "EQ000401",
@@ -26734,7 +26736,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "주연료종류: 원문 글을 가름"
+ "비고": "주연료종류: 원문 글을 가름",
+ "별칭": "버킷준설기"
},
{
"키": "EQ000426",
@@ -31131,7 +31134,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000498",
@@ -31192,7 +31196,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000499",
@@ -31253,7 +31258,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000500",
@@ -31314,7 +31320,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000501",
@@ -31375,7 +31382,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000502",
@@ -31436,7 +31444,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000503",
@@ -31497,7 +31506,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000504",
@@ -31558,7 +31568,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000505",
@@ -31619,7 +31630,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000506",
@@ -31680,7 +31692,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000507",
@@ -31741,7 +31754,8 @@
"조종원": "건설품셈 공통 8-4-8",
"조종원직종": "건설품셈 공통 8-1-3 5"
},
- "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
+ "비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
+ "별칭": "디젤 엔진"
},
{
"키": "EQ000508",
diff --git a/resources/master_template/실무자료/맞춤.json b/resources/master_template/실무자료/맞춤.json
deleted file mode 100644
index 7b37a261a..000000000
--- a/resources/master_template/실무자료/맞춤.json
+++ /dev/null
@@ -1,124 +0,0 @@
-{
- "줄": {
- "아스팔트페이버(피니셔)||시간": {
- "원래": {
- "품명": "아스팔트페이버(피니셔)",
- "규격": "",
- "단위": "hr"
- },
- "갈래": "짝",
- "짝": [
- {
- "갈래": "기계",
- "키": "EQ000193",
- "입력값": {},
- "배": 1,
- "몫": null
- }
- ],
- "엑셀조항": [],
- "누가": {
- "id": "요구735-5·736",
- "이름": "사용자 승인(시스템 관리자)"
- },
- "때": "2026-11-09T11:13:18Z",
- "처음파일": null
- },
- "디젤엔진||시간": {
- "원래": {
- "품명": "디젤 엔진",
- "규격": "",
- "단위": "hr"
- },
- "갈래": "짝",
- "짝": [
- {
- "갈래": "기계",
- "키": "EQ000500",
- "입력값": {},
- "배": 1,
- "몫": null
- }
- ],
- "엑셀조항": [],
- "누가": {
- "id": "요구735-5·736",
- "이름": "사용자 승인(시스템 관리자)"
- },
- "때": "2026-11-09T11:13:18Z",
- "처음파일": null
- },
- "워터제트||시간": {
- "원래": {
- "품명": "워터제트",
- "규격": "",
- "단위": "hr"
- },
- "갈래": "짝",
- "짝": [
- {
- "갈래": "기계",
- "키": "EQ000400",
- "입력값": {},
- "배": 1,
- "몫": null
- }
- ],
- "엑셀조항": [],
- "누가": {
- "id": "요구735-5·736",
- "이름": "사용자 승인(시스템 관리자)"
- },
- "때": "2026-11-09T11:13:18Z",
- "처음파일": null
- },
- "버킷준설기||시간": {
- "원래": {
- "품명": "버킷준설기",
- "규격": "",
- "단위": "hr"
- },
- "갈래": "짝",
- "짝": [
- {
- "갈래": "기계",
- "키": "EQ000425",
- "입력값": {},
- "배": 1,
- "몫": null
- }
- ],
- "엑셀조항": [],
- "누가": {
- "id": "요구735-5·736",
- "이름": "사용자 승인(시스템 관리자)"
- },
- "때": "2026-11-09T11:13:18Z",
- "처음파일": null
- },
- "바이브레이트|봉상후렉시블|시간": {
- "원래": {
- "품명": "바이브레이트",
- "규격": "봉상후렉시블",
- "단위": "hr"
- },
- "갈래": "짝",
- "짝": [
- {
- "갈래": "기계",
- "키": "EQ000248",
- "입력값": {},
- "배": 1,
- "몫": null
- }
- ],
- "엑셀조항": [],
- "누가": {
- "id": "요구735-5·736",
- "이름": "사용자 승인(시스템 관리자)"
- },
- "때": "2026-11-09T11:13:18Z",
- "처음파일": null
- }
- }
-}
diff --git a/resources/tester/test_815_2_alias_column.py b/resources/tester/test_815_2_alias_column.py
new file mode 100644
index 000000000..f622bb4ae
--- /dev/null
+++ b/resources/tester/test_815_2_alias_column.py
@@ -0,0 +1,104 @@
+"""PLAN 815-2 — M01 요소 「별칭」 한 열(요구 764 · 765).
+
+① 별칭 칸 저장 = 그 줄만 바뀜 · 앞뒤 빈칸 뗌 · 빈 글이면 칸 없음(임시 사본 폴더만)
+② M01 길은 시스템 관리자만(아니면 403)
+③ 마스터 기계 별칭 다섯 → 기계 줄 `별칭` · 옛 장부 파일 걷음
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+
+import pytest
+from fastapi import Depends, FastAPI
+from fastapi.testclient import TestClient
+
+from common_util import common_util_auth as auth
+from M01_MasterData import M01_MasterData_Router as router_module
+from M01_MasterData import M01_MasterData_Store as store
+
+ROOT = Path(__file__).resolve().parents[2]
+REAL = store.FOLDER
+MACHINE = "기계.json"
+KEY = "EQ000001"
+FIVE = {
+ "EQ000192": "아스팔트페이버(피니셔)",
+ "EQ000193": "아스팔트페이버(피니셔)",
+ **{f"EQ000{n}": "디젤 엔진" for n in range(497, 508)},
+ "EQ000400": "워터제트",
+ "EQ000425": "버킷준설기",
+ "EQ000248": "바이브레이트 봉상후렉시블",
+}
+
+
+@pytest.fixture
+def copy(tmp_path: Path, monkeypatch) -> Path:
+ for path in REAL.glob("*.json"):
+ if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
+ shutil.copy(path, tmp_path / path.name)
+ monkeypatch.setattr(store, "FOLDER", tmp_path)
+ return tmp_path
+
+
+def _app(role: str = "SYSTEM_ADMIN") -> TestClient:
+ app = FastAPI()
+ app.dependency_overrides[auth.verify_session] = lambda: {
+ "user_id": 1,
+ "name": "시험",
+ "role": role,
+ "company_id": 1,
+ }
+ deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)]
+ app.include_router(router_module.router, dependencies=deps)
+ return TestClient(app)
+
+
+def _save_alias(client: TestClient, text: str) -> None:
+ one = client.get("/api/m01/rows", params={"file": MACHINE, "q": KEY}).json()
+ row = next(r for r in one["rows"] if r["키"] == KEY)
+ part = {
+ "file": MACHINE,
+ "version": one["version"],
+ "changes": [{"op": "edit", "key": KEY, "row": {**row, "별칭": text}}],
+ }
+ res = client.post("/api/m01/save", json={"files": [part]})
+ assert res.status_code == 200, res.text
+
+
+def _around(text: str) -> tuple[str, str, str]:
+ """(그 줄 앞 글, 그 줄 글, 뒤 글)."""
+ start = text.rfind("{", 0, text.index(f'"키": "{KEY}"'))
+ _, end = json.JSONDecoder().raw_decode(text, start)
+ return text[:start], text[start:end], text[end:]
+
+
+def test_별칭_저장_그_줄만_빈칸이면_칸_없음(copy: Path) -> None:
+ client = _app()
+ before = (copy / MACHINE).read_bytes()
+ _save_alias(client, " 시험별칭A, 시험별칭B ")
+ after = (copy / MACHINE).read_bytes().decode("utf-8")
+ row = next(r for r in json.loads(after)["줄"] if r["키"] == KEY)
+ assert row["별칭"] == "시험별칭A, 시험별칭B" # 글 그대로 · 앞뒤 빈칸만 뗌
+ old, new = _around(before.decode("utf-8")), _around(after)
+ assert old[0] == new[0] and old[2] == new[2] # 그 줄 밖 글 · 서식 그대로
+ assert json.loads(new[1]) == {**json.loads(old[1]), "별칭": "시험별칭A, 시험별칭B"}
+ _save_alias(client, " ")
+ last = _around((copy / MACHINE).read_bytes().decode("utf-8"))
+ assert last[0] == old[0] and last[2] == old[2]
+ assert json.loads(last[1]) == json.loads(old[1]) # 빈 글 = 칸 없음 = 처음 값
+
+
+def test_M01_길은_시스템_관리자만() -> None:
+ for role in ("USER", "COMPANY_ADMIN"):
+ res = _app(role).post("/api/m01/save", json={"files": []})
+ assert res.status_code == 403
+ main = (ROOT / "main.py").read_text(encoding="utf-8")
+ assert "include_router(m01_master_data_router, dependencies=system_admin_only)" in main
+
+
+def test_마스터_기계_별칭_다섯_옮김_장부_걷음() -> None:
+ rows = {r["키"]: r for r in json.loads((REAL / MACHINE).read_text(encoding="utf-8"))["줄"]}
+ assert {k: r.get("별칭") for k, r in rows.items() if r.get("별칭")} == FIVE
+ assert not (ROOT / "resources" / "master_template" / "실무자료" / "맞춤.json").exists()
diff --git a/resources/tester/test_est_action_bindings.py b/resources/tester/test_est_action_bindings.py
new file mode 100644
index 000000000..044216b29
--- /dev/null
+++ b/resources/tester/test_est_action_bindings.py
@@ -0,0 +1,28 @@
+"""EST 테스트 앱: 처리기가 있는 화면 동작은 모두 단추·메뉴로 부를 수 있어야 함."""
+
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2] / "tools" / "est-test"
+
+
+def test_every_handled_action_has_a_trigger():
+ sources = {
+ p.name: p.read_text(encoding="utf-8")
+ for p in [*ROOT.glob("native-*.js"), *ROOT.glob("native-*.cjs")]
+ }
+ sources["index.html"] = (ROOT / "index.html").read_text(encoding="utf-8")
+ handled = set()
+ for text in sources.values():
+ handled.update(re.findall(r'name === "([a-z0-9-]+)"', text))
+ assert handled
+ unbound = []
+ for name in sorted(handled):
+ uses = sum(
+ len(re.findall(rf'"{re.escape(name)}"', text))
+ - len(re.findall(rf'name === "{re.escape(name)}"', text))
+ for text in sources.values()
+ )
+ if uses <= 0:
+ unbound.append(name)
+ assert unbound == []
diff --git a/resources/tester/test_est_blank.py b/resources/tester/test_est_blank.py
new file mode 100644
index 000000000..3a7ed48f5
--- /dev/null
+++ b/resources/tester/test_est_blank.py
@@ -0,0 +1,77 @@
+"""EST 테스트 앱 공내역 파일 저장(원본 mnuFileNullSave · PrintGongMethod 0/1/2)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+SAMPLE = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
+LOAD = """
+const fs = require('fs'), vm = require('vm');
+const ctx = vm.createContext({ structuredClone, console });
+vm.runInContext(fs.readFileSync('tools/est-test/native-blank.js', 'utf8'), ctx);
+"""
+
+
+def node(script: str, *args: str):
+ out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
+ return json.loads(out.decode("utf-8"))
+
+
+def test_methods_clear_amounts_and_chosen_values_only():
+ result = node("""
+ ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
+ const out = vm.runInContext(`(() => {
+ const pick = (p) => p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0);
+ const src = pick(project), at = project.tables.EstMainDat.indexOf(src);
+ const r = (m) => { const b = EstBlank.make(project, m), x = b.tables.EstMainDat[at];
+ return [x.TC, x.NC, x.TU > 0, x.GN > 0, b.options.AutoCalc, b.name.endsWith('(공내역)'), 'id' in b]; };
+ const head = EstBlank.make(project, 0).tables.HeadMainDat.every((x) => !Number(x.TC) && !Number(x.TU));
+ const wonga = EstBlank.make(project, 2).tables.Wonga.every((x) => !Number(x.Cost));
+ let bad = ''; try { EstBlank.make(project, 3); } catch (e) { bad = e.message; }
+ return { m0: r(0), m1: r(1), m2: r(2), head, wonga, bad,
+ untouched: Number(src.TC) > 0 && Number(src.TU) > 0 };
+ })()`, ctx);
+ process.stdout.write(JSON.stringify(out));
+ """)
+ assert result == {
+ "m0": [0, 0, False, True, False, True, False],
+ "m1": [0, 0, True, False, False, True, False],
+ "m2": [0, 0, True, True, False, True, False],
+ "head": True,
+ "wonga": True,
+ "bad": "공내역 방법을 고르세요.",
+ "untouched": True,
+ }
+
+
+def test_blank_estx_round_trip_keeps_quantities_and_clears_prices():
+ if not SAMPLE.exists():
+ pytest.skip("installed EST sample unavailable")
+ result = node(
+ """
+ const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
+ const project = imp.importEstx(fs.readFileSync(process.argv[1]));
+ ctx.project = project;
+ const blank = vm.runInContext('EstBlank.make(project, 0)', ctx);
+ const back = imp.importEstx(ex.exportEstx(blank));
+ const rows = (p) => p.tables.EstMainDat.filter((r) => Number(r.GN) > 0);
+ process.stdout.write(JSON.stringify({
+ count: rows(back).length === rows(project).length,
+ quantities: rows(back).every((r, i) => Number(r.GN) === Number(rows(project)[i].GN)),
+ prices: back.tables.EstMainDat.every((r) => !Number(r.TU) && !Number(r.TC)),
+ heads: back.tables.HeadMainDat.every((r) => !Number(r.TC)),
+ sources: back.tables.LaborcDat.length === project.tables.LaborcDat.length,
+ }));
+ """,
+ str(SAMPLE),
+ )
+ assert result == {
+ "count": True,
+ "quantities": True,
+ "prices": True,
+ "heads": True,
+ "sources": True,
+ }
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/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/resources/tester/test_est_quantity_format.py b/resources/tester/test_est_quantity_format.py
new file mode 100644
index 000000000..449e18435
--- /dev/null
+++ b/resources/tester/test_est_quantity_format.py
@@ -0,0 +1,96 @@
+"""EST 테스트 앱 수량 항목 끝수 처리(원본 AUTO·절사·올림·반올림 0~5)."""
+
+from test_est_rea_parity import run
+
+SHEET = """const sheet={Code:'N1',Name:'시험',options:{digits:2,roundMode:2},
+ items:[{Code:'r000000',Name:'가'},{Code:'r000001',Name:'나'}],
+ rows:[{LineData:' 가 1.2345×1 = \\x121.23\\x12',markers:['r000000']},
+ {LineData:' 나 2.0001×1 = \\x122\\x12',markers:['r000001']}]};"""
+
+
+def test_format_renames_item_and_marker_and_changes_result():
+ result = run(
+ "math",
+ "(()=>{"
+ + SHEET
+ + """
+ const auto=EstQuantity.calculate(sheet).items[0].GN;
+ EstQuantity.setResultFormat(sheet,0,3,1);
+ const up=EstQuantity.calculate(sheet).items[0].GN;
+ const code=sheet.items[0].Code, marker=sheet.rows[0].markers[0];
+ EstQuantity.setResultFormat(sheet,0,1,3);
+ const half=EstQuantity.calculate(sheet).items[0].GN;
+ EstQuantity.setResultFormat(sheet,0,0);
+ return {auto,up,code,marker,half,back:sheet.items[0].Code,backMarker:sheet.rows[0].markers[0],
+ other:sheet.items[1].Code};
+ })()""",
+ )
+ assert result == {
+ "auto": 1.23,
+ "up": 1.3,
+ "code": "r00000031",
+ "marker": "r00000031",
+ "half": 1.235,
+ "back": "r000000",
+ "backMarker": "r000000",
+ "other": "r000001",
+ }
+
+
+def test_format_rejects_bad_digits_collision_and_unknown_code():
+ result = run(
+ "math",
+ "(()=>{"
+ + SHEET
+ + """
+ const out=[];
+ const t=(fn)=>{try{fn();out.push('ok');}catch(e){out.push(e.message.slice(0,6));}};
+ t(()=>EstQuantity.setResultFormat(sheet,0,2,6));
+ sheet.items[1].Code='r00000021'; sheet.rows[1].markers=['r00000021'];
+ t(()=>EstQuantity.setResultFormat(sheet,0,2,1));
+ sheet.items.push({Code:'X1'});
+ t(()=>EstQuantity.setResultFormat(sheet,2,2,1));
+ return {out,unchanged:sheet.items[0].Code};
+ })()""",
+ )
+ assert result["unchanged"] == "r000000"
+ assert [m[:2] for m in result["out"]] == ["자리", "같은", "이 "]
+
+
+def test_new_item_code_skips_base_used_by_formatted_code():
+ result = run(
+ "math",
+ "(()=>{"
+ + SHEET
+ + """
+ EstQuantity.setResultFormat(sheet,0,2,3);
+ return EstQuantity.nextItemCode(sheet);
+ })()""",
+ )
+ assert result == "r000002"
+
+
+def test_estx_export_refuses_format_change_with_clear_message():
+ import json
+ import subprocess
+ from pathlib import Path
+
+ import pytest
+
+ source = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
+ if not source.exists():
+ pytest.skip("installed EST main sample unavailable")
+ root = Path(__file__).resolve().parents[2]
+ script = """
+ const fs=require('fs'), path=process.argv[1];
+ const imp=require('./tools/est-test/import-estx.cjs'), q=require('./tools/est-test/native-quantity.cjs');
+ const ex=require('./tools/est-test/export-estx.cjs'), p=imp.importEstx(fs.readFileSync(path));
+ const used=(s,c)=>s.rows.some(r=>(r.markers||[]).includes(c));
+ const s=p.quantitySheets.find(s=>s.items.some(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)));
+ q.setResultFormat(s,s.items.findIndex(i=>/^r\d{6}$/.test(i.Code)&&used(s,i.Code)),1,3);
+ let message='';
+ try { ex.exportEstx(p); } catch (e) { message=e.message; }
+ process.stdout.write(JSON.stringify(message));
+ """
+ out = subprocess.check_output(["node", "-e", script, str(source)], cwd=root)
+ assert json.loads(out.decode("utf-8")).startswith("수량 항목 끝수 처리 변경은 ESTX")
diff --git a/resources/tester/test_est_quantity_sheets.py b/resources/tester/test_est_quantity_sheets.py
new file mode 100644
index 000000000..598364c9f
--- /dev/null
+++ b/resources/tester/test_est_quantity_sheets.py
@@ -0,0 +1,74 @@
+"""EST 테스트 앱 수량산출서 복사·순서 이동과 ESTX 저장 거부."""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
+LOAD = """
+const fs = require('fs'), vm = require('vm');
+const ctx = vm.createContext({ structuredClone, console });
+vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx);
+const run = (code) => vm.runInContext(code, ctx);
+"""
+
+
+def node(script: str, *args: str):
+ out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
+ return json.loads(out.decode("utf-8"))
+
+
+def test_copy_inserts_below_with_new_code_and_cleared_links():
+ result = node("""
+ ctx.project = { quantitySheets: [
+ { Code: 'A', Name: '토공', items: [{ Code: 'r000000', TargetCode: 'D1', TargetKey: 'id:3', LinkCode: 'D1' }], rows: [{ LineData: 'x', markers: ['r000000'] }] },
+ { Code: 'B', Name: '포장', items: [], rows: [] },
+ ] };
+ const first = run("EstQuantitySheets.copy(project, 'A', 'u1')");
+ const second = run("EstQuantitySheets.copy(project, 'A', 'u1')");
+ const p = ctx.project;
+ process.stdout.write(JSON.stringify({
+ first, second, order: p.quantitySheets.map((s) => s.Code), name: p.quantitySheets[1].Name,
+ link: p.quantitySheets[1].items[0], original: p.quantitySheets[0].items[0].TargetCode,
+ rows: p.quantitySheets[1].rows[0].markers,
+ }));
+ """)
+ assert result == {
+ "first": "Qu1",
+ "second": "Qu1_2",
+ "order": ["A", "Qu1_2", "Qu1", "B"],
+ "name": "토공 복사본",
+ "link": {"Code": "r000000", "TargetCode": "", "TargetKey": ""},
+ "original": "D1",
+ "rows": ["r000000"],
+ }
+
+
+def test_move_swaps_and_stops_at_edges():
+ result = node("""
+ ctx.project = { quantitySheets: [{ Code: 'A' }, { Code: 'B' }, { Code: 'C' }] };
+ const out = [run("EstQuantitySheets.move(project, 'B', -1)"), run("EstQuantitySheets.move(project, 'B', -1)"),
+ run("EstQuantitySheets.move(project, 'C', 1)")];
+ process.stdout.write(JSON.stringify({ out, order: ctx.project.quantitySheets.map((s) => s.Code) }));
+ """)
+ assert result == {"out": [True, False, False], "order": ["B", "A", "C"]}
+
+
+def test_estx_export_refuses_reordered_sheets():
+ if not MAIN.exists():
+ pytest.skip("installed EST main sample unavailable")
+ result = node(
+ """
+ const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
+ ctx.project = imp.importEstx(fs.readFileSync(process.argv[1]));
+ run("EstQuantitySheets.move(project, project.quantitySheets[0].Code, 1)");
+ let message = '';
+ try { ex.exportEstx(ctx.project); } catch (e) { message = e.message; }
+ process.stdout.write(JSON.stringify(message));
+ """,
+ str(MAIN),
+ )
+ assert result.startswith("수량산출서 순서 변경은 ESTX")
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index c3cf5b014..cbee58afd 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -423,6 +423,35 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
## Codex · EST 독립 테스트 앱 (2026-11-04)
+- [ ] 원본 메뉴·단추 대조(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] 수량 항목 끝수 처리(원본 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 — 다른 공사·폴더 자료 검색).
+ - [x] 공내역 파일로 저장(원본 mnuFileNullSaveClick 0x10cdc44 → 0x113cf38) — 디스어셈블 근거: EstMainDat·HeadTitleDat·HeadMainDat의 TC·NC·GC·JC·SC 비움, PrintGongMethod 0 = TU~SU 비움·1 = GN 비움·2 = 금액만. 웹 사본은 자동계산 끔·원가 금액 비움(원본 WongaDat 처리는 미확인). JSON 또는 원본 구조 ESTX로 내려받기.
+ - 검증완료: `test_est_blank.py` 2개(세 방법·원본 불변·잘못된 방법 거부, 설치 sample.estx 공내역 ESTX 저장·재읽기에서 수량 유지·단가·금액 0·기초자료 유지), 관련 11개 통과. ORCA 브라우저에서 메뉴 → 수량만 남김 → JSON 내려받기(GN 133·TU 0·TC 0), 현재 작업 불변, 사본을 열어 합계 0 유지 확인 후 샘플 복구.
+ - [x] 수량산출서 복사·순서(원본 TFRMMULLANG 공종복사·붙여넣기) — 현재 산출서 아래에 복사(새 코드·내역 연결 비움), 위로·아래로. 순서 변경 ESTX 저장은 명확한 문구로 거부. 단추 지킴 시험이 `.cjs` 화면 파일도 보도록 넓힘.
+ - 검증완료: `test_est_quantity_sheets.py` 3개(복사 위치·코드·연결 비움·원본 불변, 끝에서 멈춤, 설치 main.estx 순서 변경 저장 거부), 수량 관련 23개 통과. ORCA 브라우저에서 토공 복사 → 「토공 복사본」 선택·연결 빈칸, 아래로 → 토공·포장·복사본 순서 확인 후 샘플 복구.
+ - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
+ - [ ] 남은 핵심(실무 빈도순):
+ 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
+ 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 제출용 전체 인쇄.
+ 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
+ 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
+ 6. 낙찰률 전체·범위·공종별 적용.
+ 7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
+ 8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
+ 9. 묶음 인쇄(설계서 한 벌).
+ 10. 표지·갑지·문서 편집 서식(공사명·위치·개요·금액 붙여넣기, 표).
+ - 도메인 값이 필요해 사용자 협의 뒤: 산업안전보건관리비 비교, 조달청 표준공사코드, 현재데이타 갱신(단가 DB), 품셈 열람.
+
- [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..2e43e779a 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -20,6 +20,16 @@
단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다.
+내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
+
+`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
+
+`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
+
+수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
+
+수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
+
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs
index 680789a7e..744ef6ec2 100644
--- a/tools/est-test/export-quantity.cjs
+++ b/tools/est-test/export-quantity.cjs
@@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
+ if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code))
+ throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
const titles = (project.tables.MulTitleDat || []).map((r) => ({ ...r }));
const savedSheets = sheets.slice();
@@ -438,6 +440,16 @@ function updateQuantities(project, base, cp949, memory) {
}
if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
throw Error("수량산출서 원본 편집기 데이터 충돌");
+ const resultBase = (code) => String(code ?? "").match(/^([LrG]\d{6})(?:[123]\d)?$/)?.[1];
+ if (
+ originals[i].items.some(
+ (item) =>
+ resultBase(item.Code) &&
+ !sheets[i].items.some((next) => next.Code === item.Code) &&
+ sheets[i].items.some((next) => resultBase(next.Code) === resultBase(item.Code)),
+ )
+ )
+ throw Error("수량 항목 끝수 처리 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const removed = new Set(
originals[i].items
.filter((item) => !sheets[i].items.some((next) => next.Code === item.Code))
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index ab70a52af..23b895460 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -37,6 +37,10 @@
+
+
+
+
@@ -66,6 +70,7 @@
+
@@ -87,7 +92,10 @@
>
-
+
+
@@ -112,6 +120,9 @@
>
+
+
@@ -128,7 +139,8 @@
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-blank.js b/tools/est-test/native-blank.js
new file mode 100644
index 000000000..bd5a0b96d
--- /dev/null
+++ b/tools/est-test/native-blank.js
@@ -0,0 +1,79 @@
+// 원본 공내역 파일로저장(TfrmEstMain.mnuFileNullSaveClick 0x10cdc44 → 0x113cf38).
+// 내역·일위대가 제목·일위대가 상세의 금액(TC·NC·GC·JC·SC)을 비우고, 공람 방법(PrintGongMethod)에 따라
+// 0 = 단가(TU·NU·GU·JU·SU)도 비움(수량만 남김) · 1 = 수량(GN)을 비움(단가만 남김) · 2 = 금액만 비움.
+const EstBlank = (() => {
+ const tables = ["EstMainDat", "HeadTitleDat", "HeadMainDat"];
+ const amounts = ["TC", "NC", "GC", "JC", "SC"];
+ const units = ["TU", "NU", "GU", "JU", "SU"];
+ const methods = [
+ [0, "수량만 남김(단가·금액 비움)"],
+ [1, "단가만 남김(수량·금액 비움)"],
+ [2, "수량·단가 남김(금액만 비움)"],
+ ];
+
+ function make(project, method) {
+ if (![0, 1, 2].includes(method)) throw Error("공내역 방법을 고르세요.");
+ const blank = structuredClone(project);
+ const clear =
+ method === 0 ? [...amounts, ...units] : method === 1 ? [...amounts, "GN"] : amounts;
+ for (const table of tables)
+ for (const row of blank.tables[table] || [])
+ for (const key of clear) if (row[key] != null) row[key] = 0;
+ // 금액을 비운 원가계산서가 옛 합계를 보이지 않도록 원가 금액도 비운다(원본 WongaDat 처리 확인 전 웹 보조).
+ for (const row of blank.tables.Wonga || []) if (row.Cost != null) row.Cost = 0;
+ delete blank.id;
+ blank.name = `${project.name || "내역서"} (공내역)`;
+ blank.options = { ...blank.options, AutoCalc: false, SourceRefresh: false };
+ blank.calculation = { errors: [], warnings: [], direct: {}, total: 0 };
+ blank.blankMethod = method;
+ return blank;
+ }
+
+ function open(state) {
+ const project = state.project;
+ EstActions.form(
+ "공내역 파일로 저장",
+ [
+ {
+ name: "method",
+ label: "남길 값",
+ value: Number(project.printSettings?.PrintGongMethod ?? 0),
+ choices: methods,
+ },
+ {
+ name: "format",
+ label: "파일 형식",
+ value: project.originalArchive ? "estx" : "json",
+ choices: project.originalArchive
+ ? [
+ ["estx", "원본 구조 ESTX"],
+ ["json", "웹 작업 JSON"],
+ ]
+ : [["json", "웹 작업 JSON"]],
+ },
+ ],
+ async ({ method, format }) => {
+ const blank = make(project, Number(method));
+ if (format === "estx") {
+ const response = await fetch("/api/export-estx", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(blank),
+ });
+ if (!response.ok) throw Error((await response.json()).error);
+ EstApp.download(`${blank.name}.estx`, await response.blob(), "application/octet-stream");
+ } else EstApp.download(`${blank.name}.estweb.json`, JSON.stringify(blank, null, 2));
+ state.notice = "공내역 파일을 내려받았습니다. 현재 작업은 바뀌지 않았습니다.";
+ EstApp.render();
+ },
+ );
+ }
+
+ function handle(name, state) {
+ if (name !== "blank-save") return false;
+ open(state);
+ return true;
+ }
+
+ return { methods, make, handle };
+})();
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 2e6fcd5a8..fc38404c7 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -597,7 +597,13 @@ const EstBusinessActions = (() => {
});
},
);
- } else if (EstBlockActions.handle(name, state)) {
+ } else if (
+ EstBlockActions.handle(name, state) ||
+ (typeof EstPick !== "undefined" && EstPick.handle(name, state)) ||
+ (typeof EstFind !== "undefined" && EstFind.handle(name, state)) ||
+ (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ||
+ (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state))
+ ) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
purpose = name;
diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js
index 6f06961d9..3ea8e8b2c 100644
--- a/tools/est-test/native-extra.js
+++ b/tools/est-test/native-extra.js
@@ -92,6 +92,7 @@ const EstExtra = (() => {
button("recalculate-original", "당초 단가 재계산") +
button("bid-rates", "낙찰률 적용") +
button("restore-bid", "당초단가(100%) 복원") +
+ button("reset-change", "설계변경 초기화") +
button("change-prev", "이전", state.changePage === 0 ? "disabled" : "") +
`${state.changePage + 1} / ${page.pages}쪽 · ${page.total}행` +
button("change-next", "다음", state.changePage + 1 >= page.pages ? "disabled" : ""),
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/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 `찾은 자료 ${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/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js
new file mode 100644
index 000000000..1a4c89b55
--- /dev/null
+++ b/tools/est-test/native-quantity-sheets.js
@@ -0,0 +1,52 @@
+// 원본 수량산출(TFRMMULLANG) 공종 단위 조작 — 공종복사하기·붙여넣기(mnuCopy·mnuPaste)와 순서 이동.
+// 복사본은 현재 산출서 바로 아래에 넣고, 내역 이중 반영을 막기 위해 내역 연결을 비운다.
+const EstQuantitySheets = (() => {
+ function copy(project, code, uid) {
+ const sheets = project.quantitySheets || [],
+ index = sheets.findIndex((sheet) => sheet.Code === code);
+ if (index < 0) throw Error("복사할 수량산출서를 고르세요.");
+ const used = new Set(sheets.map((sheet) => sheet.Code));
+ let next = `Q${uid}`;
+ for (let n = 2; used.has(next); n++) next = `Q${uid}_${n}`;
+ const sheet = structuredClone(sheets[index]);
+ sheet.Code = next;
+ sheet.Name = `${sheets[index].Name || sheets[index].Code} 복사본`;
+ for (const item of sheet.items || []) {
+ item.TargetCode = "";
+ item.TargetKey = "";
+ delete item.LinkCode;
+ delete item.LinkIndex;
+ }
+ sheets.splice(index + 1, 0, sheet);
+ return next;
+ }
+
+ function move(project, code, step) {
+ const sheets = project.quantitySheets || [],
+ index = sheets.findIndex((sheet) => sheet.Code === code),
+ next = index + step;
+ if (index < 0 || next < 0 || next >= sheets.length) return false;
+ [sheets[index], sheets[next]] = [sheets[next], sheets[index]];
+ return true;
+ }
+
+ function handle(name, state) {
+ const code = state.quantitySheet;
+ if (name === "quantity-sheet-copy") {
+ EstApp.change((project) => {
+ state.quantitySheet = copy(project, code, EstApp.uid());
+ }, false);
+ state.notice = "산출서를 복사했습니다. 복사본의 내역 연결은 비워 두었습니다.";
+ } else if (name === "quantity-sheet-up" || name === "quantity-sheet-down") {
+ const step = name === "quantity-sheet-up" ? -1 : 1,
+ sheets = state.project.quantitySheets || [],
+ index = sheets.findIndex((sheet) => sheet.Code === code);
+ if (index < 0 || index + step < 0 || index + step >= sheets.length)
+ throw Error("더 옮길 수 없습니다.");
+ EstApp.change((project) => move(project, code, step), false);
+ } else return false;
+ return true;
+ }
+
+ return { copy, move, handle };
+})();
diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs
index ea4c358a7..f9ec51cb3 100644
--- a/tools/est-test/native-quantity.cjs
+++ b/tools/est-test/native-quantity.cjs
@@ -315,13 +315,64 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
- `${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
${sheet.rows.map((row, index) => `| ${index + 1} | | |
`).join("")}
`;
+ `${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
${sheet.rows.map((row, index) => `| ${index + 1} | | |
`).join("")}
`;
+ }
+ // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
+ function resultFormat(code) {
+ const match = String(code ?? "").match(/^([LrG]\d{6})(?:([123])(\d))?$/);
+ return match
+ ? {
+ base: match[1],
+ method: Number(match[2] || 0),
+ digits: match[3] ? Number(match[3]) : null,
+ }
+ : null;
+ }
+ function setResultFormat(sheet, index, method, digits) {
+ const item = sheet.items[index],
+ parsed = resultFormat(item?.Code);
+ if (!parsed) throw Error("이 수량 항목은 결과 자리수를 지정할 수 없는 코드입니다.");
+ if (![0, 1, 2, 3].includes(method)) throw Error("끝수 처리 방식을 고르세요.");
+ if (method && !(Number.isInteger(digits) && digits >= 0 && digits <= 5))
+ throw Error("자리수는 0~5만 지정할 수 있습니다.");
+ const old = item.Code,
+ code = method ? `${parsed.base}${method}${digits}` : parsed.base;
+ if (code === old) return code;
+ if (
+ sheet.items.some((other, i) => i !== index && other.Code === code) ||
+ sheet.rows.some((row) => (row.markers || []).includes(code))
+ )
+ throw Error(`같은 결과 코드가 이미 있습니다: ${code}`);
+ item.Code = code;
+ for (const row of sheet.rows)
+ if (row.markers) row.markers = row.markers.map((marker) => (marker === old ? code : marker));
+ return code;
+ }
+ function formatSelect(item, index) {
+ const parsed = resultFormat(item.Code);
+ if (!parsed) return "";
+ const current = parsed.method ? `${parsed.method}:${parsed.digits}` : "0";
+ const options = [["0", "AUTO(산출서 기본)"]];
+ for (const [method, label] of [
+ [2, "절사"],
+ [3, "올림"],
+ [1, "반올림"],
+ ])
+ for (let digits = 0; digits <= 5; digits++)
+ options.push([`${method}:${digits}`, `${label} ${digits}자리`]);
+ return ``;
}
function nextItemCode(sheet) {
- const codes = new Set([
- ...sheet.items.map((item) => item.Code),
- ...sheet.rows.flatMap((row) => row.markers || []),
- ]);
+ const codes = new Set(
+ [...sheet.items.map((item) => item.Code), ...sheet.rows.flatMap((row) => row.markers || [])]
+ .filter(Boolean)
+ .map((code) => resultFormat(code)?.base || code),
+ );
for (let index = 0; index < 1000000; index++) {
const code = `r${String(index).padStart(6, "0")}`;
if (!codes.has(code)) return code;
@@ -385,6 +436,17 @@ const EstQuantity = (() => {
(sheet.rows[Number(node.dataset.quantityLine)].LineData = EstText.encode(node.value)),
false,
);
+ if (node.dataset.quantityFormat !== undefined) {
+ const [method, digits] = node.value.split(":").map(Number);
+ try {
+ EstApp.change(() =>
+ setResultFormat(sheet, Number(node.dataset.quantityFormat), method, digits),
+ );
+ } catch (error) {
+ state.notice = error.message;
+ EstApp.render();
+ }
+ }
if (node.dataset.quantityTarget !== undefined)
EstApp.change(() => {
const item = sheet.items[Number(node.dataset.quantityTarget)];
@@ -613,6 +675,9 @@ const EstQuantity = (() => {
excelUpdates,
targetSelect,
newCode,
+ resultFormat,
+ nextItemCode,
+ setResultFormat,
selector,
render,
install,
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 60968a389..9458cdb04 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -57,6 +57,10 @@ const allowed = new Set([
"native-actions.js",
"native-business-actions.js",
"native-block-actions.js",
+ "native-pick.js",
+ "native-find.js",
+ "native-blank.js",
+ "native-quantity-sheets.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",