From 4549e28fa5c3554fc628314badbf90ed1d77e2a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:20:09 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(815-2):=20M01=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=ED=91=9C=20=E3=80=8C=EB=B3=84=EC=B9=AD=E3=80=8D=20=ED=95=9C=20?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EB=B3=84=EC=B9=AD=20=EB=8B=A4=EC=84=AF=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인력 · 기계 · 재료 자재 · 유가전력 표에 비고 앞 「별칭」 열 — 눌러 고침 · 콤마로 여러 개 · 빈 글이면 칸 없음 - 저장 길 = 그대로 `/api/m01/save`(서버가 앞뒤 빈칸 뗌 · 빈 글 = 칸 지움 · 그 줄만 다시 씀) - check_master — 요소 줄 열 한 벌 밖 덧칸 `별칭` 허용(빈 글 · 글 아님은 흠) - 마스터 기계 별칭 다섯(맞춤.json) → 기계 줄 15 개 `별칭` · 옛 마스터 장부 파일 걷음 - M01 로직 1,513 셈 지문 전후 같음 - 새 시험 test_815_2_alias_column.py Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Store_Make.py | 6 + M01_MasterData/M01_MasterData_UI_Rows.ts | 49 ++++--- resources/master_data/scripts/check_master.py | 11 +- resources/master_data/기계.json | 50 ++++--- resources/master_template/실무자료/맞춤.json | 124 ------------------ resources/tester/test_815_2_alias_column.py | 104 +++++++++++++++ 6 files changed, 179 insertions(+), 165 deletions(-) delete mode 100644 resources/master_template/실무자료/맞춤.json create mode 100644 resources/tester/test_815_2_alias_column.py 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() From 8e6246493f773778b5d319a81c433ee2a3982b91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:27:30 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(est-test):=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EC=97=86=EB=8D=98=20=EB=8F=99=EC=9E=91=204=EA=B0=9C=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레벨 일괄 증가·감소, 원가계산서 초기화, 설계변경 초기화 단추 추가 - 처리기마다 단추 유무를 보는 지킴 시험 추가 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_action_bindings.py | 25 ++++++++++++++++++++ tools/est-test/PLAN.md | 4 ++++ tools/est-test/index.html | 6 ++++- tools/est-test/native-extra.js | 1 + 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_action_bindings.py diff --git a/resources/tester/test_est_action_bindings.py b/resources/tester/test_est_action_bindings.py new file mode 100644 index 000000000..4b9f10277 --- /dev/null +++ b/resources/tester/test_est_action_bindings.py @@ -0,0 +1,25 @@ +"""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")} + 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/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index c3cf5b014..d772aa9f3 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -423,6 +423,10 @@ 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] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다. - 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index ab70a52af..1b966ed3f 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -112,6 +112,9 @@ > +
+
@@ -128,7 +131,8 @@
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" : ""), From d25183e89d962c5a200c561737a16ef52baeb5d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:36:34 +0900 Subject: [PATCH 3/8] =?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", From bd3d2586169dc7e59749d2250ddfe36458ac7ef9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:48:28 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(est-test):=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EB=81=9D=EC=88=98=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 수량목록 자리수 메뉴처럼 AUTO·절사·올림·반올림 0~5자리를 항목별로 지정 - 결과 코드와 산출 행 결과 표지를 함께 바꾸고 중복 코드 거부 - 새 항목 코드가 끝수 붙은 코드와 겹치던 오류 수정 - 끝수 변경 작업의 ESTX 저장은 명확한 문구로 거부 - 연결 내역코드 칸 선택 상자가 옆 칸을 덮던 배치 수정 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_quantity_format.py | 96 ++++++++++++++++++++ tools/est-test/PLAN.md | 3 + tools/est-test/README.md | 2 + tools/est-test/export-quantity.cjs | 10 ++ tools/est-test/native-quantity.cjs | 75 ++++++++++++++- 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_est_quantity_format.py 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/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index f5736b2cf..4fea1fa89 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -429,6 +429,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [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] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다. - 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index acc1c0e7a..4a3112584 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +수량산출 화면의 수량 항목마다 `끝수 처리`에서 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..0fe094451 100644 --- a/tools/est-test/export-quantity.cjs +++ b/tools/est-test/export-quantity.cjs @@ -438,6 +438,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/native-quantity.cjs b/tools/est-test/native-quantity.cjs index ea4c358a7..499c7fb57 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) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + } + // 원본 수량목록 자리수 메뉴(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, From fe59907f568a38a609c6b5a7776b5f0e88c1c925 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:48:54 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs(est-test):=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EB=8C=80=EC=A1=B0=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=99=80=20=EB=82=A8=EC=9D=80=20=ED=95=B5=EC=8B=AC=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/est-test/PLAN.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 4fea1fa89..9138ff8e8 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -432,6 +432,19 @@ 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 저장(원본 항목 코드 키 변경 — 인덱스 재생성 필요). + - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. + - [ ] 남은 핵심(실무 빈도순): + 1. 찾기 — 다음 찾기·한 건씩 바꾸기·전체 자료 통합검색(지금은 현재 표 걸러 보기만). + 2. 공내역 파일로 저장(단가 비운 사본). + 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄. + 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). + 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). + 6. 낙찰률 전체·범위·공종별 적용. + 7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리. + 8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집. + 9. 묶음 인쇄(설계서 한 벌). + 10. 표지·갑지·문서 편집 서식(공사명·위치·개요·금액 붙여넣기, 표). + - 도메인 값이 필요해 사용자 협의 뒤: 산업안전보건관리비 비교, 조달청 표준공사코드, 현재데이타 갱신(단가 DB), 품셈 열람. - [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다. - 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다. From 982ba07fea7da9af78943e2bc2d8afbd8e795f7c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:56:47 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(est-test):=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EB=8B=A4=EC=9D=8C=EC=B0=BE=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=ED=95=9C=20=EA=B1=B4=20=EB=B0=94=EA=BE=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 찾기 창처럼 처음부터/현재위치부터 다음찾기로 행 이동, 호표 자동 전환 - 단가산출 찾은 행 한 건 바꾸기 뒤 다음 위치로 이동 - 편집 메뉴 찾기와 Ctrl+F 로 열기 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_find.py | 78 ++++++++++++++ tools/est-test/PLAN.md | 5 +- tools/est-test/README.md | 2 + tools/est-test/index.html | 3 +- tools/est-test/native-actions.js | 2 +- tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-find.js | 121 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 8 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_est_find.py create mode 100644 tools/est-test/native-find.js 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", From fc3bab4ea7623fe0f13dac08d8b1c05f56a7e142 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 15:01:13 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(est-test):=20=EA=B3=B5=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 처리 근거대로 내역·일위대가 금액을 비우고 공람 방법에 따라 단가 또는 수량을 비움 - JSON 또는 원본 구조 ESTX 로 내려받기, 사본은 자동계산 꺼 빈칸 유지 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_blank.py | 77 ++++++++++++++++++++++ tools/est-test/PLAN.md | 3 +- tools/est-test/README.md | 2 + tools/est-test/index.html | 2 + tools/est-test/native-blank.js | 79 +++++++++++++++++++++++ tools/est-test/native-business-actions.js | 3 +- tools/est-test/server.cjs | 1 + 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_est_blank.py create mode 100644 tools/est-test/native-blank.js 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/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index eea4daade..fdec0543d 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -435,10 +435,11 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [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 유지 확인 후 샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. - 2. 공내역 파일로 저장(단가 비운 사본). 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄. 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). diff --git a/tools/est-test/README.md b/tools/est-test/README.md index b273285e3..dc4da4eb6 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다. + `편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다. 수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 9112f8474..a492dd035 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -39,6 +39,7 @@ + @@ -68,6 +69,7 @@ + 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 6be4570c1..2851273af 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -600,7 +600,8 @@ const EstBusinessActions = (() => { } else if ( EstBlockActions.handle(name, state) || (typeof EstPick !== "undefined" && EstPick.handle(name, state)) || - (typeof EstFind !== "undefined" && EstFind.handle(name, state)) + (typeof EstFind !== "undefined" && EstFind.handle(name, state)) || + (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index a206156f0..0c9c3eeaa 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -59,6 +59,7 @@ const allowed = new Set([ "native-block-actions.js", "native-pick.js", "native-find.js", + "native-blank.js", "native-issues.js", "native-attachments.js", "native-standard.js", From e7e59fc513415fc22a93d31121f0dc6ef3bee50a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 15:04:11 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat(est-test):=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EC=84=9C=20=EB=B3=B5=EC=82=AC=EC=99=80=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 산출서 아래에 복사본 추가, 내역 연결은 비워 이중 반영 방지 - 산출서 위로·아래로, 순서 변경 ESTX 저장은 명확한 문구로 거부 - 단추 지킴 시험이 cjs 화면 파일도 검사 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_action_bindings.py | 5 +- resources/tester/test_est_quantity_sheets.py | 74 ++++++++++++++++++++ tools/est-test/PLAN.md | 4 +- tools/est-test/README.md | 2 + tools/est-test/export-quantity.cjs | 2 + tools/est-test/index.html | 1 + tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-quantity-sheets.js | 52 ++++++++++++++ tools/est-test/native-quantity.cjs | 2 +- tools/est-test/server.cjs | 1 + 10 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_est_quantity_sheets.py create mode 100644 tools/est-test/native-quantity-sheets.js diff --git a/resources/tester/test_est_action_bindings.py b/resources/tester/test_est_action_bindings.py index 4b9f10277..044216b29 100644 --- a/resources/tester/test_est_action_bindings.py +++ b/resources/tester/test_est_action_bindings.py @@ -7,7 +7,10 @@ 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")} + 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(): 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 fdec0543d..cbee58afd 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -437,10 +437,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 통합검색(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방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄. + 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 제출용 전체 인쇄. 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). 6. 낙찰률 전체·범위·공종별 적용. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index dc4da4eb6..2e43e779a 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -26,6 +26,8 @@ `편집 → 찾기·다음찾기`(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 0fe094451..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(); diff --git a/tools/est-test/index.html b/tools/est-test/index.html index a492dd035..23b895460 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -40,6 +40,7 @@ + diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 2851273af..fc38404c7 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -601,7 +601,8 @@ const EstBusinessActions = (() => { 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 EstBlank !== "undefined" && EstBlank.handle(name, state)) || + (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { 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 499c7fb57..f9ec51cb3 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -315,7 +315,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수. function resultFormat(code) { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 0c9c3eeaa..9458cdb04 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -60,6 +60,7 @@ const allowed = new Set([ "native-pick.js", "native-find.js", "native-blank.js", + "native-quantity-sheets.js", "native-issues.js", "native-attachments.js", "native-standard.js",