diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py
index 6cb971e55..6afc85727 100644
--- a/M02_MasterTemplete/M02_Labor_Ref.py
+++ b/M02_MasterTemplete/M02_Labor_Ref.py
@@ -363,7 +363,9 @@ def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]:
raise _bad("새 키를 못 잡음 — 다시 누름", 409)
-def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> dict[str, Any]:
+def summary(
+ folder: Path, kind: str, key: str, expand: Expand | None = None, files: UseFiles | None = None
+) -> dict[str, Any]:
"""층 목록 한 줄에 붙일 것 — `{제목, 단위, 구분, 상세구분, 연결}`(51-12) · `expand` 면
1 단위 기준 계(`기준금액` · 51-4)."""
try:
@@ -392,8 +394,8 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) ->
except Exception: # noqa: BLE001 — 없는 로직 = 키만(셈 때 까닭)
pass
out["고친줄수"] = sum(len(doc.get(k) or ()) for k in ("덮개", "더한줄", "뺀줄"))
- # ponytail: SK 마다 모든 층 훑음(users) — 목록이 느려지면 층 훑기 한 번 셈으로
- out["쓰는곳수"] = len(users({"층": layer, "주인": owner, "키": key}))
+ # SK 마다 모든 층 훑음(users) — 파일 읽기는 판 기억(`_ids_of`)이라 처음 한 번만 무거움
+ out["쓰는곳수"] = len(users({"층": layer, "주인": owner, "키": key}, files))
return out
@@ -403,29 +405,58 @@ def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
from M02_MasterTemplete.M02_Labor_Expand import Expand
expand = Expand(m01.loaded()[1])
+ files = use_files() if any(row["종류"] == "sangeun" for row in rows) else None
for row in rows:
if row["종류"] in layers.LIBRARY_KINDS:
- row.update(summary(folder, row["종류"], row["이름"], expand))
+ row.update(summary(folder, row["종류"], row["이름"], expand, files))
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
-def users(ref: Ref) -> list[str]:
+UseFiles = list[tuple[str, str, Path]]
+
+
+def use_files() -> UseFiles:
+ """쓰는 곳을 찾을 파일 — (무엇, 어디, 파일) · 목록 한 번에 한 번 찾음(818-1)."""
+ # 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」
+ kinds = (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출"))
+ return [(what, at, path) for kind, what in kinds for at, path in layers.layer_files(kind)]
+
+
+def users(ref: Ref, files: UseFiles | None = None) -> list[str]:
"""`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 ·
프로젝트 뺌)."""
rid, out = ref_id(ref), []
- # 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」
- for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")):
- for at, path in layers.layer_files(kind):
- try:
- doc = json.loads(path.read_bytes())
- except (OSError, ValueError):
- continue
- if any(ref_id(one) == rid for one in refs_in(doc)):
- name = doc.get("일위대가번") or doc.get("이름") or path.stem
- out.append(f"{what} {at} 「{name}」")
+ for what, at, path in use_files() if files is None else files:
+ got = _ids_of(path)
+ if got and rid in got[1]:
+ out.append(f"{what} {at} 「{got[0]}」")
return out
+_IDS: dict[Path, tuple[tuple[int, int], tuple[str, frozenset[str]]]] = {}
+
+
+def _ids_of(path: Path) -> tuple[str, frozenset[str]] | None:
+ """파일 하나의 (이름, 참조 이름표들) — 판(고친 때 · 크기)이 같으면 기억한 것(818-1 · 로직 사본으로
+ SK 파일이 MB 단위라 목록 줄마다 모든 층을 다시 읽으면 분 단위)."""
+ try:
+ st = path.stat()
+ stamp = (st.st_mtime_ns, st.st_size)
+ if (hit := _IDS.get(path)) and hit[0] == stamp:
+ return hit[1]
+ doc = json.loads(path.read_bytes())
+ except (OSError, ValueError):
+ return None
+ name = (
+ (doc.get("일위대가번") or doc.get("이름") or path.stem)
+ if isinstance(doc, dict)
+ else path.stem
+ )
+ got = (str(name), frozenset(ref_id(one) for one in refs_in(doc)))
+ _IDS[path] = (stamp, got)
+ return got
+
+
def refuse_used(folder: Path, kind: str, key: str) -> None:
"""쓰는 곳이 있으면 409 + 이름(서고 종류만)."""
if kind not in PREFIX:
diff --git a/resources/tester/test_est_autolink.py b/resources/tester/test_est_autolink.py
new file mode 100644
index 000000000..971f0531f
--- /dev/null
+++ b/resources/tester/test_est_autolink.py
@@ -0,0 +1,63 @@
+"""EST 테스트 앱 데이타 자동연결(원본 TfrmDataAutoLink, 맞춤 10e0e58·행 처리 10d2a48)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def run(code: str):
+ script = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{}});
+ vm.runInContext(fs.readFileSync('tools/est-test/native-autolink.js', 'utf8'), ctx);
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
+
+
+def test_matching_rules_order_and_overwrite():
+ result = run(
+ """(() => {
+ const base = () => ({ tables: {
+ LaborcDat: [{ Code: 'L1', Name: '보통 인부', TC: 170000, NC: 170000 }],
+ StuffcDat: [{ Code: 'M1', Name: '레미콘', Size: '25-24-(150)', Unite: 'm3', TC: 90000, GC: 90000 },
+ { Code: 'M2', Name: '보통인부', TC: 1, GC: 1 }],
+ HeadTitleDat: [{ Code: 'U1', Name: '거푸집', Size: '합판', Unite: '㎡당', TC: 30000, NC: 20000, GC: 10000, SCheck: 3 }],
+ EstMainDat: [
+ { Name: '공종', Level: 2 },
+ { Name: '보통인부', Size: '일반', Unite: '인' },
+ { Name: '레미콘', Size: '25-24-150', Unite: '[m3]' },
+ { Name: '레미콘', Size: '25-24-150', Unite: 'ton' },
+ { Name: '거 푸 집', Size: '(합판)', Unite: '㎡' },
+ { Name: '일반', Size: '보통인부' },
+ { Name: '순공사비', LinkIndex: 54 },
+ { Name: '이미', LinkIndex: 5, Code: 'OLD' } ] } });
+ const p = base();
+ const n = EstAutoLink.run(p, 'EstMainDat', 'all', false);
+ const r = p.tables.EstMainDat;
+ const q = base(); q.tables.EstMainDat[7].Name = '레미콘'; q.tables.EstMainDat[7].Size = '25-24-150'; q.tables.EstMainDat[7].Unite = 'm3';
+ EstAutoLink.run(q, 'EstMainDat', '5', true);
+ const u = base(); const cut = EstAutoLink.run(u, 'EstMainDat', 'unlink', false);
+ return { n, rows: r.map((x) => [x.Code ?? null, x.LinkIndex ?? null]), unit: [r[2].GU, r[2].TU],
+ swap: [r[5].Name, r[5].Size], head: [r[4].NU, r[4].GU, r[4].SCheck], over: q.tables.EstMainDat[7].Code,
+ cut, allZero: u.tables.EstMainDat.every((x) => x.LinkIndex === 0) };
+ })()"""
+ )
+ assert result["n"] == 4
+ assert result["rows"] == [
+ [None, None],
+ ["L1", 6],
+ ["M1", 5],
+ [None, None],
+ ["U1", 103],
+ ["L1", 6],
+ [None, 54],
+ ["OLD", 5],
+ ]
+ assert result["unit"] == [90000, 90000]
+ assert result["swap"] == ["보통인부", "일반"]
+ assert result["head"] == [20000, 10000, 3]
+ assert result["over"] == "M1"
+ assert result["cut"] == 8 and result["allZero"] is True
diff --git a/resources/tester/test_est_defaults.py b/resources/tester/test_est_defaults.py
new file mode 100644
index 000000000..07f701039
--- /dev/null
+++ b/resources/tester/test_est_defaults.py
@@ -0,0 +1,24 @@
+"""EST 테스트 앱 기본환경 설정·목록에서 삭제(원본 TfrmOption btnSave·TfrmWorkFileList btnDelete)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_pick_keeps_only_environment_keys_and_store_kind(tmp_path):
+ script = """
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({});
+ vm.runInContext(fs.readFileSync('tools/est-test/native-defaults.js', 'utf8'), ctx);
+ const picked = vm.runInContext("EstDefaults.pick({ HeadDigits: 2, TotalFix: true, Mode: '설계변경', AutoCalc: false })", ctx);
+ const f = require(process.cwd() + '/tools/est-test/forms-store.cjs');
+ f.save(process.argv[1], { kind: 'options', name: '기본환경', data: picked });
+ process.stdout.write(JSON.stringify({ picked, stored: f.read(process.argv[1]).map((x) => [x.kind, x.name]) }));
+ """
+ out = json.loads(
+ subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8")
+ )
+ assert out["picked"] == {"HeadDigits": 2, "TotalFix": True}
+ assert out["stored"] == [["options", "기본환경"]]
diff --git a/resources/tester/test_est_docs.py b/resources/tester/test_est_docs.py
new file mode 100644
index 000000000..e31f03f4a
--- /dev/null
+++ b/resources/tester/test_est_docs.py
@@ -0,0 +1,99 @@
+"""EST 테스트 앱 갑지·표지·설명서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover.opt)."""
+
+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")
+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-docs.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.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_korean_amount_and_cost_lines():
+ result = run_js(
+ """(() => ({
+ k: [EstDocs.korean(15000000), EstDocs.korean(10), EstDocs.korean(100000001), EstDocs.korean(79603000), EstDocs.korean(0)],
+ one: EstDocs.costLine('도급액', 15000000, 1), two: EstDocs.costLine('도급액', 15000000, 2),
+ text: (() => { const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
+ const i = p.tables.Wonga.findIndex((r) => Number(r.LinkIndex) === 55); return EstDocs.costText(p, [i], 1); })(),
+ }))()"""
+ )
+ assert result["k"] == ["일천오백만", "일십", "일억일", "칠천구백육십만삼천", "영"]
+ assert result["one"] == "도급액 : 금 일천오백만원(₩15,000,000)"
+ assert result["two"] == "도급액 : 금15,000,000원(금일천오백만원)"
+ assert result["text"].endswith("원(₩79,603,000)")
+
+
+def test_preview_html_and_model_accepts_docs():
+ result = run_js(
+ """(() => {
+ const p = structuredClone(source);
+ p.docs = { gabgi: { Name: 'x', Position: '경기도', Gulga: ['과장', '계장'], Cost: '도급액 : 금 일원(₩1)', CompanyPos: 2 },
+ cover: { Name2: '부제', Date: '2026년도', CompanyName: '시청', CompanyPos: 0 }, explan: { text: '설명 본문' } };
+ const g = EstDocs.html(8, p), c = EstDocs.html(9, p), e = EstDocs.html(11, p), none = EstDocs.html(10, p);
+ EstModel.validate(p);
+ return { g: [g.includes('경기도'), g.includes('
과장 | '), g.includes(p.name), g.includes('text-align:right')],
+ c: [c.includes('2026년도'), c.includes('부제'), c.includes('text-align:left')], e: e.includes('설명 본문'), none };
+ })()"""
+ )
+ assert result == {
+ "g": [True, True, True, True],
+ "c": [True, True, True],
+ "e": True,
+ "none": "",
+ }
+
+
+def test_import_reads_documents_and_export_refuses_edits():
+ if not SAMPLE.exists():
+ pytest.skip("installed EST sample unavailable")
+ script = """
+ const fs = require('fs'), imp = require(process.cwd() + '/tools/est-test/import-estx.cjs');
+ const ex = require(process.cwd() + '/tools/est-test/export-estx.cjs');
+ const p = imp.importEstx(fs.readFileSync(process.argv[1]));
+ const same = Buffer.compare(ex.exportEstx(p), fs.readFileSync(process.argv[1])) === 0;
+ p.docs.gabgi.Position = '바뀐 위치';
+ let message = ''; try { ex.exportEstx(p); } catch (e) { message = e.message; }
+ process.stdout.write(JSON.stringify({ name: p.docs.gabgi.Name, gulga: p.docs.gabgi.Gulga, cover: p.docs.cover.Date, same, message }));
+ """
+ result = json.loads(
+ subprocess.check_output(["node", "-e", script, str(SAMPLE)], cwd=ROOT).decode("utf-8")
+ )
+ assert result["name"] == "연습공사"
+ assert result["gulga"] == ["과 장", "계 장", "심사자", "설계자"]
+ assert result["cover"] == "2012년도"
+ assert result["same"] is True
+ assert result["message"].startswith("갑지·표지·설명서 편집은 ESTX")
diff --git a/resources/tester/test_est_est_tools.py b/resources/tester/test_est_est_tools.py
new file mode 100644
index 000000000..b4b68eac9
--- /dev/null
+++ b/resources/tester/test_est_est_tools.py
@@ -0,0 +1,98 @@
+"""EST 테스트 앱 하단으로 집계·공종 다듬기·단가산출 들여쓰기·레벨 확인(원본 f01a90·0x113eb44·0xf0770c·0xd10840)."""
+
+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-est-tools.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.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_down_sum_inserts_nested_subtotals_and_engine_fills_them():
+ result = run_js(
+ """(() => {
+ const calc = (p) => EstNative.calculate(EstModel.validate(p));
+ const p = calc(structuredClone(source));
+ const total = p.calculation.total, direct = p.calculation.direct.TC;
+ const added = EstEstTools.downSum(p);
+ const again = EstEstTools.downSum(p);
+ const done = calc(p);
+ const rows = done.tables.EstMainDat;
+ const subs = rows.map((r, i) => [r, i]).filter(([r]) => Number(r.LinkIndex) === 53);
+ const first = subs[0][1];
+ let head = first - 1; while (head >= 0 && !(Number(rows[head].Level) > 0)) head--;
+ const expected = rows.slice(head + 1, first).filter((r) => !Number(r.Level)).reduce((n, r) => n + Number(r.TC || 0), 0);
+ const removed = EstEstTools.upSum(done);
+ return { added: added > 0, again, firstOk: Math.abs(Number(rows[first].TC) - expected) < 1 && expected > 0,
+ name: rows[first].Name, total: done.calculation.total === total, direct: done.calculation.direct.TC === direct,
+ removed: removed === added, errors: done.calculation.errors.length };
+ })()"""
+ )
+ assert result == {
+ "added": True,
+ "again": 0,
+ "firstOk": True,
+ "name": "소 계",
+ "total": True,
+ "direct": True,
+ "removed": True,
+ "errors": 0,
+ }
+
+
+def test_left_space_indent_and_level_tree():
+ result = run_js(
+ """(() => {
+ const p = { options: {}, tables: { EstMainDat: [
+ { Level: 3, Name: 'A' }, { Level: 2, Name: 'B' }, { Level: 0, Name: 'b1' }, { Level: 2, Name: 'C' },
+ { Level: 1, Name: 'D' }, { Level: 0, Name: 'd1' }, { Level: 3, Name: 'E' }, { LinkIndex: 54, Level: 0 } ],
+ CostMainDat: [
+ { Index: 1, Code: 'D1', Name: '터파기', Size: '토사' },
+ { LineData: ' 가 = \x123\x12' },
+ { LineData: ' 1', Level: 0 }, { LineData: ' ───── = \x120.5\x12' }, { LineData: ' 2' },
+ { Level: 2, LineData: ' 큰 공종' },
+ { SCheck: -1, LineData: '노무 \x1212,345\x12' } ] } };
+ EstEstTools.leftSpace(p, 2);
+ EstEstTools.costIndent(p, 4);
+ const tree = EstEstTools.levelTree(p, 'D1');
+ return { left: p.tables.EstMainDat.map((r) => r.LeftSpace), lines: p.tables.CostMainDat.map((r) => r.LineData),
+ tree: tree.title, items: tree.lines.map((l) => l.text.replace(/ {2,}/g, '_')) };
+ })()"""
+ )
+ assert result["left"] == [0, 2, 4, 2, 4, 6, 0, 0]
+ assert result["lines"][1] == " 가 = \x123\x12"
+ assert result["lines"][2:5] == [" 1", " ───── = \x120.5\x12", " 2"]
+ assert result["lines"][5] == " 큰 공종"
+ assert result["tree"] == "터파기 (토사)"
+ assert result["items"] == ["큰 공종_[2]", "노무비 : 12,345"]
diff --git a/resources/tester/test_est_estimate_print.py b/resources/tester/test_est_estimate_print.py
new file mode 100644
index 000000000..3062c54a1
--- /dev/null
+++ b/resources/tester/test_est_estimate_print.py
@@ -0,0 +1,70 @@
+"""EST 테스트 앱 견적서 작성(원본 TfrmEstiMatePrn — 레벨 행 선택·열·합계금액·쪽 나눔·.einf)."""
+
+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-docs.js",
+ "native-estimate-print.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.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_rows_total_pages_and_ini():
+ result = run_js(
+ """(() => {
+ const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
+ const all = EstEstimatePrint.rows(p, 0).length, est = p.tables.EstMainDat.length;
+ const lvl = EstEstimatePrint.rows(p, 2);
+ const okLevel = lvl.every((r) => Number(r.Level) >= 2 || [54, 55, 58].includes(Number(r.LinkIndex)));
+ const pages = EstEstimatePrint.pages(Array.from({ length: 70 }, (_, i) => i), 27, 36).map((x) => x.length);
+ const s = { ...EstEstimatePrint.settings(p), ComPany_Name: '(주)시험', Company_Show: false, F_PageCount: 20 };
+ const ini = EstEstimatePrint.toIni(s);
+ const back = EstEstimatePrint.fromIni(ini);
+ const html = EstEstimatePrint.html(p, { ...s, Level: 2 });
+ let bad = ''; try { EstEstimatePrint.pages([1], 0, 1); } catch (e) { bad = e.message; }
+ return { all: all === est, okLevel, some: lvl.length > 0 && lvl.length < est, pages,
+ total: EstEstimatePrint.totalText(EstEstimatePrint.total(p)),
+ ini: ini.includes('[EstiPrnCompanyInfo]') && ini.includes('ComPany_Name=(주)시험') && ini.includes('Company_Show=0'),
+ back: [back.ComPany_Name, back.Company_Show, back.F_PageCount, back.Project_Name === undefined],
+ html: [html.includes('견 적 서'), html.includes('아래와 같이 견적합니다'), html.includes('공
급'), html.includes('공급가액')],
+ bad };
+ })()"""
+ )
+ assert result["all"] is True and result["okLevel"] is True and result["some"] is True
+ assert result["pages"] == [27, 36, 7]
+ assert result["total"] == " 일금:구천칠백일십구만구천원정 (₩97,199,000원정)"
+ assert result["ini"] is True
+ assert result["back"] == ["(주)시험", False, 20, True]
+ assert result["html"] == [True, True, False, True]
+ assert result["bad"] == "쪽당 줄 수는 1 이상입니다."
diff --git a/resources/tester/test_est_mach_formula.py b/resources/tester/test_est_mach_formula.py
new file mode 100644
index 000000000..05ac0b433
--- /dev/null
+++ b/resources/tester/test_est_mach_formula.py
@@ -0,0 +1,48 @@
+"""EST 테스트 앱 기계경비 공식 모음(원본 TfrmMachGongSic·TfrmMachText·구공식변환 0xd90c60)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_old_file_parse_and_insert(tmp_path):
+ script = r"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({ TextDecoder, DataView, Uint8Array });
+ vm.runInContext(fs.readFileSync('tools/est-test/native-mach-formula.js', 'utf8'), ctx);
+ const rec = (head, a, b) => {
+ const r = Buffer.alloc(394); r.writeInt32LE(head, 5);
+ const ea = Buffer.from(a, 'latin1'), eb = Buffer.from(b, 'latin1');
+ r[0x16] = ea.length; ea.copy(r, 0x17); r[0x49] = eb.length; eb.copy(r, 0x4a); return r;
+ };
+ const file = Buffer.concat([rec(1, 'Back', 'hoe'), rec(0, 'Q = ', '1.0'), rec(0, 'E = 0.7', ''), rec(2, 'Dozer', ''), rec(0, 'f = 1', '')]);
+ ctx.bytes = new Uint8Array(file);
+ const parsed = vm.runInContext('EstMachFormula.parseOld(bytes)', ctx);
+ let bad = ''; try { vm.runInContext('EstMachFormula.parseOld(new Uint8Array(10))', ctx); } catch (e) { bad = e.message.slice(0, 6); }
+ ctx.p = { tables: { CostMainDat: [{ PkID: 3, Index: 1, Code: 'D1' }, { PkID: 4, LineData: '끝' }] } };
+ ctx.body = 'Q = 1\rE = 0.7\r';
+ const count = vm.runInContext('EstMachFormula.insertInto(p, 0, body)', ctx);
+ process.stdout.write(JSON.stringify({ parsed, bad, count, rows: ctx.p.tables.CostMainDat.map((r) => [r.PkID, r.LineData ?? null]) }));
+ """
+ out = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
+ assert out["parsed"] == [
+ {"name": "Backhoe", "body": "Q = 1.0\rE = 0.7\r", "index": 1},
+ {"name": "Dozer", "body": "f = 1\r", "index": 2},
+ ]
+ assert out["bad"] == "구공식 파일"
+ assert out["count"] == 2
+ assert out["rows"] == [[3, None], [5, "Q = 1"], [6, "E = 0.7"], [4, "끝"]]
+
+
+def test_store_accepts_machine_formula_kind(tmp_path):
+ script = """
+ const f = require(process.cwd() + '/tools/est-test/forms-store.cjs');
+ f.save(process.argv[1], { kind: 'machformula', name: '백호', data: { index: 1, body: 'Q = 1' } });
+ process.stdout.write(JSON.stringify(f.read(process.argv[1]).map((x) => [x.kind, x.name])));
+ """
+ out = json.loads(
+ subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8")
+ )
+ assert out == [["machformula", "백호"]]
diff --git a/resources/tester/test_est_print_batch.py b/resources/tester/test_est_print_batch.py
new file mode 100644
index 000000000..b49dddc08
--- /dev/null
+++ b/resources/tester/test_est_print_batch.py
@@ -0,0 +1,59 @@
+"""EST 테스트 앱 묶음 인쇄·간지(원본 TEstPrint.Print 0x10fef98 — Checked 서식 차례, 간지 = 가운데 제목)."""
+
+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-docs.js",
+ "native-forms.js",
+ "native-print-batch.js",
+]
+
+
+def test_checked_forms_in_order_with_gangi():
+ runner = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{ structuredClone, console, Intl }});
+ for (const f of {json.dumps(FILES)})
+ vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
+ ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
+ ctx.catalog = JSON.parse(fs.readFileSync('tools/est-test/print-catalog.json', 'utf8'));
+ process.stdout.write(JSON.stringify(vm.runInContext(`(() => {{
+ const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
+ const list = EstPrintBatch.forms(catalog, p);
+ const pick = list.map((f) => ({{ ...f, checked: [9, 8, 1].includes(Number(f.Index)) }}));
+ const out = EstPrintBatch.document(pick, p, {{ view: 'EstMainDat' }}, true);
+ const order = [...out.html.matchAll(/(?:|
)([^<]*)/g)].map((m) => m[1].split(' ')[0] + ':' + m[2]);
+ p.printBatch = {{ checked: [9], gangi: false }};
+ const saved = EstPrintBatch.forms(catalog, p).filter((f) => f.checked).map((f) => f.Index);
+ return {{ zero: list.some((f) => Number(f.Index) === 0), order, skipped: out.skipped, saved,
+ gangiOff: !EstPrintBatch.document(pick, p, {{}}, false).html.includes('class="gangi') }};
+ }})()`, ctx)));
+ """
+ result = json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+ assert result["zero"] is False
+ names = [x.split(":")[1] for x in result["order"]]
+ kinds = [x.split(":")[0] for x in result["order"]]
+ assert kinds[:2] == ["gangi", "form"] and names[0] == names[1]
+ assert len(result["order"]) + 2 * len(result["skipped"]) == 6
+ assert result["saved"] == [9]
+ assert result["gangiOff"] is True
diff --git a/resources/tester/test_est_quantity_sheets.py b/resources/tester/test_est_quantity_sheets.py
index 5984aa335..9b2b5082a 100644
--- a/resources/tester/test_est_quantity_sheets.py
+++ b/resources/tester/test_est_quantity_sheets.py
@@ -141,3 +141,16 @@ def test_estx_export_refuses_marker_edit():
str(MAIN),
)
assert result.startswith("산출 줄의 수량 지정·해제는 ESTX")
+
+
+def test_sheet_search_ignores_spaces_bars_and_brackets():
+ result = node("""
+ const sheet = { Code: 'N1', Name: '토 공(터파기)', items: [{ Name: '잔토|처리', Size: '0.7㎥' }] };
+ process.stdout.write(JSON.stringify([
+ run("EstQuantitySheets.matches(" + JSON.stringify(sheet) + ", '토공터파기')"),
+ run("EstQuantitySheets.matches(" + JSON.stringify(sheet) + ", '잔토 처리')"),
+ run("EstQuantitySheets.matches(" + JSON.stringify(sheet) + ", '포장')"),
+ run("EstQuantitySheets.matches(" + JSON.stringify(sheet) + ", '')"),
+ ]));
+ """)
+ assert result == [True, True, False, True]
diff --git a/resources/tester/test_est_quantity_total.py b/resources/tester/test_est_quantity_total.py
new file mode 100644
index 000000000..c9873a046
--- /dev/null
+++ b/resources/tester/test_est_quantity_total.py
@@ -0,0 +1,55 @@
+"""EST 테스트 앱 수량산출 누계(원본 ActionAdd Ctrl+U 0x11155c4, 재계산 때 F·a 표지 다시 계산)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+FILES = [
+ "native-math.js",
+ "native-formula.js",
+ "native-text.js",
+ "native-quantity-total.js",
+ "native-quantity.cjs",
+]
+SCRIPT = r"""(() => {
+ const sheet = { Code: 'N1', options: { digits: 2, roundMode: 2 }, items: [
+ { Code: 'r000001' }, { Code: 'r000002' }, { Code: 'r000003' }, { Code: 'r000004' }],
+ rows: [
+ { LineData: ' \x0e터파기\x0e 2×3 = \x126\x12', markers: ['r000001'] },
+ { LineData: ' 1.5×2 = \x123\x12', markers: ['r000002'] },
+ { LineData: ' \x16999\x16', markers: [], totals: ['F000001'] },
+ { LineData: ' 4×1 = \x124\x12', markers: ['r000003'] },
+ { LineData: ' 0.3333×1 = \x120.33\x12', markers: ['r000004'] },
+ { LineData: ' \x160\x16', markers: [], totals: ['F00000222'] } ] };
+ const out = EstQuantity.calculate(sheet);
+ const totals = out.rows.map((r) => (r.LineData.match(/\x16([^\x16]*)\x16/) || [])[1]).filter(Boolean);
+ sheet.rows[0].LineData = sheet.rows[0].LineData.replace('2×3', '200×30');
+ const big = EstQuantity.calculate(sheet).rows[2].LineData.match(/\x16([^\x16]*)\x16/)[1];
+ const fresh = { Code: 'N2', options: { digits: 1, roundMode: 1 }, items: [], rows: [{ LineData: ' 끝', markers: [] }] };
+ const name = EstQuantityTotal.insert(fresh, 0, 1);
+ let inside = ''; try { EstQuantityTotal.insert(fresh, 0, 4); } catch (e) { inside = e.message.slice(0, 5); }
+ const parsed = EstQuantity.fromWpt('N9', '시험',
+ '
7
', [], {});
+ return { totals, big, name, line: EstText.display(fresh.rows[0].LineData), edited: fresh.markerEdited,
+ inside, parsed: [parsed.rows[0]?.LineData, parsed.rows[0]?.totals], errors: out.errors };
+})()"""
+
+
+def test_total_is_subtotal_since_name_or_previous_total():
+ 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 }});
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(SCRIPT)}, ctx)));
+ """
+ result = json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+ assert result["errors"] == []
+ assert result["totals"] == ["9", "4.33"]
+ assert result["big"] == "6,003"
+ assert result["name"] == "F00000111"
+ assert result["line"] == " ⟦누계⟧0⟦누계⟧끝"
+ assert result["edited"] is True
+ assert result["inside"] == "누계 표지"
+ assert result["parsed"] == ["\x167\x16", ["F00000312"]]
diff --git a/resources/tester/test_est_review_auto.py b/resources/tester/test_est_review_auto.py
new file mode 100644
index 000000000..fb902261e
--- /dev/null
+++ b/resources/tester/test_est_review_auto.py
@@ -0,0 +1,42 @@
+"""EST 테스트 앱 계약심사 자동입력(원본 TfrmInvest 심사평가결과·주요심사내용)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_result_and_content_text():
+ script = """
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({});
+ vm.runInContext(fs.readFileSync('tools/est-test/native-review-auto.js', 'utf8'), ctx);
+ ctx.p = { tables: {
+ Wonga: [{ Name: '도 급 액', Cost: 1 }, { Name: '총 공 사 비', Cost_: 1000000, Cost: 876543.21 }],
+ StuffcDat: [{ Name: '레 미 콘', Size: '25-24|150', NewItem: true }, { Name: 'x' }, { Name: 'y', NewItem: true }, { NewItem: true }],
+ LaborcDat: [{ Name: '보통 인부', Size: '일반', NewItem: true }],
+ HeadTitleDat: [{ Name: '거푸집', Size: '합판' }] } };
+ const r = vm.runInContext('EstReviewAuto.result(p)', ctx);
+ ctx.q = { tables: { Wonga: [{ Name: '총공사비', Cost_: 100, Cost: 150 }] } };
+ const up = vm.runInContext('EstReviewAuto.result(q)', ctx);
+ const none = vm.runInContext('EstReviewAuto.result({ tables: { Wonga: [] } })', ctx);
+ const c = vm.runInContext('EstReviewAuto.content(p)', ctx);
+ process.stdout.write(JSON.stringify({ r, up, none, c }));
+ """
+ out = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
+ assert out["r"].split("\n") == [
+ f"o 설계금액 : {'1,000,000':>16} 원",
+ f"o 심사금액 : {'876,543.21':>16} 원",
+ f"o 절 감 액 : {'-123,456.79':>16} 원",
+ f"o 절 감 율 : {'-12.34':>16} %",
+ ]
+ assert out["up"].split("\n")[2:] == [
+ f"o 절 감 액 : {'+50':>16} 원",
+ f"o 절 감 율 : {'+50':>16} %",
+ ]
+ assert out["none"] is None
+ assert out["c"].split("\n") == [
+ "노임단가 조정 : 보통인부 외1 종",
+ "자재단가 조정 : 레미콘(25-24150) 외3 종",
+ ]
diff --git a/resources/tester/test_est_wonga_tools.py b/resources/tester/test_est_wonga_tools.py
new file mode 100644
index 000000000..39f133e46
--- /dev/null
+++ b/resources/tester/test_est_wonga_tools.py
@@ -0,0 +1,89 @@
+"""EST 테스트 앱 원가 보조 계산(원본 TfrmWongaDiget 기타경비 계산·산업안전보건관리비 비교)."""
+
+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-wonga-tools.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.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_anjun_reproduces_original_formula_pair_in_sample():
+ result = run_js(
+ """(() => {
+ const calc = (p) => EstNative.calculate(EstModel.validate(p));
+ const p = calc(structuredClone(source));
+ const plain = (t) => String(t).replace(/\s+/g, '');
+ const row = p.tables.Wonga.find((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) !== 2);
+ const original = [row.VSusic, p.tables.Wonga.find((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) === 2).VSusic];
+ const before = p.tables.Wonga.length;
+ const out = EstWongaTools.anjun(p, { youl: 3.15, base: 5349000, addBase: false, compare: true });
+ const after = calc(p);
+ return { base: EstWongaTools.anjunBase(p.tables.Wonga), original, chosen: out.chosen, other: out.other,
+ rows: p.tables.Wonga.length === before, errors: after.calculation.errors.length };
+ })()"""
+ )
+ assert result["base"] == "(A + 4)"
+ assert result["chosen"] == "(A + 4) × 0.0315 × 1.2"
+ assert result["other"] == "((A + 4) + J/1.1) × 0.0315"
+ assert [x.replace(" ", "") for x in result["original"]] == [
+ result["chosen"].replace(" ", ""),
+ result["other"].replace(" ", ""),
+ ]
+ assert result["rows"] is True and result["errors"] == 0
+
+
+def test_gita_picks_smaller_base_and_validates():
+ result = run_js(
+ """(() => {
+ const p = { options: {}, tables: { Wonga: [
+ { Gubun: 'A', Name: '직 접 공 사 비', Cost: 1000 }, { Gubun: 'B', Name: '직접노무비', Cost: 300 },
+ { Gubun: 'C', Name: '간접노무비', Cost: 50 }, { Gubun: 'D', Name: '기 타 경 비', Youl: '5%' } ] } };
+ const out = EstWongaTools.gita(p, { gcRatio: 0.4, baseRatio: 0.6, youl: 5.2, compare: true });
+ const row = p.tables.Wonga[3], cmp = p.tables.Wonga[4];
+ const errors = [];
+ for (const bad of [{ gcRatio: 2, baseRatio: 0.5, youl: 1 }, { gcRatio: 0.5, baseRatio: 0.5, youl: 11 }])
+ try { EstWongaTools.gita(p, bad); } catch (e) { errors.push(e.message.slice(0, 4)); }
+ let none = ''; try { EstWongaTools.anjun({ options: {}, tables: { Wonga: [] } }, { youl: 1 }); } catch (e) { none = e.message; }
+ return { values: out.values, vs: row.VSusic, youl: row.Youl, cmp: [cmp.VSusic, cmp.CalcIndex], errors, none,
+ stored: [p.options.GitaGCRatio, p.options.GitaBaseRatio] };
+ })()"""
+ )
+ assert result["values"] == [750, 600]
+ assert result["vs"] == "(A × 0.6) × 0.052"
+ assert result["youl"] == "5.2%"
+ assert result["cmp"] == ["((A × 0.4) + B + C) × 0.052", 2]
+ assert result["errors"] == ["1 이하", "기타경비"]
+ assert result["none"] == "산업안전보건관리비 비목이 없습니다."
+ assert result["stored"] == [0.4, 0.6]
diff --git a/resources/tester/test_est_xml_check.py b/resources/tester/test_est_xml_check.py
new file mode 100644
index 000000000..42639ba64
--- /dev/null
+++ b/resources/tester/test_est_xml_check.py
@@ -0,0 +1,42 @@
+"""EST 테스트 앱 조달청 XML 검사·자리수자동수정(원본 TfrmJDLConvert 검사 1·8·10·11, TfrmXMLDigetCheck)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_rules_and_fix():
+ script = """
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({ structuredClone });
+ for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-xml-check.js'])
+ vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx);
+ ctx.p = { tables: {
+ CostMainDat: [
+ { Index: 1, Code: 'D1', Name: '터파기' },
+ { SCheck: -1, Diget: 2, LineData: '노무' },
+ { SCheck: 0, LinkIndex: 5, Diget: 1, LineData: '플래그 전 재료' },
+ { Index: -20 },
+ { LinkIndex: 6, DigetIndex: 1, LineData: '노임 연결' },
+ { LinkIndex: 61 },
+ { LinkIndex: 5, Diget: 3, LineData: '소계 뒤 재료' } ],
+ StuffcDat: [{ Code: 'M1', Index: 1, Name: '가', Size: '규' }, { Code: 'M1', Index: 2, Name: '나' }, { Code: 'm2', Index: 3 }],
+ MachTitleDat: [{ Code: 'E0000000233' }, { Code: 'E1234567890123456' }] } };
+ const list = vm.runInContext('EstXmlCheck.check(p)', ctx).map((e) => [e.kind, e.table, e.index, e.text]);
+ const fixed = vm.runInContext('EstXmlCheck.fix(p)', ctx);
+ const after = vm.runInContext('EstXmlCheck.check(p).filter((e) => e.kind === 1).length', ctx);
+ const kept = vm.runInContext('[p.tables.CostMainDat[2].Diget, p.tables.CostMainDat[6].Diget, p.tables.CostMainDat[1].Diget]', ctx);
+ process.stdout.write(JSON.stringify({ list, fixed, after, kept }));
+ """
+ result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
+ assert result["list"] == [
+ [1, "CostMainDat", 1, "노무"],
+ [1, "CostMainDat", 4, "노임 연결"],
+ [8, "StuffcDat", 1, "1.가(규) 와 코드 중복"],
+ [10, "StuffcDat", 2, "코드에 소문자사용"],
+ [11, "MachTitleDat", 0, "장비코드 길이가틀림(17자리)"],
+ ]
+ assert result["fixed"] == 2 and result["after"] == 0
+ assert result["kept"] == [1, 3, 0]
diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py
index 7c14351ce..ee77ac30f 100644
--- a/resources/tester/test_m02_labor_ref.py
+++ b/resources/tester/test_m02_labor_ref.py
@@ -319,6 +319,17 @@ def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world):
assert not (world["system"] / "precombo" / f"{sc}.json").exists()
+def test_쓰는_곳_기억은_파일을_고치면_다시_읽음_818_1(world):
+ sc = _combo(world["system"], [{"로직": TEN}])
+ ref = {**SYS, "키": sc}
+ with store.at(world["personal"]):
+ store.write("labor", "x1", "", {"열": "x1", "담은로직": [{"참조": ref}], "자재": []})
+ assert len(refs.users(ref)) == 1 and refs.users(ref) == refs.users(ref, refs.use_files())
+ ver = store.read("labor", "x1")["판"]
+ store.write("labor", "x1", ver, {"열": "x1", "담은로직": [], "자재": []})
+ assert refs.users(ref) == []
+
+
def test_복사는_못_읽는_참조를_같이_복사_본뜬(world):
inner = _combo(world["personal"], [{"로직": TEN}], "안")
sys_sp = _promote(world["system"])
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index e04bbc76f..0c48226fe 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -473,13 +473,42 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 1단계 읽기 — `xls-read.cjs`(복합문서·SST/CONTINUE·LABELSST·NUMBER·RK·MULRK·LABEL·BOOLERR·FORMULA 저장값), 가져올 때 `sheet.excel`, 수량산출 화면 읽기 전용 격자(수식 셀 굵게).
- 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구.
- 남음: 셀 편집·수식 계산·4방향 합계·품명/규격/수량 지정(항목과 셀 대응 근거 미확인), .xls 저장.
+ - [x] 수량산출 누계(원본 TfrmMulWord ActionAdd Ctrl+U → 0x11155c4, 재계산 0x111c0b0 — 디스어셈블 근거) — 원본 F·a 표지를 `` 쌍·`row.totals` 로 읽고, 위쪽 품명 줄(포함)/앞 누계(제외)부터 줄마다 첫 결과 합을 표지 반올림(F+6자리+방식+자리)으로 매 계산 갱신, 커서 자리 누계 넣기. 원본 TFRMMULNAMEINPUT 「누계계산」 단추는 실제로 지정해제와 같은 처리라 따로 만들지 않음.
+ - 검증완료: `test_est_quantity_total.py`(소계 범위·앞 누계 제외·반올림 표지·값 갱신 6,003·넣기 이름·표지 안 거부·원본 F 표지 읽기) 통과, 수량 시험 포함 26개 통과. ORCA 브라우저에서 「소계」 줄 끝에 누계 → ⟦누계⟧9⟦누계⟧(6+3), 샘플 복구.
+ - [x] 갑지·표지·공사설명서·시방서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover/DatExplean/DatSiBang.opt — 디스어셈블 근거) — `midas-read.cjs` MIDAS 레코드·WPTools 글자 읽기 → `project.docs`, 편집 창, 공사명↔공사 이름 동기화, 공사금액 자동입력 두 방식·한글금액(일 생략 없음), 설명서·시방서 갑지 붙여넣기, 인쇄 미리보기 8·9·10·11. 문서 편집 작업은 ESTX 저장 거부.
+ - 검증완료: `test_est_docs.py` 3개(한글금액·금액 줄·원가 도급액, 미리보기·모델 통과, 설치 sample.estx 갑지 「연습공사」·결재란·표지 2012년도·원본 재저장 동일·편집 시 ESTX 거부) 통과. ORCA 브라우저에서 갑지 편집 → 공사금액 「금 칠천구백육십만삼천원(₩79,603,000)」, 인쇄 미리보기에 결재란·위치·공사명·개요·금액·발주처 표시(단일 표 쪽 나누기에서 갑지 제외 수정), 샘플 복구.
+ - 남음: 서식·여백·글꼴·표, 양식목록(문서), 공사금액 자동변경, 작업모드 2·4의 C 판.
+ - [x] 기타경비 계산·산업안전보건관리비 비교(원본 TfrmWongaDiget 모드 4·1, 0xd0bc80·0xd0b298 — 디스어셈블 근거, 내장 요율표 없음) — 작은 기초금액 식 선택·요율 입력·비교 줄(CalcIndex 2), 대상액(직접공사비 또는 재료비 소계+비과세+직접노무비)·관급/1.1·1.2배·기초금액(기본 5,349,000 원본 상수).
+ - 검증완료: `test_est_wonga_tools.py` 2개(샘플에서 원본이 적어 둔 식 `(A + 4) × 0.0315 × 1.2`·비교 `((A + 4) + J/1.1) × 0.0315` 그대로 재현·오류 0, 기타경비 작은 쪽·비교 줄·잘못된 입력 거부) 통과. ORCA 브라우저에서 안전관리비 비교 → 같은 식·도급액 유지, 기타경비는 직접공사비 행 없는 샘플에서 원본처럼 거부, 샘플 복구.
+ - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
+ - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
+ - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
+ - [ ] 원가 행 자리수(Diget·DigetIndex) 반영 — 웹 원가 엔진은 행 자리수를 계산에 쓰지 않음(선택항목 자리수 조정은 값만 기록). 설치 자료 중 sample.estx 만 Diget 사용(도급액 3·산업안전 3, DigetIndex 0)이고 산업안전 1,660,273 처럼 천단위가 아닌 값이 있어 DigetIndex 0 의 뜻(사사오입/없음) 확정 불가 — 근거 확보 전 엔진 변경 보류. 기성 원가계산서 편집(절하·한줄 삽입 등)도 같은 근거가 필요해 보류.
+ - [x] 견적서 작성(원본 mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994·그리기 0x10c4768 — 디스어셈블 근거) — 공사별 설정(`project.estimatePrint`), 원본 `.einf` INI 세 구역 저장·두 구역 불러오기, 레벨 행 선택, 7열, 1쪽 머리·공급자·합계금액(아래에서 위로 Checked 금액, 한글), 첫/나머지 쪽 줄수, 미리보기·인쇄. 수탁공사변환·XML 역변환 비교는 원본에서 숨긴 개발용 메뉴라 범위 밖.
+ - 검증완료: `test_est_estimate_print.py`(전체/레벨 2 행, 쪽 27·36·7, 합계 「일금:구천칠백일십구만구천원정 (₩97,199,000원정)」, INI 왕복, 미리보기 글) 통과. ORCA 브라우저에서 상호·레벨 2 → 2쪽·28줄·합계·공급자 표, 공사 설정 저장 확인 후 샘플 복구.
+ - 남음: 견적서 Excel 변환, 글꼴·크기·여백 세부값.
+ - [x] 조달청 XML 검사·자리수자동수정(원본 TfrmJDLConvert 0xe7d4bc 검사 1·8·10·11, TfrmXMLDigetCheck btnOk 0xcc848c — 디스어셈블 근거) — 비목값·−20~소계 사이 재료/노임 연결 줄 자리수, 7개 표 코드 중복·소문자, 기계 코드 17자리, 수정은 오류 1 줄 Diget·DigetIndex 0 후 재계산. 원본은 오류가 있으면 변환을 멈추나 웹은 기존 내보내기를 막지 않고 검사 창으로 알림(샘플 기계 코드가 11자리라 모두 막히는 것을 피함).
+ - 검증완료: `test_est_xml_check.py`(규칙 1의 −20/61 구간·SCheck, 중복 문구, 소문자, 17자리, 수정 대상만 풀림) 통과. ORCA 브라우저에서 샘플 검사 → 자리수 0·중복 0·소문자 0·장비코드 8건.
+ - [x] 계약심사 자동입력(원본 TfrmInvest 심사평가결과·주요심사내용 0x10bfc98 — 디스어셈블 근거) — 총공사비 행 Cost_/Cost, 절감율 Trunc(×10000)×0.01, cp949 16바이트 맞춤·증액 「+」, 7개 표 NewItem 「○○ 조정 : 이름(규격) 외N 종」(N 원본 셈). 심사 사본에 설계 원가 Cost_ 보존.
+ - 검증완료: `test_est_review_auto.py`(네 줄 글·증액 부호·총공사비 없음·주요심사 두 줄) 통과, 계약심사 관련 시험 44개 통과. ORCA 브라우저에서 수량 0.9배 심사 → 사본 → 자동입력 「설계 97,199,324 · 심사 96,952,167 · 절감 −247,157 · −0.25 %」, 샘플 복구.
+ - 남음: 위치·사업개요·사업기간·기타사항 칸(Invest.Dat 10개 RTF).
+ - [x] 데이타 자동연결(원본 ActionAutoLink 0x10d54f4·TfrmDataAutoLink 0x10d3008·10d2a48·맞춤 10e0e58 — 디스어셈블 근거) — 9개 선택·Over Write, 정규화·맞춤 단계·노임 맞바꿈·기계 주연료·건너뛰기 규칙·쓰는 값 원본대로, 전체 순서 노임→자재→견적→표준→기계(내역·일위만)→단가산출→일위대가.
+ - 검증완료: `test_est_autolink.py`(노임 품명만·규격 재시도 맞바꿈·단위 정규화·일위 비목 단가·SCheck 3·Over Write·이미 연결 건너뜀·전체 끊기) 통과. ORCA 브라우저에서 샘플 전체자동(Over Write) → 7행 연결·순공사비 45,293,066 그대로, 되돌리기 원상.
+ - 남음: 전역변수(`` 표지 — 설치 자료 사용 0건, 단가산출 엔진 깊은 변경이 필요해 보류).
+ - [x] 하단/상단으로 집계·공종 다듬기·단가산출 들여쓰기·레벨 확인(원본 f01a90·f018d0·f01750, 0x113eb44, 0xf0770c, 0xd10840 — 디스어셈블 근거) — 「소 계」(53, Level −10) 넣기 규칙, 내역 엔진에 53 행 합(닫는 공종 안쪽부터, 상세 행) 추가, LeftSpace 계산·이름 칸 들여 보이기, 들여쓰기 n 칸·분수 세 줄 함께, 레벨 나무(60−Level 칸 [레벨]·비목 소계). 다시 집계 시 겹침 방지는 웹 보완.
+ - 검증완료: `test_est_est_tools.py` 2개(소계 넣기·재실행 0·첫 소계 = 공종 합·도급액/순공사비 불변·상단 집계로 원상, 들여쓰기·LeftSpace·레벨 나무) 통과, 대조·업무·설계변경·연차·ESTX 저장 시험 111개 통과(엔진 변경 회귀). ORCA 브라우저에서 하단 집계 11줄(토공 2,382,484 등 공종 합과 같음)·도급액 유지, 공종 다듬기 59칸 들여쓰기, 레벨 나무 표시, 샘플 복구.
+ - [x] 묶음 인쇄·간지(원본 TEstPrint.Print 0x10fef98, TFRMESTPRINT2·TFRMPRINTLISTCHECK·TFRMESTPRINTPRIVATE — 디스어셈블 근거) — 인쇄표 차례·Checked 서식, 간지(Name2 가운데·겹테두리), 한 문서 이어 인쇄, 간지 쪽 번호 숨김, 공사별 선택 저장(`project.printBatch`). 원본은 서식마다 따로 인쇄 작업·간지는 쪽 번호를 소모하지 않음 — 웹은 브라우저 쪽 번호라 간지도 쪽 수에 들어감(번호만 숨김).
+ - 검증완료: `test_est_print_batch.py`(공통 행 제외·간지→서식 차례·간지 끔·선택 저장) 통과. ORCA 브라우저에서 인쇄표 40개 목록·4개 선택 → 간지 4·서식 4·표 3, 샘플 복구.
+ - 남음: 설정값 일괄적용(간지 글꼴·여백 13항목), 번호자동매김·시작 쪽, 끌어서 차례 바꾸기.
+ - [x] 기계경비 공식 모음(원본 TfrmMachGongSic 0xf146f0·MachText.Dat, TfrmMachText 0xd3d58c, 구공식변환 0xd90c60 — 디스어셈블 근거) — 서버 양식 보관함 종류 machformula, 삽입·추가·삭제·번호 1..N·저장, 단가산출 현재 행 아래로 본문 줄 넣기, 옛 394바이트 레코드 변환. 원본이 기계경비 연결 때 덧붙이는 요약 4줄은 형식 확인 전이라 빼둠.
+ - 검증완료: `test_est_mach_formula.py` 2개(구공식 두 공식·본문 CR·크기 오류, 줄 넣기 PkID·위치, 보관함 종류) 통과. ORCA 브라우저에서 공식 추가·저장 → 보관함 등록 → 단가산출 호표 아래 2줄 넣기 → 보관함 정리·샘플 복구.
+ - [x] 기본환경으로 설정·목록에서 삭제(원본 TfrmOption btnSave, TfrmWorkFileList btnDelete) — 계산 환경 14개 키를 서버 보관함(종류 options)에 저장해 새 내역서에 적용, 저장 작업은 data/removed 로 옮겨 목록에서만 뺌(지우지 않음).
+ - 검증완료: `test_est_defaults.py`(환경 키만 고름·보관함 종류) 통과. ORCA 브라우저에서 내역 소수 3자리 → 기본환경 → 새 내역서에 3 적용, 시험 작업 저장 → 목록에서 삭제 → 목록에서 빠지고 removed 로 이동 확인 후 시험 파일·기본환경 정리.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
- 9. 묶음 인쇄(설계서 한 벌).
- 10. 표지·갑지·문서 편집 서식(공사명·위치·개요·금액 붙여넣기, 표).
- 도메인 값이 필요해 사용자 협의 뒤: 산업안전보건관리비 비교, 조달청 표준공사코드, 현재데이타 갱신(단가 DB), 품셈 열람.
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 8e5978469..5ce2092b4 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -22,14 +22,34 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
+`원가계산서 → 기본환경으로 설정`은 현재 자릿수·도급액 고정 등 계산 환경을 서버에 기본값으로 저장하고, `새 내역서`를 만들 때 적용합니다. `저장한 작업` 목록의 `목록에서 삭제`는 원본 최근공사목록처럼 목록에서만 빼며 파일은 `data/removed/`로 옮겨 남깁니다.
+
+`편집 → 기계경비 공식`은 원본 기계경비 공식 창처럼 명칭과 공식 본문을 모아 두는 목록(공사와 무관하게 이 앱 서버에 저장)을 편집합니다. 단가산출 화면에서 행을 고르고 `단가산출에 불러오기`를 누르면 공식 본문을 줄마다 그 아래에 넣습니다(계산식으로 풀지 않음). `구공식변환`으로 옛 EST Plus `mach_Text.dat`(394바이트 레코드)를 읽어 덧붙입니다.
+
+`편집 → 하단으로 집계`는 원본처럼 공종이 끝나는 자리(다음 공종 머리·순공사비 앞·마지막)에 「소 계」 줄을 넣고, 계산할 때 그 공종 상세 행의 합을 채웁니다. 겹쳐 넣지 않으며 `상단으로 집계`는 소계 줄을 지웁니다. `공종 다듬기`는 레벨마다 공종명을 들여 보입니다. `단가산출 들여쓰기`는 산출근거 줄 앞 빈칸을 같은 칸 수로 맞추고(분수 세 줄은 함께 옮김), `단가산출 레벨 확인`은 현재 호표의 레벨 줄과 비목 소계를 계층으로 보입니다.
+
+`편집 → 데이타 자동연결`은 원본처럼 내역서·일위대가·기계경비 행을 현재 공사의 노임·자재·견적·표준·기계경비·단가산출·일위대가 표와 품명(·규격·단위)으로 맞춰 코드·단가·연결종류를 적습니다. 빈칸·|·괄호를 무시하고 단위의 「당」·[ ]를 지우며, 노임은 품명만 맞추고 못 찾으면 규격으로 찾아 품명·규격을 맞바꿉니다. `Over Write`를 끄면 이미 연결된 행은 건너뜁니다. `전체연결 끊기`는 원본처럼 모든 행의 연결종류를 0으로 합니다.
+
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
+계약심사 화면의 `심사평가결과 자동입력`은 원본처럼 원가계산서의 총공사비 행에서 설계(심사 전)·심사 금액, 절감액, 절감율(소수 둘째 자리 버림)을 적고, `주요심사내용 자동입력`은 노임·자재·표준시장·견적·기계경비·단가산출·일위대가 중 새로 바뀐 자료를 「○○ 조정 : 이름(규격) 외N 종」으로 적습니다. 심사 내역 사본은 설계 원가 금액을 함께 남깁니다.
+
+`파일 → 조달청 XML 검사·자리수 자동 수정`은 원본 XML 변환 전 검사처럼 단가산출 비목 값·재료/노임 연결 줄의 행별 자리수 지정, 단가 표 코드 중복·소문자, 기계경비 코드 17자리 아님을 목록으로 보입니다. `자리수 자동 수정`은 첫 항목의 자리수 지정만 풀고 다시 계산합니다. 기존 XML 내보내기는 막지 않습니다.
+
+`파일 → 묶음 인쇄`는 원본처럼 인쇄표 차례대로 고른 서식을 한 번에 이어 인쇄합니다. `간지 인쇄`를 켜면 서식마다 앞에 서식 제목을 가운데 크게 쓴 간지(겹테두리)를 넣고, 간지 쪽에는 쪽 번호를 찍지 않습니다. 화면 전용 서식은 빼고 알립니다. 고른 서식은 공사마다 저장됩니다.
+
+`파일 → 견적서 작성`은 원본처럼 제목·공사명·발주처·견적일자와 공급자(등록번호·상호·대표자·주소·업태·종목·전화)를 넣고, 설계내역 레벨(0 = 전체, N = N 이상 공종과 순공사비·비율 줄)·첫/나머지 페이지 줄수·정렬·비고란을 정해 미리보기·인쇄합니다. 합계금액은 원가계산서 아래에서 위로 첫 체크된 금액을 `일금:…원정 (₩…원정)`으로 적습니다. 공급자정보는 원본과 같은 `.einf`(INI) 파일로 저장·불러오기 합니다. 설정은 공사마다 저장됩니다.
+
+`파일 → 갑지·표지·공사설명서·시방서 편집`은 원본 ESTX의 문서 레코드(DatGabgi·DatCover·DatExplean·DatSiBang)를 읽어 공사명·공사위치·발주처·설계일자·공사개요·공사금액·결재란, 표지의 부제·설계년도를 고칩니다. 공사명은 공사 이름과 함께 바뀝니다. `공사금액 만들기`는 원가계산서에서 고른 비목을 `금 일천오백만원(₩15,000,000)` 또는 `금15,000,000원(금일천오백만원)` 방식으로 적습니다. 설명서·시방서에서는 갑지의 공사명·위치·개요·금액을 커서 자리에 붙입니다. 인쇄 서식 8·9·10·11 미리보기는 이 내용을 씁니다. 서식·여백·글꼴은 다루지 않고, 문서를 고친 작업은 ESTX 저장을 거부합니다.
+
`편집 → 통합 검색`은 원본 통합 검색처럼 일위대가·단가산출·기계경비·견적·실적·자재·노임 중 고른 자료를 검색어로 찾습니다. 검색 위치는 이 앱에 저장된 다른 작업입니다. 찾은 자료를 고르면 하위 연결 자료와 함께 현재 공사로 가져오고(같은 코드는 현재 자료 유지), 단가 고르기로 행에 넣습니다.
`편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다.
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
+`원가계산서 → 기타경비 계산`은 원본처럼 `(직접공사비 × 재료비율) + 직접노무비 + 간접노무비`와 `직접공사비 × 기초금액상한율` 중 작은 기초금액에 요율을 곱한 식을 기타경비 행에 넣습니다. `산업안전보건관리비 비교`는 `대상액 × 요율 × 1.2`와 `(대상액 + 관급자재/1.1) × 요율`(기초금액 더하기 선택) 중 작은 식을 고릅니다. 요율·비율·기초금액은 사용자가 넣으며, 고르지 않은 식은 아래 계산 제외 줄로 보일 수 있습니다.
+
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
@@ -48,10 +68,14 @@
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
+산출 줄 옆 `누계`는 원본 누계(Ctrl+U)처럼 커서 자리에 누계 값을 넣습니다. 값은 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 바로 위 줄까지 줄마다 첫 결과 수량의 합(소계)이며, 계산할 때마다 다시 구합니다. 원본 파일의 누계 표지도 읽어 다시 계산합니다.
+
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
+수량산출 화면의 `검색`은 원본처럼 산출서 이름과 항목의 명칭·규격에서 빈칸·`|`·괄호를 빼고 찾아 목록을 좁히고, `전체보기`로 되돌립니다.
+
수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs
index fb85cb8be..96734dc32 100644
--- a/tools/est-test/export-estx.cjs
+++ b/tools/est-test/export-estx.cjs
@@ -176,6 +176,8 @@ function exportEstx(project) {
const original = Buffer.from(project.originalArchive, "base64"),
base = importEstx(original),
files = unzip(original);
+ if (project.docs && !same(project.docs, base.docs))
+ throw Error("갑지·표지·설명서 편집은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
const optionFields = {
GCToWonga: ["TWongaOption", "GCToWonga"],
GangubExceptJodalRatio: ["TWongaOption", "GangubExceptJodalRatio"],
diff --git a/tools/est-test/forms-store.cjs b/tools/est-test/forms-store.cjs
index 3b8ddd43e..72024ddde 100644
--- a/tools/est-test/forms-store.cjs
+++ b/tools/est-test/forms-store.cjs
@@ -3,7 +3,7 @@
const fs = require("node:fs");
const path = require("node:path");
-const kinds = new Set(["wonga", "document"]);
+const kinds = new Set(["wonga", "document", "machformula", "options"]);
const LIMIT = 2 * 1024 * 1024;
function file(directory) {
diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs
index 664eb38ee..d2259acce 100644
--- a/tools/est-test/import-estx.cjs
+++ b/tools/est-test/import-estx.cjs
@@ -1,5 +1,6 @@
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
const { readXls } = require("./xls-read.cjs");
+const { readDocuments } = require("./midas-read.cjs");
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const { readPrintOptions } = require("./import-print.cjs");
@@ -380,10 +381,17 @@ function importEstx(buffer, name = "가져온 내역서") {
}
}
existingTargets(tables, quantitySheets, warnings);
+ let docs = {};
+ try {
+ docs = readDocuments(files);
+ } catch (error) {
+ warnings.add(`갑지·표지 문서: ${error.message}`);
+ }
return {
schema: "est-web-2",
name: props.TCommonOption?.ProjectName || name,
tables,
+ docs,
wongaTemplates: templates,
originalOptions: objects,
options,
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index bc17909b4..a8ece4079 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -10,6 +10,7 @@
+
@@ -50,6 +51,16 @@
+
+
+
+
+
+
+
+
+
+
@@ -74,13 +85,21 @@
+
+
+
+
+
@@ -110,6 +129,7 @@
+
@@ -125,6 +145,12 @@
+
+
원가계산서 보기
+
diff --git a/tools/est-test/midas-read.cjs b/tools/est-test/midas-read.cjs
new file mode 100644
index 000000000..5a91354b6
--- /dev/null
+++ b/tools/est-test/midas-read.cjs
@@ -0,0 +1,126 @@
+// 원본 문서 레코드(.opt = MIDAS TClientDataSet 이진 묶음, 머리 96 19 E0 BD) 일반 읽기 — 갑지·표지·공사설명서·시방서.
+// 필드형: 1/4 정수 · 3/2 짧은 정수 · 4/8 실수 · 73/1 문자열(cp949) · 75/4 BLOB(바이트 그대로).
+// WPTools BLOB 은 wptText 로 글자만 꺼낸다(서식 버림).
+const zlib = require("node:zlib");
+const decoder = new TextDecoder("euc-kr");
+
+function readRecords(buffer) {
+ if (!buffer || buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996)
+ throw Error("문서 레코드 머리 오류");
+ const count = buffer.readUInt16LE(12),
+ rows = buffer.readUInt16LE(14),
+ start = buffer.readUInt16LE(22);
+ if (count > 1024 || rows > 10000 || start > buffer.length) throw Error("문서 레코드 크기 오류");
+ let at = 24;
+ const take = (n) => {
+ if (n < 0 || at + n > buffer.length) throw Error("문서 레코드 범위 오류");
+ const out = buffer.subarray(at, at + n);
+ at += n;
+ return out;
+ };
+ const fields = [];
+ for (let i = 0; i < count; i++) {
+ const name = take(take(1)[0]).toString("ascii"),
+ width = take(2).readUInt16LE(),
+ kind = take(2).readUInt16LE();
+ take(2);
+ const attributes = take(2).readUInt16LE();
+ for (let j = 0; j < attributes; j++) {
+ take(take(1)[0]);
+ const size = take(2).readUInt16LE(),
+ type = take(2).readUInt16LE();
+ if (type === 73) take(take(2).readUInt16LE());
+ else take(size);
+ }
+ fields.push({ name, width, kind });
+ }
+ at = start;
+ const out = [];
+ for (let r = 0; r < rows; r++) {
+ take(1);
+ const status = take(Math.ceil(count / 4)),
+ row = {};
+ fields.forEach(({ name, width, kind }, i) => {
+ const state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
+ if (state === 1 || state === 2) return (row[name] = null);
+ if (kind === 1 && width === 4) row[name] = take(4).readInt32LE();
+ else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE();
+ else if (kind === 4 && width === 8) row[name] = take(8).readDoubleLE();
+ else if (kind === 73 && width === 1)
+ row[name] = decoder.decode(take(take(1)[0])).replace(/\0+$/, "");
+ else if (kind === 75 && width === 4) row[name] = Buffer.from(take(take(4).readUInt32LE()));
+ else throw Error(`문서 레코드 필드형 오류: ${name}/${kind}/${width}`);
+ });
+ out.push(row);
+ }
+ return out;
+}
+
+function wptText(blob) {
+ if (!blob?.length) return "";
+ let bytes = blob;
+ if (bytes[0] === 0x78) bytes = zlib.inflateSync(bytes);
+ const source = decoder.decode(bytes);
+ if (!source.startsWith("/gi, "")
+ .replace(/<\/(div|p)>|
/gi, "\n")
+ .replace(/<[^>]*>/g, "")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
+ .replace(/&/g, "&")
+ .replace(/[ \t]+\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+// 갑지·표지·공사설명서·시방서 → project.docs (글자 필드만; 서식·여백은 원본에 그대로 둔다)
+function readDocuments(files) {
+ const docs = {};
+ const one = (name) => {
+ const key = [...files.keys()].find((k) => k.toLowerCase() === name.toLowerCase());
+ return key ? readRecords(files.get(key))[0] || null : null;
+ };
+ const gabgi = one("DatGabgi.opt");
+ if (gabgi)
+ docs.gabgi = {
+ Name: gabgi.Name ?? "",
+ Position: gabgi.Position ?? "",
+ CompanyName: gabgi.CompanyName ?? "",
+ Explan: wptText(gabgi.EXPLEAN),
+ Cost: wptText(gabgi.Cost),
+ Gulga: gabgi.Gulga
+ ? decoder
+ .decode(gabgi.Gulga)
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ : [],
+ Date: gabgi.Date ?? "",
+ YearShow: Boolean(gabgi.YearShow),
+ DayShow: Boolean(gabgi.DayShow),
+ CompanyPos: gabgi.CompanyPos ?? 1,
+ };
+ const cover = one("DatCover.opt");
+ if (cover)
+ docs.cover = {
+ Name: cover.Name ?? "",
+ Name2: cover.Name2 ?? "",
+ CompanyName: cover.CompanyName ?? "",
+ Date: cover.Date ?? "",
+ YearShow: Boolean(cover.YearShow),
+ CompanyPos: cover.CompanyPos ?? 1,
+ };
+ for (const [key, file] of [
+ ["explan", "DatExplean.opt"],
+ ["sibang", "DatSiBang.opt"],
+ ]) {
+ const row = one(file);
+ if (row) docs[key] = { text: wptText(row.Editor) };
+ }
+ return docs;
+}
+
+module.exports = { readRecords, wptText, readDocuments };
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js
index 5bc51a560..7c92f39d0 100644
--- a/tools/est-test/native-actions.js
+++ b/tools/est-test/native-actions.js
@@ -137,8 +137,10 @@ const EstActions = (() => {
project = state.project;
state.notice = "";
if (name === "new") {
- if (confirm("저장하지 않은 입력을 비우고 새 내역서를 만드시겠습니까?"))
+ if (confirm("저장하지 않은 입력을 비우고 새 내역서를 만드시겠습니까?")) {
EstApp.setProject(EstNative.empty());
+ if (typeof EstDefaults !== "undefined") await EstDefaults.applyNew();
+ }
} else if (name === "sample") {
if (confirm("현재 작업을 REA 샘플로 바꾸시겠습니까?"))
EstApp.setProject(await api("/api/sample"));
@@ -312,7 +314,7 @@ const EstActions = (() => {
? projects
.map(
(p) =>
- ` ${esc(p.modified)}
`,
+ ` ${esc(p.modified)}
`,
)
.join("")
: "저장된 작업이 없습니다.",
diff --git a/tools/est-test/native-autolink.js b/tools/est-test/native-autolink.js
new file mode 100644
index 000000000..d395ea8e8
--- /dev/null
+++ b/tools/est-test/native-autolink.js
@@ -0,0 +1,140 @@
+// 원본 데이타 자동연결(ActionAutoLink Ctrl+Alt+L 0x10d54f4 → TfrmDataAutoLink 적용 0x10d3008, 행 처리 10d2a48, 맞춤 10e0e58).
+// 내역서·일위대가·기계경비 화면에서 현재 공사의 노임·자재·견적·표준·기계경비·단가산출·일위대가 표로 품명(·규격·단위)을 맞춰 연결.
+// 정규화: 빈칸·|·(·) 제거, 단위는 「당」·[·] 를 한 번씩 더 지움. 노임은 품명만(못 찾으면 규격으로 찾고 품명·규격을 맞바꿈),
+// 규격·단위 비면 품명+규격, 단위 있으면 품명+규격+단위. 노임·기계는 단위 무시. 처음 맞는 행. Over Write 끄면 이미 연결된 행은 건너뜀.
+// 쓰는 값: Code, TU(=원본 TC), 내역·일위대가는 NU·GU·JU·SU(=원본 NC·GC·JC·SC), LinkIndex, 원본 SCheck 3 이면 SCheck 3.
+// 「전체연결 끊기」는 원본처럼 모든 행 LinkIndex 를 0 으로 한다(조건 없음).
+const EstAutoLink = (() => {
+ const num = (value) => Number(value) || 0;
+ const sources = {
+ 6: "LaborcDat",
+ 5: "StuffcDat",
+ 101: "EstiMateDat",
+ 21: "SilJukDat",
+ 105: "MachTitleDat",
+ 104: "CostTitleDat",
+ 103: "HeadTitleDat",
+ };
+ const order = [6, 5, 101, 21, 105, 104, 103];
+ const tags = { EstMainDat: 1, HeadMainDat: 2, MachMainDat: 4 };
+ const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
+ const unit = (s) => norm(s).replace("당", "").replace("[", "").replace("]", "");
+
+ function key(row, tag) {
+ let name = row.Name ?? "",
+ size = row.Size ?? "";
+ const unite = row.Unite ?? "";
+ if (tag === 4) {
+ name = String(name).replace(/[\s|]/g, "");
+ size = String(size).replace(/[\s|]/g, "");
+ if ([5, 212].includes(num(row.LinkIndex)) && name.includes("주연료")) {
+ name = size;
+ size = "";
+ }
+ }
+ return { name: String(name).trim(), size: String(size).trim(), unite: String(unite).trim() };
+ }
+
+ function find(project, kind, k) {
+ const rows = project.tables[sources[kind]] || [],
+ useUnit = ![6, 105].includes(kind) && k.unite;
+ const mode = !k.size && !useUnit ? (kind === 6 ? "name" : "size") : useUnit ? "unit" : "size";
+ return rows.find((src) => {
+ if (!src.Code) return false;
+ if (kind !== 101 && src.LinkIndex != null && num(src.LinkIndex) !== kind) return false;
+ if (src.DataKind != null && num(src.DataKind) !== 1) return false;
+ if (norm(src.Name) !== norm(k.name)) return false;
+ if (mode === "name" || kind === 6) return true;
+ if (norm(src.Size) !== norm(k.size)) return false;
+ if (mode === "size" || kind === 105) return true;
+ return unit(src.Unite) === unit(k.unite);
+ });
+ }
+
+ function linkKind(project, view, kind, overwrite) {
+ const tag = tags[view],
+ rows = project.tables[view] || [];
+ let linked = 0;
+ for (const row of rows) {
+ if ((tag === 2 || tag === 4) && num(row.Index) > 0) continue;
+ if (tag === 1 && (num(row.Level) > 0 || [54, 55, 58].includes(num(row.LinkIndex)))) continue;
+ const k = key(row, tag);
+ if (!k.name && !k.size && !k.unite) continue;
+ if (!overwrite && num(row.LinkIndex)) continue;
+ let src = find(project, kind, kind === 6 ? { name: k.name, size: "", unite: "" } : k);
+ if (!src && kind === 6 && k.size) {
+ src = find(project, kind, { name: k.size, size: "", unite: "" });
+ if (src) [row.Name, row.Size] = [row.Size, row.Name];
+ }
+ if (!src) continue;
+ row.Code = src.Code;
+ row.TU = num(src.TC);
+ if (tag === 1 || tag === 2) {
+ row.NU = num(src.NC);
+ row.GU = num(src.GC);
+ row.JU = num(src.JC);
+ row.SU = num(src.SC);
+ }
+ row.LinkIndex = kind;
+ if (num(src.SCheck) === 3) row.SCheck = 3;
+ linked++;
+ }
+ return linked;
+ }
+
+ function run(project, view, choice, overwrite) {
+ if (!tags[view]) throw Error("내역서·일위대가·기계경비 화면에서 자동연결하세요.");
+ if (choice === "unlink") {
+ for (const row of project.tables[view] || []) row.LinkIndex = 0;
+ return (project.tables[view] || []).length;
+ }
+ const kinds =
+ choice === "all"
+ ? order.filter((k) => k !== 105 || [1, 2].includes(tags[view]))
+ : [Number(choice)];
+ return kinds.reduce((n, kind) => n + linkKind(project, view, kind, overwrite), 0);
+ }
+
+ const choices = [
+ ["unlink", "전체연결 끊기"],
+ ["all", "전체자동 연결"],
+ ["6", "노임단가 연결"],
+ ["5", "자재단가 연결"],
+ ["101", "견적단가 연결"],
+ ["21", "표준단가 연결"],
+ ["105", "기계경비 연결"],
+ ["104", "단가산출 연결"],
+ ["103", "일위대가 연결"],
+ ];
+ function open(state) {
+ if (!tags[state.view]) throw Error("내역서·일위대가·기계경비 화면에서 자동연결하세요.");
+ EstApp.showDialog(
+ "데이타 자동연결",
+ ``,
+ );
+ }
+ function apply(state) {
+ const choice = document.querySelector('#dialog-content [name="al-choice"]').value,
+ overwrite = document.querySelector('#dialog-content [name="al-over"]').checked;
+ const ask =
+ choice === "unlink"
+ ? "데이타를 연결을 끊을까요?"
+ : choice === "all"
+ ? "전체 데이타를 연결 시킬까요?"
+ : null;
+ if (ask && !confirm(ask)) return;
+ let count;
+ EstApp.change((project) => (count = run(project, state.view, choice, overwrite)));
+ EstApp.byId("autolink-result").textContent =
+ choice === "unlink" ? `${count}행의 연결을 끊었습니다.` : `${count}행을 연결했습니다.`;
+ }
+
+ function handle(name, state) {
+ if (name === "autolink") open(state);
+ else if (name === "autolink-apply") apply(state);
+ else return false;
+ return true;
+ }
+
+ return { run, find, key, handle };
+})();
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 56b2871da..ebe0aa032 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -611,7 +611,17 @@ const EstBusinessActions = (() => {
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
- (typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state))
+ (typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ||
+ (typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) ||
+ (typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) ||
+ (typeof EstEstimatePrint !== "undefined" && EstEstimatePrint.handle(name, state)) ||
+ (typeof EstXmlCheck !== "undefined" && EstXmlCheck.handle(name, state)) ||
+ (typeof EstReviewAuto !== "undefined" && EstReviewAuto.handle(name, state)) ||
+ (typeof EstAutoLink !== "undefined" && EstAutoLink.handle(name, state)) ||
+ (typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state)) ||
+ (typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) ||
+ (typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) ||
+ (typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-defaults.js b/tools/est-test/native-defaults.js
new file mode 100644
index 000000000..8ec765b04
--- /dev/null
+++ b/tools/est-test/native-defaults.js
@@ -0,0 +1,69 @@
+// 원본 환경설정 「기본환경으로설정」(TfrmOption btnSave)과 최근공사목록 「목록에서삭제」(TfrmWorkFileList btnDelete).
+// 기본환경: 현재 계산 환경 값을 서버 양식 보관함(종류 options, 이름 「기본환경」)에 두고 새 내역서를 만들 때 덮어 쓴다.
+// 목록에서 삭제: 원본은 최근 목록에서만 빼므로, 웹은 저장 파일을 data/removed/ 로 옮겨 목록에서만 사라지게 한다(지우지 않음).
+const EstDefaults = (() => {
+ const keys = [
+ "HeadDigits",
+ "EstDigits",
+ "CostValueTrunc",
+ "MidCostDiget",
+ "TotalFix",
+ "TotalFit",
+ "DollarCostCut",
+ "JDLCalcMethod",
+ "GCToWonga",
+ "GangubExceptJodalRatio",
+ "EstLeftSpace",
+ "GitaGCRatio",
+ "GitaBaseRatio",
+ "AnjunBaseCost",
+ ];
+ const NAME = "기본환경";
+ const post = (body) => ({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ function pick(options) {
+ return Object.fromEntries(
+ keys.filter((k) => options?.[k] !== undefined).map((k) => [k, options[k]]),
+ );
+ }
+ async function saved() {
+ return (await EstApp.api("/api/forms")).find((f) => f.kind === "options" && f.name === NAME)
+ ?.data;
+ }
+ async function applyNew() {
+ const data = await saved();
+ if (data) EstApp.change((project) => Object.assign(project.options, pick(data)));
+ return Boolean(data);
+ }
+
+ async function run(name, state, node) {
+ if (name === "options-save-default") {
+ if (!confirm("현재 계산 환경을 새 내역서의 기본환경으로 설정하시겠습니까?")) return;
+ await EstApp.api(
+ "/api/forms",
+ post({ kind: "options", name: NAME, data: pick(state.project.options) }),
+ );
+ state.notice = "기본환경으로 설정했습니다. 새 내역서에 적용됩니다.";
+ } else if (name === "project-hide") {
+ if (!confirm("이 작업을 목록에서 빼시겠습니까? 파일은 data/removed 에 남습니다.")) return;
+ await EstApp.api(`/api/projects/${node.dataset.id}/hide`, { method: "POST" });
+ EstApp.byId("dialog").close();
+ state.notice = "목록에서 뺐습니다.";
+ }
+ EstApp.render();
+ }
+ function handle(name, state, node) {
+ if (name !== "options-save-default" && name !== "project-hide") return false;
+ run(name, state, node).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+
+ return { keys, pick, applyNew, handle };
+})();
diff --git a/tools/est-test/native-docs.js b/tools/est-test/native-docs.js
new file mode 100644
index 000000000..6144c0976
--- /dev/null
+++ b/tools/est-test/native-docs.js
@@ -0,0 +1,216 @@
+// 원본 갑지(TFRMGABGI)·표지(TFRMCOVER)·공사설명서/시방서(TFRMESTEDITOR) 웹 편집 — project.docs.
+// 공사명은 열 때 공사 이름을 따르고 고치면 공사 이름에도 적는다(원본 TCommonOption.ProjectName 동기화).
+// 공사금액 자동입력: 원가계산서에서 고른 비목을 두 방식으로 적는다(원본 c_cb2e78·c_cb3080, 한글금액 c_931b44).
+// 1 = 「도급액 : 금 일천오백만원(₩15,000,000)」 · 2 = 「도급액 : 금15,000,000원(금일천오백만원)」, 「일」을 줄이지 않는다.
+// 설명서·시방서 붙여넣기는 갑지의 공사명·공사위치·공사개요·공사금액을 커서 자리에 넣는다. 서식·여백은 다루지 않는다.
+const EstDocs = (() => {
+ const digits = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
+ const places = ["천", "백", "십", ""];
+ const groups = ["", "만", "억", "조"];
+
+ function korean(value) {
+ const n = Math.round(Number(value) || 0);
+ if (!n) return "영";
+ const text = String(Math.abs(n)),
+ padded = text.padStart(Math.ceil(text.length / 4) * 4, " "),
+ count = padded.length / 4;
+ let out = "";
+ for (let g = 0; g < count; g++) {
+ const part = padded.slice(g * 4, g * 4 + 4);
+ let chunk = "";
+ [...part].forEach((ch, i) => {
+ const d = Number(ch);
+ if (ch !== " " && d) chunk += digits[d] + places[i];
+ });
+ if (chunk) out += chunk + groups[count - 1 - g];
+ }
+ return out;
+ }
+ const comma = (value) => Math.round(Number(value) || 0).toLocaleString("en-US");
+
+ function costLine(name, cost, method) {
+ return method === 2
+ ? `${name} : 금${comma(cost)}원(금${korean(cost)}원)`
+ : `${name} : 금 ${korean(cost)}원(₩${comma(cost)})`;
+ }
+ function costText(project, indices, method) {
+ const rows = project.tables.Wonga || [];
+ const picked = indices.map((i) => rows[i]).filter(Boolean);
+ if (!picked.length) throw Error("공사금액에 넣을 원가 비목을 고르세요.");
+ const width = Math.max(...picked.map((r) => String(r.Name ?? "").trim().length));
+ return picked
+ .map((r) =>
+ costLine(
+ String(r.Name ?? "")
+ .trim()
+ .padEnd(width, " "),
+ r.Cost,
+ method,
+ ),
+ )
+ .join("\n");
+ }
+
+ const defaults = () => ({
+ gabgi: {
+ Name: "",
+ Position: "",
+ CompanyName: "",
+ Explan: "",
+ Cost: "",
+ Gulga: ["과 장", "계 장", "심사자", "설계자"],
+ Date: "",
+ YearShow: true,
+ DayShow: false,
+ CompanyPos: 1,
+ },
+ cover: { Name: "", Name2: "", CompanyName: "", Date: "", YearShow: true, CompanyPos: 1 },
+ explan: { text: "" },
+ sibang: { text: "" },
+ });
+ function doc(project, kind) {
+ project.docs ||= {};
+ const value = { ...defaults()[kind], ...(project.docs[kind] || {}) };
+ if ((kind === "gabgi" || kind === "cover") && project.name) value.Name = project.name;
+ return value;
+ }
+ function year(value) {
+ const text = String(value ?? "")
+ .split("년")[0]
+ .trim();
+ return `${text || new Date().getFullYear()}년도`;
+ }
+
+ // 인쇄 미리보기(서식 8 갑지 · 9 표지 · 10 시방서 · 11 공사설명서)
+ function html(form, project) {
+ const esc = (v) =>
+ String(v ?? "").replace(
+ /[&<>"]/g,
+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
+ );
+ const align = (pos) => ["left", "center", "right"][Number(pos)] || "center";
+ if (form === 9) {
+ const c = doc(project, "cover");
+ return `${c.YearShow ? `
${esc(year(c.Date))}
` : ""}
${esc(c.Name)}
${c.Name2 ? `
${esc(c.Name2)}
` : ""}
${esc(c.CompanyName)}
`;
+ }
+ if (form === 8) {
+ const g = doc(project, "gabgi");
+ const gulga = g.Gulga.length
+ ? `${g.Gulga.map((n) => `| ${esc(n)} | `).join("")}
${g.Gulga.map(() => " | ").join("")}
`
+ : "";
+ return `${gulga}
${esc(g.Date)}
${g.Position ? `
공사위치 : ${esc(g.Position)}
` : ""}
${esc(g.Name)}
${g.Explan ? `
공사개요 :
${esc(g.Explan)}` : ""}${g.Cost ? `
공사금액 :
${esc(g.Cost)}` : ""}
${esc(g.CompanyName)}
`;
+ }
+ if (form === 10 || form === 11) {
+ const text = doc(project, form === 10 ? "sibang" : "explan").text;
+ return text ? `${esc(text)}` : "";
+ }
+ return "";
+ }
+
+ // 화면
+ const field = (name) => document.querySelector(`#dialog-content [name="${name}"]`);
+ function editor(state, kind) {
+ const { esc } = EstApp,
+ d = doc(state.project, kind);
+ const input = (name, label, value) =>
+ ``;
+ const area = (name, label, value, rows = 4) =>
+ ``;
+ const pos = (value) =>
+ ``;
+ let body = "";
+ if (kind === "cover")
+ body =
+ input("Name", "공사명", d.Name) +
+ input("Name2", "부제", d.Name2) +
+ input("Date", "설계년도", d.Date) +
+ input("CompanyName", "발주처", d.CompanyName) +
+ pos(d.CompanyPos);
+ else if (kind === "gabgi") {
+ const rows = (state.project.tables.Wonga || [])
+ .map((r, i) => [r, i])
+ .filter(([r]) => Number(r.Cost) && String(r.Name ?? "").trim());
+ const fixed = rows.find(([r]) => Number(r.LinkIndex) === 55)?.[1];
+ body =
+ input("Name", "공사명", d.Name) +
+ input("Position", "공사위치", d.Position) +
+ input("CompanyName", "발주처", d.CompanyName) +
+ input("Date", "설계일자", d.Date) +
+ pos(d.CompanyPos) +
+ area("Explan", "공사개요", d.Explan) +
+ area("Cost", "공사금액", d.Cost, 3) +
+ `공사금액 자동입력:
` +
+ area("Gulga", "결재란(한 줄에 하나)", d.Gulga.join("\n"), 4);
+ } else
+ body =
+ area("text", kind === "explan" ? "공사설명서" : "시방서", d.text, 14) +
+ `갑지에서 붙여넣기: ${[
+ ["Name", "공사명"],
+ ["Position", "공사위치"],
+ ["Explan", "공사개요"],
+ ["Cost", "공사금액"],
+ ]
+ .map(([k, l]) => ``)
+ .join(" ")}
`;
+ const title = { gabgi: "갑지", cover: "표지", explan: "공사설명서", sibang: "시방서" }[kind];
+ EstApp.showDialog(
+ `${title} 편집`,
+ `${body}
`,
+ );
+ }
+ function collect(kind) {
+ const value = (name) => field(`doc-${name}`)?.value ?? "";
+ if (kind === "cover")
+ return {
+ Name: value("Name"),
+ Name2: value("Name2"),
+ Date: value("Date"),
+ CompanyName: value("CompanyName"),
+ CompanyPos: Number(value("CompanyPos")),
+ };
+ if (kind === "gabgi")
+ return {
+ Name: value("Name"),
+ Position: value("Position"),
+ CompanyName: value("CompanyName"),
+ Date: value("Date"),
+ CompanyPos: Number(value("CompanyPos")),
+ Explan: value("Explan"),
+ Cost: value("Cost"),
+ Gulga: value("Gulga")
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter(Boolean),
+ };
+ return { text: value("text") };
+ }
+
+ function handle(name, state, node) {
+ if (name === "doc-edit") editor(state, node.dataset.kind);
+ else if (name === "doc-save") {
+ const kind = node.dataset.kind,
+ next = collect(kind);
+ EstApp.byId("dialog").close();
+ EstApp.change((project) => {
+ project.docs ||= {};
+ project.docs[kind] = { ...doc(project, kind), ...next };
+ if (next.Name && next.Name !== project.name) project.name = next.Name;
+ }, false);
+ state.notice = "문서를 저장했습니다.";
+ } else if (name === "doc-cost") {
+ const indices = [...field("doc-rows").selectedOptions].map((o) => Number(o.value));
+ field("doc-Cost").value = costText(state.project, indices, Number(field("doc-method").value));
+ } else if (name === "doc-paste") {
+ const key = node.dataset.field,
+ g = doc(state.project, "gabgi"),
+ text = String(g[key] ?? "").trim();
+ if (!text) throw Error("갑지에서 입력을 하지 않았습니다.");
+ const area = field("doc-text"),
+ at = area.selectionStart ?? area.value.length;
+ area.value = area.value.slice(0, at) + text + area.value.slice(area.selectionEnd ?? at);
+ } else return false;
+ return true;
+ }
+
+ return { korean, costLine, costText, html, doc, handle };
+})();
diff --git a/tools/est-test/native-est-tools.js b/tools/est-test/native-est-tools.js
new file mode 100644
index 000000000..dd6413924
--- /dev/null
+++ b/tools/est-test/native-est-tools.js
@@ -0,0 +1,176 @@
+// 원본 내역·단가산출 다듬기 도구.
+// 하단으로 집계(popMainDownSum → f01a90·f018d0): 공종 머리(TU 0 또는 57) 앞에서 열린 공종(레벨 1..L)마다, 순공사비(54)·경계(Index 59)
+// 앞과 마지막 행 뒤에서 열린 공종 전부에 「소 계」(LinkIndex 53, Level −10) 행을 넣는다. 바로 위 구간에 이미 소계가 있으면 넣지 않는다.
+// 금액은 재계산이 채운다(EstRows.estimate). 상단으로 집계(mnuSumUp f01750)는 53 행을 모두 지운다.
+// 이미 소계가 있는 자리는 닫힌 것으로 보아 다시 눌러도 겹치지 않게 한다(원본은 열린 표시가 남아 끝에 덧붙을 수 있음).
+// 공종 다듬기(mnuEstLeftSpace 0x113eb44): LeftSpace = 상위 공종 들여쓰기 + 간격, 54·55·58 행은 0.
+// 단가산출 들여쓰기(ActionLeftMargin 0xf0770c): 제목·레벨 행 말고 LineData 앞 빈칸을 n 칸으로 바꾸고, 분수(─) 세 줄은 같은 만큼 옮김.
+// 레벨 확인(TfrmCostLevelCheck 0xd10840): 현재 호표의 레벨 줄([레벨])과 비목 소계 줄을 계층으로 보인다(읽기 전용).
+const EstEstTools = (() => {
+ const num = (value) => Number(value) || 0;
+
+ function hasSubtotalAbove(rows, at) {
+ for (let i = at - 1; i >= 0; i--) {
+ const r = rows[i];
+ if (num(r.LinkIndex) === 53) return true;
+ if (num(r.Level) > 0 || [54, 55, 58, 59].includes(num(r.LinkIndex))) return false;
+ }
+ return false;
+ }
+ function downSum(project) {
+ const rows = project.tables.EstMainDat || [],
+ out = [],
+ open = new Set();
+ const subtotal = () => ({ Index: 0, Name: "소 계", LinkIndex: 53, Level: -10 });
+ let added = 0;
+ // 바로 위에 소계가 이미 있으면 넣지 않고 닫은 것으로 본다(다시 눌러도 겹치지 않게).
+ const close = (max, at) => {
+ const exists = hasSubtotalAbove(rows, at);
+ for (let level = 1; level <= max; level++)
+ if (open.has(level)) {
+ open.delete(level);
+ if (exists) continue;
+ out.push(subtotal());
+ added++;
+ }
+ };
+ rows.forEach((row, i) => {
+ const level = num(row.Level),
+ link = num(row.LinkIndex);
+ if (level > 0 && num(row.Index) !== 53) {
+ if (!num(row.TU) || link === 57) close(level, i);
+ open.add(level);
+ } else if (link === 54 || num(row.Index) === 59) close(9, i);
+ out.push(row);
+ if (i === rows.length - 1) close(9, i + 1);
+ });
+ let pk = Math.max(0, ...rows.map((r) => num(r.PkID)));
+ for (const row of out) if (row.PkID == null) row.PkID = ++pk;
+ project.tables.EstMainDat = out;
+ return added;
+ }
+ function upSum(project) {
+ const rows = project.tables.EstMainDat || [];
+ project.tables.EstMainDat = rows.filter((r) => num(r.LinkIndex) !== 53);
+ return rows.length - project.tables.EstMainDat.length;
+ }
+
+ function leftSpace(project, step) {
+ if (!Number.isInteger(step) || step < 0 || step > 10)
+ throw Error("들여쓰기 간격은 0~10 입니다.");
+ const open = [];
+ for (const row of project.tables.EstMainDat || []) {
+ if ([54, 55, 58].includes(num(row.LinkIndex))) {
+ row.LeftSpace = 0;
+ continue;
+ }
+ const level = num(row.Level) > 0 ? num(row.Level) : 0;
+ while (open.length && open.at(-1).level <= level) open.pop();
+ const indent = open.length ? open.at(-1).indent + step : 0;
+ row.LeftSpace = indent;
+ if (level > 0) open.push({ level, indent });
+ }
+ project.options.EstLeftSpace = step;
+ }
+
+ function costIndent(project, n) {
+ if (!Number.isInteger(n) || n < 0 || n > 50) throw Error("들여쓰기 칸 수는 0~50 입니다.");
+ const rows = project.tables.CostMainDat || [];
+ const lead = (s) => String(s ?? "").length - String(s ?? "").trimStart().length;
+ const shift = (s, delta) => {
+ const text = String(s ?? "");
+ return delta >= 0 ? " ".repeat(delta) + text : text.slice(Math.min(-delta, lead(text)));
+ };
+ const done = new Set();
+ rows.forEach((row, i) => {
+ if (done.has(i) || num(row.Index) > 0 || num(row.Level) > 0 || !row.LineData) return;
+ if (String(row.LineData).includes("─")) {
+ const delta = n - lead(row.LineData);
+ for (const j of [i - 1, i, i + 1])
+ if (rows[j] && !(num(rows[j].Index) > 0) && !done.has(j)) {
+ rows[j].LineData = shift(rows[j].LineData, delta);
+ done.add(j);
+ }
+ return;
+ }
+ const next = rows[i + 1];
+ if (next && String(next.LineData ?? "").includes("─")) return;
+ row.LineData = " ".repeat(n) + String(row.LineData).trimStart();
+ done.add(i);
+ });
+ }
+
+ function levelTree(project, code) {
+ const rows = project.tables.CostMainDat || [],
+ group = EstNative.groups(rows).get(code);
+ if (!group) throw Error("레벨을 볼 단가산출 호표를 고르세요.");
+ const head = group.rows[0],
+ label = {
+ [-5]: " 계 : ",
+ [-3]: "경 비 : ",
+ [-2]: "재료비 : ",
+ [-1]: "노무비 : ",
+ [-4]: "",
+ };
+ const lines = [];
+ for (const row of group.rows.slice(1)) {
+ const level = num(row.Level),
+ plain = EstText.clean(row.LineData || "").trim();
+ if (level > 0) {
+ const s = plain.padEnd(104, " ");
+ lines.push({ level, text: (s.slice(0, Math.max(0, 60 - level)) + `[${level}]`).trimEnd() });
+ } else if (num(row.SCheck) <= -1 && num(row.SCheck) >= -5) {
+ const value = String(row.LineData || "").match(/\x12([-\d.,]+)\x12/)?.[1] ?? "";
+ lines.push({ level: 0, text: `${label[num(row.SCheck)]}${value}` });
+ }
+ }
+ return { title: `${head.Name ?? ""} (${head.Size ?? ""})`, lines };
+ }
+
+ function handle(name, state) {
+ if (name === "est-down-sum" || name === "est-up-sum") {
+ if (state.view !== "EstMainDat") throw Error("내역서 화면에서 집계하세요.");
+ const down = name === "est-down-sum";
+ if (
+ !confirm(
+ down ? "공사비 집계를 하단으로 하시겠습니까?" : "공사비 집계를 상단으로 하시겠습니까?",
+ )
+ )
+ return true;
+ let count;
+ EstApp.change((project) => (count = down ? downSum(project) : upSum(project)));
+ state.notice = down ? `소계 ${count}줄을 넣었습니다.` : `소계 ${count}줄을 지웠습니다.`;
+ } else if (name === "est-left-space") {
+ EstActions.form(
+ "공종 다듬기",
+ [
+ {
+ name: "step",
+ label: "레벨당 들여쓰기 간격(0~10)",
+ type: "number",
+ value: state.project.options.EstLeftSpace ?? 2,
+ },
+ ],
+ ({ step }) => EstApp.change((project) => leftSpace(project, step), false),
+ );
+ } else if (name === "cost-indent") {
+ if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 들여쓰기하세요.");
+ EstActions.form(
+ "들여쓰기",
+ [{ name: "n", label: "들여쓰기 칸", type: "number", value: 4 }],
+ ({ n }) => EstApp.change((project) => costIndent(project, n)),
+ );
+ } else if (name === "cost-level-check") {
+ if (state.view !== "CostMainDat") throw Error("단가산출 화면에서 레벨을 확인하세요.");
+ const tree = levelTree(state.project, state.group),
+ { esc } = EstApp;
+ EstApp.showDialog(
+ "단가산출 레벨(계층) 보기",
+ `${esc(tree.title)}
${tree.lines.map((l) => esc(l.text)).join("\n") || "레벨 지정 줄이 없습니다."}`,
+ );
+ } else return false;
+ return true;
+ }
+
+ return { downSum, upSum, leftSpace, costIndent, levelTree, handle };
+})();
diff --git a/tools/est-test/native-estimate-print.js b/tools/est-test/native-estimate-print.js
new file mode 100644
index 000000000..8863d8e20
--- /dev/null
+++ b/tools/est-test/native-estimate-print.js
@@ -0,0 +1,233 @@
+// 원본 견적서작성(mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994, 그리기 0x10c4768).
+// 설정은 공사마다 project.estimatePrint(원본 EstiPrintInfo.einf 세 구역), 공급자정보는 원본과 같은 INI(.einf)로 내보내기·불러오기
+// (불러오기는 EstiPrnCompanyInfo·EstiPrnInfo 두 구역만). 행: 레벨 0 = 전 행, N = Level ≥ N 공종 머리 + LinkIndex 54·55·58.
+// 열: 품명(Pr+Name)·규격·수량·단위·단가·공급가액·비고(선택). 1쪽만 머리·공급자·합계금액, 1쪽 F_PageCount·이후 L_PageCount 줄.
+// 합계금액 = 원가계산서를 아래에서 위로 훑어 Checked 이고 금액 있는 첫 행(없으면 도급액 55 행) — 「일금: …원정 (₩…원정)」.
+const EstEstimatePrint = (() => {
+ const num = (value) => Number(value) || 0;
+ const defaults = {
+ Title_Name: "견 적 서",
+ Project_Name: "",
+ Order_name: "",
+ Esti_Date: "",
+ Reg_Num: "",
+ ComPany_Name: "",
+ Ceo_Name: "",
+ Addr: "",
+ Uptae: "",
+ Jongmog: "",
+ Tel: "",
+ Company_Show: true,
+ F_PageCount: 27,
+ L_PageCount: 36,
+ Aligment: 0,
+ Bigo_Show: false,
+ Level: 0,
+ };
+ const sections = {
+ EstiProjectInfo: ["Title_Name", "Project_Name", "Order_name", "Esti_Date"],
+ EstiPrnCompanyInfo: [
+ "Reg_Num",
+ "ComPany_Name",
+ "Ceo_Name",
+ "Addr",
+ "Uptae",
+ "Jongmog",
+ "Tel",
+ "Company_Show",
+ ],
+ EstiPrnInfo: ["F_PageCount", "L_PageCount", "Aligment", "Bigo_Show"],
+ };
+
+ function settings(project) {
+ const today = new Date();
+ return {
+ ...defaults,
+ Project_Name: project.name || "",
+ Esti_Date: `${today.getFullYear()}년 ${String(today.getMonth() + 1).padStart(2, "0")}월 ${String(today.getDate()).padStart(2, "0")}일`,
+ ...(project.estimatePrint || {}),
+ };
+ }
+
+ function rows(project, level) {
+ const list = project.tables.EstMainDat || [];
+ return level > 0
+ ? list.filter((r) => num(r.Level) >= level || [54, 55, 58].includes(num(r.LinkIndex)))
+ : list;
+ }
+
+ function total(project) {
+ const wonga = project.tables.Wonga || [];
+ for (let i = wonga.length - 1; i >= 0; i--)
+ if (wonga[i].Checked === true && num(wonga[i].Cost)) return num(wonga[i].Cost);
+ return num(wonga.find((r) => num(r.LinkIndex) === 55)?.Cost ?? project.calculation?.total);
+ }
+
+ function totalText(value) {
+ return ` 일금:${EstDocs.korean(value)}원정 (₩${Math.round(value).toLocaleString("en-US")}원정)`;
+ }
+
+ function pages(list, first, rest) {
+ if (!(first > 0 && rest > 0)) throw Error("쪽당 줄 수는 1 이상입니다.");
+ const out = [list.slice(0, first)];
+ for (let at = first; at < list.length; at += rest) out.push(list.slice(at, at + rest));
+ return out;
+ }
+
+ // 원본 TIniFile 형식(.einf)
+ function toIni(s, keys = Object.keys(sections)) {
+ return keys
+ .map(
+ (section) =>
+ `[${section}]\r\n${sections[section].map((key) => `${key}=${typeof s[key] === "boolean" ? (s[key] ? "1" : "0") : (s[key] ?? "")}`).join("\r\n")}`,
+ )
+ .join("\r\n\r\n");
+ }
+ function fromIni(text, keys = ["EstiPrnCompanyInfo", "EstiPrnInfo"]) {
+ const out = {};
+ let section = "";
+ for (const line of String(text).split(/\r?\n/)) {
+ const head = line.match(/^\s*\[([^\]]+)\]\s*$/);
+ if (head) {
+ section = head[1];
+ continue;
+ }
+ const pair = line.match(/^\s*([^=]+?)\s*=(.*)$/);
+ if (!pair || !keys.includes(section) || !sections[section].includes(pair[1])) continue;
+ const key = pair[1],
+ value = pair[2].trim(),
+ base = defaults[key];
+ out[key] =
+ typeof base === "boolean"
+ ? ["1", "true", "-1"].includes(value.toLowerCase())
+ : typeof base === "number"
+ ? Number(value) || 0
+ : value;
+ }
+ return out;
+ }
+
+ function html(project, s) {
+ const esc = (v) =>
+ String(v ?? "").replace(
+ /[&<>"]/g,
+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
+ );
+ const fmt = (v) => (num(v) ? num(v).toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "");
+ const lines = (text) => esc(text).split("|").join("
");
+ const align = num(s.Aligment) ? "center" : "left";
+ const head = [
+ "품 명",
+ "규 격",
+ "수 량",
+ "단위",
+ "단 가",
+ "공급가액",
+ ...(s.Bigo_Show ? ["비 고"] : []),
+ ];
+ const table = (list) =>
+ `${head.map((h) => `| ${h} | `).join("")}
${list
+ .map((r) => {
+ const bold =
+ num(r.Level) > 0 || num(r.LinkIndex) === 54 ? ' style="font-weight:bold"' : "";
+ return `| ${esc(`${r.Pr ?? ""}${r.Name ?? ""}`)} | ${esc(r.Size)} | ${fmt(r.GN)} | ${esc(r.Unite)} | ${fmt(r.TU)} | ${fmt(r.TC)} | ${s.Bigo_Show ? `${esc(typeof r.Bigo === "string" ? r.Bigo : "")} | ` : ""}
`;
+ })
+ .join("")}
`;
+ const company = s.Company_Show
+ ? `공 급 자 | 등록번호 | ${esc(s.Reg_Num)} |
${[
+ ["상 호", s.ComPany_Name],
+ ["성 명", s.Ceo_Name],
+ ["사업장주소", s.Addr],
+ ["업 태", s.Uptae],
+ ["종 목", s.Jongmog],
+ ["전화(FAX)", s.Tel],
+ ]
+ .map(([k, v]) => `| ${k} | ${esc(v)} |
`)
+ .join("")}
`
+ : "";
+ const all = pages(rows(project, num(s.Level)), num(s.F_PageCount), num(s.L_PageCount));
+ const sum = total(project);
+ const body = all
+ .map(
+ (list, i) =>
+ `${
+ i === 0
+ ? `${esc(s.Title_Name)}
공사명: ${lines(s.Project_Name)}
${lines(s.Order_name)} 귀하
${esc(s.Esti_Date)}
아래와 같이 견적합니다
${company}
합계금액 ${esc(totalText(sum))}
`
+ : ""
+ }${table(list)}`,
+ )
+ .join("");
+ return `${body}`;
+ }
+
+ // 화면
+ const field = (name) => document.querySelector(`#dialog-content [name="ep-${name}"]`);
+ function open(state) {
+ const s = settings(state.project),
+ { esc } = EstApp;
+ const input = (k, label, type = "text") =>
+ ``;
+ const check = (k, label) =>
+ ``;
+ EstApp.showDialog(
+ "견적서 작성",
+ `${input("Title_Name", "제목")}${input("Project_Name", "공사명(| 줄바꿈)")}${input("Order_name", "발주처")}${input("Esti_Date", "견적일자")}${input("Level", "설계내역 레벨표시(0~9)", "number")}${input("F_PageCount", "첫 페이지 줄수", "number")}${input("L_PageCount", "나머지 페이지 줄수", "number")}${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}
공급자
${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}
`,
+ );
+ }
+ function read(state) {
+ const s = settings(state.project);
+ for (const key of Object.keys(defaults)) {
+ const node = field(key);
+ if (!node) continue;
+ s[key] =
+ node.type === "checkbox"
+ ? node.checked
+ : typeof defaults[key] === "number"
+ ? Number(node.value) || 0
+ : node.value;
+ }
+ if (!(s.Level >= 0 && s.Level <= 9)) throw Error("레벨은 0~9 입니다.");
+ if (JSON.stringify(s) !== JSON.stringify(settings(state.project)))
+ EstApp.change((project) => (project.estimatePrint = s), false);
+ return s;
+ }
+ function preview(state) {
+ const s = read(state),
+ frame = EstApp.byId("estimate-print-frame");
+ frame.srcdoc = html(state.project, s);
+ return frame;
+ }
+
+ function handle(name, state) {
+ if (name === "estimate-print") open(state);
+ else if (name === "estimate-print-preview") preview(state);
+ else if (name === "estimate-print-print") {
+ const frame = preview(state);
+ frame.onload = () => frame.contentWindow.print();
+ } else if (name === "estimate-print-save")
+ EstApp.download("견적서 기본정보.einf", toIni(read(state)), "text/plain");
+ else if (name === "estimate-print-load") {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".einf,.ini,.txt";
+ picker.addEventListener("change", async () => {
+ const file = picker.files[0];
+ if (!file) return;
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ let text;
+ try {
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ } catch {
+ text = new TextDecoder("euc-kr").decode(bytes);
+ }
+ const next = { ...read(state), ...fromIni(text) };
+ EstApp.change((project) => (project.estimatePrint = next), false);
+ open(state);
+ });
+ picker.click();
+ } else return false;
+ return true;
+ }
+
+ return { settings, rows, total, totalText, pages, toIni, fromIni, html, handle };
+})();
diff --git a/tools/est-test/native-mach-formula.js b/tools/est-test/native-mach-formula.js
new file mode 100644
index 000000000..8a6235c96
--- /dev/null
+++ b/tools/est-test/native-mach-formula.js
@@ -0,0 +1,164 @@
+// 원본 기계경비 공식(TfrmMachGongSic 0xf146f0, 전역 파일 System\MainSys\MachText.Dat: Index·Name·BigoMemo)과
+// 기계경비공식불러오기(TfrmMachText 0xd3d58c: 고른 공식 본문을 줄마다 단가산출 LineData 행으로 현재 행 뒤에 넣음, 계산하지 않음).
+// 웹은 공식 모음을 서버 양식 보관함(종류 machformula)에 둔다. 번호는 늘 1..N.
+// 구공식변환(숨김 단추 0xda5c18 → 0xd90c60): 옛 EST Plus mach_Text.dat 394바이트 레코드 — +5 정수 > 0 이면 새 공식 머리
+// (이름 = +0x16 짧은 문자열 + +0x49 짧은 문자열), 아니면 본문 줄(앞글+뒤글+CR).
+// 원본이 기계경비 연결 때 덧붙이는 요약 4줄(노무비·재료비·경비·소계)은 형식 확인 전이라 넣지 않는다.
+const EstMachFormula = (() => {
+ function parseOld(bytes) {
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+ if (!view.length || view.length % 394)
+ throw Error("구공식 파일 크기가 394바이트 단위가 아닙니다.");
+ const decoder = new TextDecoder("euc-kr"),
+ data = new DataView(view.buffer, view.byteOffset, view.byteLength);
+ const short = (at, max) =>
+ decoder.decode(view.subarray(at + 1, at + 1 + Math.min(view[at], max)));
+ const out = [];
+ let current = null;
+ for (let at = 0; at < view.length; at += 394) {
+ const head = data.getInt32(at + 5, true),
+ text = short(at + 0x16, 50) + short(at + 0x49, 255);
+ if (head > 0) {
+ current = { name: text.slice(0, 100), body: "" };
+ out.push(current);
+ } else if (current) current.body += `${text}\r`;
+ }
+ return out.map((f, i) => ({ ...f, index: i + 1 }));
+ }
+
+ function lines(body) {
+ return String(body ?? "")
+ .split(/\r\n|\r|\n/)
+ .filter((line, i, all) => line !== "" || i < all.length - 1);
+ }
+
+ function insertInto(project, at, body) {
+ const rows = project.tables.CostMainDat;
+ if (!rows?.[at]) throw Error("단가산출에서 넣을 위치(행)를 고르세요.");
+ const add = lines(body);
+ if (!add.length) throw Error("공식 본문이 비어 있습니다.");
+ let pk = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
+ rows.splice(at + 1, 0, ...add.map((LineData) => ({ PkID: ++pk, Index: 0, LineData })));
+ return add.length;
+ }
+
+ // 화면
+ async function load() {
+ return (await EstApp.api("/api/forms"))
+ .filter((f) => f.kind === "machformula")
+ .sort((a, b) => (a.data.index ?? 0) - (b.data.index ?? 0));
+ }
+ async function save(list) {
+ const old = await load();
+ for (const f of old)
+ if (!list.some((n) => n.name === f.name))
+ await EstApp.api("/api/forms/delete", post({ kind: "machformula", name: f.name }));
+ for (const [i, f] of list.entries())
+ await EstApp.api(
+ "/api/forms",
+ post({ kind: "machformula", name: f.name, data: { index: i + 1, body: f.body } }),
+ );
+ }
+ const post = (body) => ({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ async function editor(state) {
+ const list = (await load()).map((f) => ({ name: f.name, body: f.data.body }));
+ state.machFormulas = list;
+ draw(state);
+ }
+ function draw(state) {
+ const { esc } = EstApp,
+ list = state.machFormulas,
+ cost = state.view === "CostMainDat";
+ EstApp.showDialog(
+ "기계경비 공식",
+ `${list
+ .map(
+ (f, i) =>
+ `
${cost ? ` ` : ""}
`,
+ )
+ .join(
+ "
",
+ )}
`,
+ );
+ }
+ function read(state) {
+ for (const node of document.querySelectorAll('#dialog-content [name="mf-name"]'))
+ state.machFormulas[Number(node.dataset.i)].name = node.value.trim();
+ for (const node of document.querySelectorAll('#dialog-content [name="mf-body"]'))
+ state.machFormulas[Number(node.dataset.i)].body = node.value;
+ }
+ function unique(list) {
+ const seen = new Set();
+ for (const f of list) {
+ if (!f.name) throw Error("공식 명칭을 입력하세요.");
+ if (seen.has(f.name)) throw Error(`같은 명칭이 있습니다: ${f.name}`);
+ seen.add(f.name);
+ }
+ }
+
+ async function run(name, state, node) {
+ const i = Number(node?.dataset.i);
+ if (name === "mach-formula") return editor(state);
+ read(state);
+ const list = state.machFormulas;
+ if (name === "mach-formula-append") list.push({ name: `새 공식 ${list.length + 1}`, body: "" });
+ else if (name === "mach-formula-insert")
+ list.splice(i, 0, { name: `새 공식 ${list.length + 1}`, body: "" });
+ else if (name === "mach-formula-delete") list.splice(i, 1);
+ else if (name === "mach-formula-save") {
+ unique(list);
+ await save(list);
+ state.notice = `기계경비 공식 ${list.length}개를 저장했습니다.`;
+ } else if (name === "mach-formula-use") {
+ const at = [...state.selection].sort((a, b) => a - b).at(-1);
+ let count;
+ EstApp.change((project) => (count = insertInto(project, at, list[i].body)));
+ EstApp.byId("dialog").close();
+ state.notice = `「${list[i].name}」 공식 ${count}줄을 넣었습니다.`;
+ return;
+ } else if (name === "mach-formula-old") {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".dat";
+ picker.addEventListener("change", async () => {
+ try {
+ const file = picker.files[0];
+ if (!file) return;
+ for (const f of parseOld(new Uint8Array(await file.arrayBuffer())))
+ list.push({ name: f.name, body: f.body });
+ draw(state);
+ } catch (error) {
+ state.notice = error.message;
+ EstApp.render();
+ }
+ });
+ picker.click();
+ return;
+ }
+ draw(state);
+ }
+ const names = new Set([
+ "mach-formula",
+ "mach-formula-append",
+ "mach-formula-insert",
+ "mach-formula-delete",
+ "mach-formula-save",
+ "mach-formula-use",
+ "mach-formula-old",
+ ]);
+ function handle(name, state, node) {
+ if (!names.has(name)) return false;
+ run(name, state, node).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+
+ return { parseOld, lines, insertInto, handle };
+})();
diff --git a/tools/est-test/native-print-batch.js b/tools/est-test/native-print-batch.js
new file mode 100644
index 000000000..36f4ad5c4
--- /dev/null
+++ b/tools/est-test/native-print-batch.js
@@ -0,0 +1,107 @@
+// 원본 묶음 인쇄(TEstPrint.Print 0x10fef98, TFRMESTPRINT2·TFRMPRINTLISTCHECK·TFRMESTPRINTPRIVATE 간지).
+// 인쇄표 차례대로(공통 설정 행 Index 0 제외) 고른(Checked) 서식마다 간지(PrintGangiShow, 가운데 제목 Name2 · 겹테두리) → 서식 본문.
+// 웹은 한 문서로 이어 인쇄하고 쪽 번호는 브라우저 쪽 번호를 쓴다(원본은 간지가 쪽 번호를 쓰지 않음 — 웹은 간지 쪽에 번호만 숨김).
+// 서식 본문은 인쇄 미리보기와 같은 자료(EstForms.data · EstDocs.html). 화면 전용 서식은 건너뛰고 알린다.
+const EstPrintBatch = (() => {
+ const esc = (v) =>
+ String(v ?? "").replace(
+ /[&<>"]/g,
+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
+ );
+
+ function forms(catalog, project) {
+ const saved = project.printBatch?.checked;
+ return catalog
+ .filter((row) => Number(row.Index) !== 0)
+ .map((row) => ({
+ Index: row.Index,
+ Name: row.Name2 || row.Name || String(row.Index),
+ checked: saved ? saved.includes(row.Index) : Number(row.Checked) === 1,
+ gangi: project.printBatch?.gangi ?? Number(row.PrintGangiShow) === 1,
+ outline: Number(row.PrintGangiOutLineShow ?? 1) === 1,
+ }));
+ }
+
+ function body(index, project, state) {
+ const output = EstForms.data(index, project, state);
+ if (output) return EstForms.grid(output);
+ const doc = typeof EstDocs !== "undefined" ? EstDocs.html(Number(index), project) : "";
+ return doc || null;
+ }
+
+ function document_(list, project, state, gangi) {
+ const skipped = [],
+ parts = [];
+ for (const form of list.filter((f) => f.checked)) {
+ const html = body(form.Index, project, state);
+ if (html == null) {
+ skipped.push(form.Name);
+ continue;
+ }
+ if (gangi)
+ parts.push(
+ ``,
+ );
+ parts.push(``);
+ }
+ const html = `${esc(project.name)} 설계서${parts.join("")}`;
+ return { html, skipped, count: parts.length };
+ }
+
+ let catalog = [];
+ async function open(state) {
+ const defaults = await EstApp.api("/api/print-catalog");
+ const original = new Map((state.project.printCatalog || []).map((row) => [row.Index, row]));
+ catalog = defaults.map((row) => ({ ...row, ...original.get(row.Index) }));
+ render(state);
+ }
+ function render(state) {
+ const list = forms(catalog, state.project);
+ EstApp.showDialog(
+ "묶음 인쇄(설계서 일체)",
+ `인쇄할 서식을 고르세요. 인쇄표 차례대로 이어 인쇄합니다.
${list.map((f) => ``).join("")}
`,
+ );
+ }
+ function read(state) {
+ const checked = [...document.querySelectorAll('#dialog-content [name="pb-form"]:checked')].map(
+ (n) => (/^\d+$/.test(n.value) ? Number(n.value) : n.value),
+ );
+ const gangi = document.querySelector('#dialog-content [name="pb-gangi"]').checked;
+ if (!checked.length) throw Error("인쇄할 서식을 하나 이상 고르세요.");
+ const next = { checked, gangi };
+ if (JSON.stringify(next) !== JSON.stringify(state.project.printBatch))
+ EstApp.change((project) => (project.printBatch = next), false);
+ return next;
+ }
+ function build(state) {
+ const { gangi } = read(state),
+ list = forms(catalog, state.project),
+ out = document_(list, state.project, state, gangi),
+ frame = EstApp.byId("print-batch-frame");
+ frame.srcdoc = out.html;
+ EstApp.byId("print-batch-note").textContent =
+ `서식·간지 ${out.count}개${out.skipped.length ? ` · 화면 전용이라 뺀 서식: ${out.skipped.join(", ")}` : ""}`;
+ return frame;
+ }
+
+ function handle(name, state) {
+ if (name === "print-batch")
+ open(state).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ else if (name === "print-batch-preview") build(state);
+ else if (name === "print-batch-print") {
+ const frame = build(state);
+ frame.onload = () => frame.contentWindow.print();
+ } else return false;
+ return true;
+ }
+
+ return { forms, document: document_, handle };
+})();
diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js
index 9d91345b7..d02a6c609 100644
--- a/tools/est-test/native-quantity-sheets.js
+++ b/tools/est-test/native-quantity-sheets.js
@@ -106,8 +106,53 @@ const EstQuantitySheets = (() => {
return editor;
}
+ // 원본 수량산출 검색(TFRMMULLANG btnSerch·btnAllDisplay): 명칭·규격에서 빈칸·|·괄호를 빼고 포함 검색.
+ const loose = (text) =>
+ String(text ?? "")
+ .replace(/[\s|()[\]{}]/g, "")
+ .toLowerCase();
+ function matches(sheet, query) {
+ const q = loose(query);
+ if (!q) return true;
+ return [sheet.Name, sheet.Code, ...(sheet.items || []).flatMap((i) => [i.Name, i.Size])].some(
+ (t) => loose(t).includes(q),
+ );
+ }
+
+ // 수량산출서 고르기 줄(원본 공종 목록 + 검색·전체보기).
+ function selector(state) {
+ const { esc } = EstApp;
+ const options = (state.project.quantitySheets || [])
+ .filter((sheet) => matches(sheet, state.quantityQuery) || sheet.Code === state.quantitySheet)
+ .map(
+ (sheet) =>
+ ``,
+ )
+ .join("");
+ return ``;
+ }
+
function handle(name, state, node) {
const code = state.quantitySheet;
+ if (name === "quantity-sheet-search" || name === "quantity-sheet-all") {
+ state.quantityQuery =
+ name === "quantity-sheet-all"
+ ? ""
+ : (document.querySelector("[data-quantity-query]")?.value ?? "");
+ const first = (state.project.quantitySheets || []).find((s) =>
+ matches(s, state.quantityQuery),
+ );
+ if (state.quantityQuery && !first) throw Error("찾는 수량산출서가 없습니다.");
+ if (
+ first &&
+ !matches(
+ state.project.quantitySheets.find((s) => s.Code === code) || {},
+ state.quantityQuery,
+ )
+ )
+ state.quantitySheet = first.Code;
+ return true;
+ }
if (name === "quantity-sheet-copy") {
EstApp.change((project) => {
state.quantitySheet = copy(project, code, EstApp.uid());
@@ -120,6 +165,15 @@ const EstQuantitySheets = (() => {
if (index < 0 || index + step < 0 || index + step >= sheets.length)
throw Error("더 옮길 수 없습니다.");
EstApp.change((project) => move(project, code, step), false);
+ } else if (name === "quantity-total") {
+ const sheet = state.project.quantitySheets?.find((next) => next.Code === code),
+ index = Number(node?.dataset.index),
+ editor = lineEditor(index);
+ if (!sheet) throw Error("수량산출서를 고르세요.");
+ EstApp.change(
+ () => EstQuantityTotal.insert(sheet, index, editor.selectionStart ?? 0, editor.value),
+ false,
+ );
} else if (name === "quantity-mark" || name === "quantity-free") {
const sheet = state.project.quantitySheets?.find((next) => next.Code === code),
index = Number(node?.dataset.index),
@@ -167,5 +221,5 @@ const EstQuantitySheets = (() => {
.join("");
}
- return { copy, move, mark, free, excelView, handle };
+ return { copy, move, mark, free, excelView, matches, selector, handle };
})();
diff --git a/tools/est-test/native-quantity-total.js b/tools/est-test/native-quantity-total.js
new file mode 100644
index 000000000..a282a20ee
--- /dev/null
+++ b/tools/est-test/native-quantity-total.js
@@ -0,0 +1,61 @@
+// 원본 수량산출 누계(TfrmMulWord ActionAdd Ctrl+U → 0x11155c4, 재계산 0x111c0b0 에서 F·a 표지 다시 계산).
+// 누계 값 = 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 누계 줄 바로 위까지, 줄마다 첫 결과 수량의 합.
+// 앞 누계는 더하지 않는 소계다. 반올림은 표지 이름 뒤 방식·자리(F000003 + 1 + 2), 없으면 산출서 설정.
+// 웹은 누계를 \x16 쌍으로 적고 이름을 row.totals 에 둔다. 「누계」·「∴」 같은 글자는 넣지 않는다.
+const EstQuantityTotal = (() => {
+ const first = (line) => {
+ const match = String(line ?? "").match(/\x12([-\d.,]+)\x12/);
+ return match ? Number(match[1].replaceAll(",", "")) || 0 : 0;
+ };
+ function format(value, digits) {
+ return value.toLocaleString("en-US", { maximumFractionDigits: digits, useGrouping: true });
+ }
+ function rounding(sheet, name) {
+ const match = String(name ?? "").match(/^[Fa]\d{6}([123])(\d)$/);
+ return {
+ method: match ? Number(match[1]) : (sheet.options?.roundMode ?? 2),
+ digits: match ? Number(match[2]) : (sheet.options?.digits ?? 2),
+ };
+ }
+ // rows: 계산이 끝난 줄(LineData 의 결과 값이 갱신된 것). 누계 줄의 \x16 값을 고쳐 쓴다.
+ function apply(sheet, rows) {
+ let start = 0;
+ rows.forEach((row, i) => {
+ const line = String(row.LineData ?? "");
+ if (line.includes("\x16")) {
+ let sum = 0;
+ for (let j = start; j < i; j++) sum += first(rows[j].LineData);
+ const names = sheet.rows[i]?.totals || [];
+ let part = 0;
+ row.LineData = line.replace(/\x16[^\x16]*\x16/g, () => {
+ const { method, digits } = rounding(sheet, names[part++]);
+ return `\x16${format(EstMath.fixed(sum, digits, method), digits)}\x16`;
+ });
+ start = i + 1;
+ } else if (line.includes("\x0e")) start = i;
+ });
+ return rows;
+ }
+ // 커서 자리에 새 누계를 넣는다. text 는 화면 표기(⟦누계⟧) 줄, caret 은 그 안의 위치.
+ function insert(sheet, rowIndex, caret, text) {
+ const row = sheet.rows[rowIndex];
+ if (!row) throw Error("누계를 넣을 산출 줄을 고르세요.");
+ const used = new Set(sheet.rows.flatMap((r) => (r.totals || []).map((n) => n.slice(1, 7))));
+ let n = 1;
+ while (used.has(String(n).padStart(6, "0"))) n++;
+ const { method, digits } = rounding(sheet, "");
+ const name = `F${String(n).padStart(6, "0")}${method}${digits}`,
+ shown = text ?? EstText.display(row.LineData),
+ at = Math.max(0, Math.min(caret, shown.length)),
+ position = shown.slice(0, at).split("⟦누계⟧").length - 1;
+ const before = shown.slice(0, at);
+ if (position % 2 || before.lastIndexOf("⟦") > before.lastIndexOf("⟧"))
+ throw Error("누계 표지 안에는 넣을 수 없습니다.");
+ row.LineData = EstText.encode(`${shown.slice(0, at)}⟦누계⟧0⟦누계⟧${shown.slice(at)}`);
+ row.totals = [...(row.totals || [])];
+ row.totals.splice(position / 2, 0, name);
+ sheet.markerEdited = true;
+ return name;
+ }
+ return { apply, insert, rounding };
+})();
diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs
index 54717dff3..ea9bb7925 100644
--- a/tools/est-test/native-quantity.cjs
+++ b/tools/est-test/native-quantity.cjs
@@ -26,16 +26,19 @@ const EstQuantity = (() => {
if (name) tags.set(match[1], name);
}
for (const match of text.matchAll(/]*>([\s\S]*?)<\/div>/gi)) {
- const markers = [];
+ const markers = [],
+ totals = [];
let raised = false;
const tag = (name) =>
/^r\d+$/.test(name) || /^L\d+$/.test(name)
? "\x12"
- : /^N\d+$/.test(name)
- ? "\x0e"
- : /^S\d+$/.test(name)
- ? "\x13"
- : "";
+ : /^[Fa]\d+$/.test(name)
+ ? "\x16"
+ : /^N\d+$/.test(name)
+ ? "\x0e"
+ : /^S\d+$/.test(name)
+ ? "\x13"
+ : "";
let body = match[1].replace(/
/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
@@ -47,11 +50,12 @@ const EstQuantity = (() => {
.replace(/
]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
const name = tags.get(id) || "";
if (tag(name) === "\x12") markers.push(name);
+ if (tag(name) === "\x16") totals.push(name);
return tag(name);
})
.replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || ""));
body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, ""));
- if (body.trim()) rows.push({ LineData: body, markers });
+ if (body.trim()) rows.push({ LineData: body, markers, ...(totals.length && { totals }) });
}
return { Code: code, Name: name, rows, items, options };
}
@@ -78,7 +82,7 @@ const EstQuantity = (() => {
};
return {
...row,
- LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴/g, ""),
+ LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""),
Index: 0,
SCheck: 0,
LinkIndex: 0,
@@ -112,6 +116,8 @@ const EstQuantity = (() => {
(whole) => actual[part++]?.[0] || whole,
);
});
+ if (typeof EstQuantityTotal !== "undefined")
+ EstQuantityTotal.apply(sheet, result.rows.slice(1));
return {
rows: result.rows.slice(1),
items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })),
@@ -217,8 +223,7 @@ const EstQuantity = (() => {
return `Q${index}`;
}
function selector(state) {
- const { esc } = EstApp;
- return ``;
+ return typeof EstQuantitySheets === "undefined" ? "" : EstQuantitySheets.selector(state);
}
function validExcelAddress(address) {
const parts = typeof address === "string" && address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
@@ -315,7 +320,7 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
- `${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
${sheet.rows.map((row, index) => `| ${index + 1} | | |
`).join("")}
`;
+ `${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
${sheet.rows.map((row, index) => `| ${index + 1} | | |
`).join("")}
`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
diff --git a/tools/est-test/native-report.js b/tools/est-test/native-report.js
index 3d5af7630..17065bd10 100644
--- a/tools/est-test/native-report.js
+++ b/tools/est-test/native-report.js
@@ -34,7 +34,11 @@ const EstReport = (() => {
if (output) root.innerHTML = EstForms.grid(output);
else if (form) {
const total = state.project.calculation?.total || 0;
- root.innerHTML = `${esc(settings.notes)}
${[8, 9].includes(form.Index) ? `공사금액 ${number(total)}원
${esc(settings.company)}
` : ""}`;
+ const doc =
+ typeof EstDocs !== "undefined" ? EstDocs.html(Number(form.Index), state.project) : "";
+ root.innerHTML = doc
+ ? `${doc}${settings.notes ? `${esc(settings.notes)}
` : ""}`
+ : `${esc(settings.notes)}
${[8, 9].includes(form.Index) ? `공사금액 ${number(total)}원
${esc(settings.company)}
` : ""}`;
} else if (state.project.tables[state.view]) {
const rows = all
? state.project.tables[state.view].map((row, index) => ({ row, index }))
@@ -118,7 +122,8 @@ const EstReport = (() => {
}
function paginate(root, settings) {
const table = root.querySelector("table");
- if (!table || root.querySelectorAll("table").length !== 1) return root.innerHTML;
+ if (!table || root.querySelectorAll("table").length !== 1 || table.closest(".document-gabgi"))
+ return root.innerHTML;
const rows = [...table.querySelectorAll("tbody>tr")],
pages = [];
for (let at = 0; at < rows.length; at += settings.rowsPerPage) {
diff --git a/tools/est-test/native-review-auto.js b/tools/est-test/native-review-auto.js
new file mode 100644
index 000000000..8d7791b44
--- /dev/null
+++ b/tools/est-test/native-review-auto.js
@@ -0,0 +1,69 @@
+// 원본 주요심사내용 창(TfrmInvest) 자동입력 — 심사평가결과(0x10bf3xx)·주요심사내용(0x10bfc98).
+// 심사평가결과: 원가계산서에서 이름(빈칸·| 제거)에 「총공사비」가 든 첫 행의 Cost_(설계)·Cost(심사), 절감액 = 심사 − 설계,
+// 절감율 = Trunc(절감액 / 설계 × 10000) × 0.01 %(둘 다 0 아닐 때), 숫자는 cp949 바이트 16칸 오른쪽 맞춤, 증액에만 「+」.
+// 주요심사내용: 노임·자재·표준시장·견적·기계경비·단가산출·일위대가 차례로 NewItem 행이 있으면
+// 「○○ 조정 : 이름(규격) 외N 종」(N = 첫 행을 포함한 전체 개수, 원본 셈 그대로) 한 줄씩.
+const EstReviewAuto = (() => {
+ const num = (value) => Number(value) || 0;
+ const strip = (text) => String(text ?? "").replace(/[\s|]/g, "");
+ const bytes = (text) => [...text].reduce((n, ch) => n + (ch.charCodeAt(0) > 0x7f ? 2 : 1), 0);
+ const right16 = (text) => " ".repeat(Math.max(0, 16 - bytes(text))) + text;
+ const fmt = (value) =>
+ Number(value).toLocaleString("en-US", { maximumFractionDigits: 9, useGrouping: true });
+
+ function result(project) {
+ const row = (project.tables.Wonga || []).find((r) => strip(r.Name).includes("총공사비"));
+ if (!row) return null;
+ const design = num(row.Cost_),
+ review = num(row.Cost),
+ d = review - design,
+ sign = d > 0 ? "+" : "";
+ const lines = [
+ `o 설계금액 : ${right16(fmt(design))} 원`,
+ `o 심사금액 : ${right16(fmt(review))} 원`,
+ `o 절 감 액 : ${right16(sign + fmt(d))} 원`,
+ ];
+ if (d && design) {
+ const r = Math.trunc((d / design) * 10000) * 0.01;
+ lines.push(`o 절 감 율 : ${right16(sign + fmt(Number(r.toFixed(2))))} %`);
+ }
+ return lines.join("\n");
+ }
+
+ const tables = [
+ ["LaborcDat", "노임단가", false],
+ ["StuffcDat", "자재단가", true],
+ ["SilJukDat", "표준시장단가", true],
+ ["EstiMateDat", "견적단가", true],
+ ["MachTitleDat", "기계경비", true],
+ ["CostTitleDat", "단가산출", true],
+ ["HeadTitleDat", "일위대가", true],
+ ];
+ function content(project) {
+ return tables
+ .map(([table, label, size]) => {
+ const rows = (project.tables[table] || []).filter((r) => r.NewItem);
+ if (!rows.length) return "";
+ const first = rows[0];
+ return `${label} 조정 : ${strip(first.Name)}${size ? `(${strip(first.Size)})` : ""} 외${rows.length} 종`;
+ })
+ .filter(Boolean)
+ .join("\n");
+ }
+
+ function handle(name, state) {
+ if (name !== "review-auto-result" && name !== "review-auto-content") return false;
+ const key = name === "review-auto-result" ? "173-result" : "173",
+ text = name === "review-auto-result" ? result(state.project) : content(state.project);
+ if (text == null) throw Error("원가계산서에 총공사비 행이 없습니다.");
+ const old = state.project.documents?.[key];
+ if (old && !confirm("기존 내용을 지우고 자동입력하시겠습니까?")) return true;
+ EstApp.change((project) => {
+ project.documents ||= {};
+ project.documents[key] = text;
+ }, false);
+ return true;
+ }
+
+ return { result, content, handle };
+})();
diff --git a/tools/est-test/native-review.js b/tools/est-test/native-review.js
index 6e69c368a..d2c4e5191 100644
--- a/tools/est-test/native-review.js
+++ b/tools/est-test/native-review.js
@@ -101,7 +101,11 @@ const EstReview = (() => {
delete next.contractReview;
next.name = `${project.name} · 심사 내역`;
next.tables.EstMainDat = output.reviewed.tables.EstMainDat;
- next.tables.Wonga = output.reviewed.tables.Wonga;
+ // 원본 심사 자료처럼 설계(심사 전) 원가 금액을 Cost_ 에 남긴다(심사평가결과 설계금액).
+ next.tables.Wonga = output.reviewed.tables.Wonga.map((row, i) => ({
+ ...row,
+ Cost_: EstMath.num(output.baseline.tables.Wonga?.[i]?.Cost),
+ }));
next.options.SourceRefresh = false;
next.calculation = output.reviewed.calculation;
return next;
@@ -115,7 +119,7 @@ const EstReview = (() => {
({ row }) => editable(row) && (row.Name || row.Code),
);
byId("work-content").innerHTML =
- `계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}
심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.
${esc(output.errors.join(" / "))}
${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => `| ${value} | `).join("")}
${rows
+ `계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}
심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.
${esc(output.errors.join(" / "))}
${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => `| ${value} | `).join("")}
${rows
.filter(({ row, index }) => {
const key = rowKey(row, index),
input = edits[key] || {},
@@ -165,6 +169,13 @@ const EstReview = (() => {
EstApp.render();
return;
}
+ if (node.dataset.reviewResult !== undefined) {
+ EstApp.change(
+ () => ((EstApp.state.project.documents ||= {})["173-result"] = node.value),
+ false,
+ );
+ return;
+ }
if (node.dataset.reviewSummary !== undefined) {
EstApp.change(() => {
const project = EstApp.state.project;
diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js
index b5d28302f..7a4d9f387 100644
--- a/tools/est-test/native-rows.js
+++ b/tools/est-test/native-rows.js
@@ -163,6 +163,7 @@ const EstRows = (() => {
segment = [];
continue;
}
+ if (link === 53) continue;
if (link === 54) {
Object.assign(row, add(prior));
boundary = true;
@@ -192,6 +193,19 @@ const EstRows = (() => {
segment.push(row);
if (!boundary) prior.push(row);
}
+ // 원본 하단으로 집계(popMainDownSum 0xf01a90)의 「소 계」 행(53): 닫는 공종(안쪽부터)의 상세 행 합.
+ const open = [];
+ output.forEach((row, i) => {
+ const level = num(row.Level);
+ if (level > 0) {
+ while (open.length && open.at(-1).level <= level) open.pop();
+ open.push({ level, start: i });
+ } else if (num(row.LinkIndex) === 53) {
+ const group = open.pop();
+ const body = group ? output.slice(group.start + 1, i) : [];
+ Object.assign(row, add(body.filter((r) => !num(r.Level) && num(r.LinkIndex) !== 53)));
+ }
+ });
for (let i = output.length - 1; i >= 0; i--) {
const row = output[i],
level = num(row.Level);
diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js
index e80a27772..71d0c8ed5 100644
--- a/tools/est-test/native-text.js
+++ b/tools/est-test/native-text.js
@@ -615,11 +615,12 @@ const EstText = (() => {
"\x12": "결과",
"\x13": "규격",
"\x15": "단위",
+ "\x16": "누계",
"\x1a": "합계",
};
const display = (text) =>
String(text || "").replace(
- /[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x1a]/g,
+ /[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g,
(ch) => `⟦${names[ch]}⟧`,
);
const encode = (text) =>
diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js
index 51de43517..41d3c55e0 100644
--- a/tools/est-test/native-views.js
+++ b/tools/est-test/native-views.js
@@ -76,7 +76,7 @@ const EstViews = (() => {
}
function grid(table, list, columns) {
const { esc, number, state } = EstApp;
- return ``;
+ return ``;
}
function render(state) {
const { project, view } = state,
diff --git a/tools/est-test/native-wonga-tools.js b/tools/est-test/native-wonga-tools.js
new file mode 100644
index 000000000..65eefc12d
--- /dev/null
+++ b/tools/est-test/native-wonga-tools.js
@@ -0,0 +1,193 @@
+// 원본 원가계산서 보조 계산(TfrmWongaDiget) — 기타경비 계산(popGita 모드 4, 0xd0bc80·확인 0xd09c89)과
+// 산업안전보건관리비 비교(popAnjun 모드 1, 식 0xd0b298·확인 0xd09973). 요율·기초금액은 사용자가 넣는다(내장 요율표 없음).
+// 기타경비: 식1 = (직접공사비 × 재료비율) + 직접노무비 + 간접노무비, 식2 = 직접공사비 × 기초금액상한율 — 작은 기초금액 × 요율.
+// 안전관리비: 대상액 T(직접공사비, 없으면 재료비 소계 + 비과세 + 직접노무비), 관급 = 「관급자재」 행.
+// 기초금액 켬: Up (T × r + B) × 1.2 · Down (T + 관급/1.1) × r + B / 끔: Up T × r × 1.2 · Down (T + 관급/1.1) × r — 작은 쪽.
+// 고르지 않은 식은 「비교 표시」를 켜면 바로 아래 계산 제외 행(CalcIndex 2)으로 남긴다.
+const EstWongaTools = (() => {
+ const num = (value) => Number(value) || 0;
+ const plain = (text) => String(text ?? "").replace(/\s+/g, "");
+ const key = (row) => row?.Gubun || row?.NumStr || "";
+ const find = (rows, ...words) =>
+ rows.find((r) => num(r.CalcIndex) !== 2 && words.every((w) => plain(r.Name).includes(w)));
+ const decimal = (value) => String(Number(value.toFixed(10)));
+
+ function evaluate(rows, expression) {
+ const costs = new Map(rows.filter((r) => key(r)).map((r) => [String(key(r)), num(r.Cost)]));
+ return EstExpression.evaluate(expression, (name) => costs.get(String(name)), {
+ numericReferences: true,
+ });
+ }
+
+ function compareRow(rows, target, expression, show) {
+ const at = rows.indexOf(target),
+ existing = rows.findIndex(
+ (r, i) => i > at && num(r.CalcIndex) === 2 && plain(r.Name) === plain(target.Name),
+ );
+ if (!show) {
+ if (existing >= 0) rows.splice(existing, 1);
+ return;
+ }
+ const row =
+ existing >= 0 ? rows[existing] : (rows.splice(at + 1, 0, { ...target }), rows[at + 1]);
+ Object.assign(row, {
+ Gubun: "",
+ NumStr: "",
+ Bimog1: "",
+ Bimog2: "",
+ VSusic: expression,
+ CalcIndex: 2,
+ Checked: false,
+ LinkIndex: 0,
+ });
+ }
+
+ function gita(project, { gcRatio, baseRatio, youl, compare }) {
+ const rows = project.tables.Wonga || [];
+ if (![gcRatio, baseRatio].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
+ throw Error("1 이하의 수치값으로 입력 하세요");
+ if (!(Number.isFinite(youl) && youl >= 0 && youl <= 10))
+ throw Error("기타경비요율은 0~10% 입니다.");
+ const target = find(rows, "기타", "경비"),
+ a = find(rows, "직접공사비"),
+ b = find(rows, "직접노무비"),
+ c = find(rows, "간접노무비");
+ if (!target) throw Error("기타경비 비목이 없습니다.");
+ if (!a) throw Error("직접공사비 비목이 없어 기타경비를 계산할 수 없습니다.");
+ const first = [`(${key(a)} × ${decimal(gcRatio)})`, key(b), key(c)].filter(Boolean).join(" + "),
+ second = `${key(a)} × ${decimal(baseRatio)}`;
+ const v1 = evaluate(rows, first),
+ v2 = evaluate(rows, second),
+ rate = decimal(youl / 100);
+ const up = `(${first}) × ${rate}`,
+ down = `(${second}) × ${rate}`,
+ [chosen, other] = v2 >= v1 ? [up, down] : [down, up];
+ target.VSusic = chosen;
+ target.Youl = `${decimal(youl)}%`;
+ compareRow(rows, target, other, compare);
+ project.options.GitaGCRatio = gcRatio;
+ project.options.GitaBaseRatio = baseRatio;
+ return { chosen, other, values: [v1, v2] };
+ }
+
+ function anjunBase(rows) {
+ const direct = find(rows, "직접공사비");
+ if (direct) return key(direct);
+ const dm = find(rows, "직접재료비"),
+ im = find(rows, "간접재료비");
+ const subtotal = rows.find(
+ (r) =>
+ dm &&
+ String(r.VSusic ?? "").includes(key(dm)) &&
+ (!im || String(r.VSusic ?? "").includes(key(im))),
+ );
+ let t = subtotal ? key(subtotal) : [dm, im].filter(Boolean).map(key).join(" + ");
+ const free = find(rows, "비과세") || find(rows, "면세");
+ if (free) t += ` + ${key(free)}`;
+ const labor = find(rows, "직접노무비");
+ if (labor) t = `(${t} + ${key(labor)})`;
+ if (!t) throw Error("산업안전보건관리비 대상액 비목을 찾을 수 없습니다.");
+ return t;
+ }
+
+ function anjun(project, { youl, base, addBase, compare }) {
+ const rows = project.tables.Wonga || [];
+ if (!(Number.isFinite(youl) && youl >= 0 && youl <= 15)) throw Error("요율은 0~15% 입니다.");
+ const target = find(rows, "산업안전", "보건");
+ if (!target) throw Error("산업안전보건관리비 비목이 없습니다.");
+ const t = anjunBase(rows),
+ gov = find(rows, "관급자재"),
+ r = decimal(youl / 100),
+ b = addBase ? ` + ${Math.round(base)}` : "";
+ const up = addBase ? `(${t} × ${r}${b}) × 1.2` : `${t} × ${r} × 1.2`;
+ const down = gov && num(gov.Cost) ? `(${t} + ${key(gov)}/1.1) × ${r}${b}` : `${t} × ${r}${b}`;
+ const vu = evaluate(rows, up),
+ vd = evaluate(rows, down),
+ [chosen, other] = vd > vu ? [up, down] : [down, up];
+ target.VSusic = chosen;
+ target.Youl = `${decimal(youl)}%`;
+ compareRow(rows, target, other, compare);
+ project.options.AnjunBaseCost = base;
+ return { chosen, other, values: [vu, vd] };
+ }
+
+ function dialog(state, kind) {
+ const rows = state.project.tables.Wonga || [],
+ o = state.project.options,
+ rate = (row) => Number(String(row?.Youl ?? "").split("%")[0]) || 0;
+ const fields =
+ kind === "gita"
+ ? [
+ { name: "gcRatio", label: "재료비율(0~1)", type: "number", value: o.GitaGCRatio ?? "" },
+ {
+ name: "baseRatio",
+ label: "기초금액상한율(0~1)",
+ type: "number",
+ value: o.GitaBaseRatio ?? "",
+ },
+ {
+ name: "youl",
+ label: "기타경비요율(%)",
+ type: "number",
+ value: rate(find(rows, "기타", "경비")),
+ },
+ ]
+ : [
+ {
+ name: "youl",
+ label: "안전관리비 요율(%)",
+ type: "number",
+ value: rate(find(rows, "산업안전", "보건")),
+ },
+ {
+ name: "addBase",
+ label: "기초금액 더하기",
+ value: "no",
+ choices: [
+ ["no", "더하지 않음"],
+ ["yes", "더함"],
+ ],
+ },
+ {
+ name: "base",
+ label: "기초금액(원)",
+ type: "number",
+ value: o.AnjunBaseCost ?? 5349000,
+ },
+ ];
+ fields.push({
+ name: "compare",
+ label: "비교 식 표시",
+ value: "yes",
+ choices: [
+ ["yes", "아래 줄에 표시"],
+ ["no", "표시 안 함"],
+ ],
+ });
+ EstActions.form(
+ kind === "gita" ? "기타경비 계산" : "산업안전보건관리비 비교",
+ fields,
+ (values) => {
+ const input = {
+ ...values,
+ compare: values.compare === "yes",
+ addBase: values.addBase === "yes",
+ };
+ const run = (project) => (kind === "gita" ? gita(project, input) : anjun(project, input));
+ run(structuredClone(state.project));
+ let out;
+ EstApp.change((project) => (out = run(project)));
+ state.notice = `작은 쪽 식을 골랐습니다: ${out.chosen}`;
+ },
+ );
+ }
+
+ function handle(name, state) {
+ if (name === "wonga-gita") dialog(state, "gita");
+ else if (name === "wonga-anjun") dialog(state, "anjun");
+ else return false;
+ return true;
+ }
+
+ return { gita, anjun, anjunBase, handle };
+})();
diff --git a/tools/est-test/native-xml-check.js b/tools/est-test/native-xml-check.js
new file mode 100644
index 000000000..457e7b399
--- /dev/null
+++ b/tools/est-test/native-xml-check.js
@@ -0,0 +1,127 @@
+// 원본 조달청 XML 내보내기 전 검사(TfrmJDLConvert 0xe7d4bc)와 자리수자동수정(TfrmXMLDigetCheck btnOk 0xcc848c).
+// 오류 1: 단가산출에서 비목 지정 값(SCheck −5~−1) 또는 −20 행부터 소계(61)까지의 재료·노임 연결(5·6) 행에 행별 자리수.
+// 오류 8: 7개 단가 표 코드 중복 · 10: 코드에 영문 소문자 · 11: 기계경비 코드 길이 ≠ 17.
+// 자리수자동수정은 오류 1 행의 Diget·DigetIndex 만 0 으로 풀고 다시 계산한다(특정 자리로 고치지 않음).
+// 웹은 기존 XML 내보내기를 막지 않고 별도 검사 창으로 알린다.
+const EstXmlCheck = (() => {
+ const num = (value) => Number(value) || 0;
+ const tables = [
+ "LaborcDat",
+ "StuffcDat",
+ "EstiMateDat",
+ "SilJukDat",
+ "MachTitleDat",
+ "CostTitleDat",
+ "HeadTitleDat",
+ ];
+ const explain = {
+ 1: "비목값에 자리수지정",
+ 8: "코드 중복",
+ 10: "코드에 소문자사용",
+ 11: "장비코드 길이가틀림(17자리)",
+ };
+
+ function check(project) {
+ const out = [];
+ let head = null,
+ flag = false;
+ (project.tables.CostMainDat || []).forEach((row, index) => {
+ if (num(row.Index) > 0) head = row;
+ if (num(row.Index) === -20) flag = true;
+ if (num(row.LinkIndex) === 61) flag = false;
+ const digit = num(row.DigetIndex) || num(row.Diget),
+ sc = num(row.SCheck);
+ if (digit && ((sc >= -5 && sc <= -1) || (flag && [5, 6].includes(num(row.LinkIndex)))))
+ out.push({
+ kind: 1,
+ table: "CostMainDat",
+ index,
+ code: head?.Code ?? "",
+ name: head?.Name ?? "",
+ text: EstText.clean(row.LineData || ""),
+ });
+ });
+ for (const table of tables) {
+ const seen = new Map();
+ (project.tables[table] || []).forEach((row, index) => {
+ if (!row.Code) return;
+ const first = seen.get(row.Code);
+ if (first)
+ out.push({
+ kind: 8,
+ table,
+ index,
+ code: row.Code,
+ name: row.Name ?? "",
+ text: `${num(first.Index)}.${first.Name ?? ""}(${first.Size ?? ""}) 와 코드 중복`,
+ });
+ else seen.set(row.Code, row);
+ if (/[a-z]/.test(row.Code))
+ out.push({
+ kind: 10,
+ table,
+ index,
+ code: row.Code,
+ name: row.Name ?? "",
+ text: explain[10],
+ });
+ if (table === "MachTitleDat" && String(row.Code).length !== 17)
+ out.push({
+ kind: 11,
+ table,
+ index,
+ code: row.Code,
+ name: row.Name ?? "",
+ text: explain[11],
+ });
+ });
+ }
+ return out;
+ }
+
+ function fix(project) {
+ const rows = project.tables.CostMainDat || [];
+ const targets = check(project).filter((e) => e.kind === 1);
+ for (const e of targets) Object.assign(rows[e.index], { Diget: 0, DigetIndex: 0 });
+ return targets.length;
+ }
+
+ function show(state) {
+ const list = check(state.project),
+ { esc } = EstApp,
+ counts = [1, 8, 10, 11].map(
+ (k) => `${explain[k]} ${list.filter((e) => e.kind === k).length}건`,
+ );
+ EstApp.showDialog(
+ "조달청 XML 검사",
+ `${counts.join(" · ")}
| 구분 | 표 | 코드 | 품명 | 내용 |
${list
+ .slice(0, 300)
+ .map(
+ (e) =>
+ `| ${esc(explain[e.kind])} | ${esc(EstApp.titles?.[e.table] || e.table)} | ${esc(e.code)} | ${esc(e.name)} | ${esc(e.text)} |
`,
+ )
+ .join(
+ "",
+ )}
${list.some((e) => e.kind === 1) ? '' : ""}`,
+ );
+ }
+
+ function handle(name, state) {
+ if (name === "xml-check") show(state);
+ else if (name === "xml-diget-fix") {
+ if (
+ !confirm(
+ "자리수 수정후 재계산 시키므로공사금액이 변경될수 있습니다.자리수를 수정하시겠습니까?",
+ )
+ )
+ return true;
+ let count;
+ EstApp.change((project) => (count = fix(project)));
+ state.notice = `단가산출 ${count}줄의 자리수 지정을 풀고 다시 계산했습니다.`;
+ show(state);
+ } else return false;
+ return true;
+ }
+
+ return { check, fix, handle };
+})();
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 678d80657..3f14f4527 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -21,6 +21,7 @@ const scripts = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-total.js",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
@@ -72,6 +73,16 @@ const allowed = new Set([
"native-name-reg.js",
"native-tidy.js",
"native-multi-search.js",
+ "native-docs.js",
+ "native-wonga-tools.js",
+ "native-estimate-print.js",
+ "native-xml-check.js",
+ "native-review-auto.js",
+ "native-autolink.js",
+ "native-est-tools.js",
+ "native-print-batch.js",
+ "native-mach-formula.js",
+ "native-defaults.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",
@@ -184,6 +195,19 @@ const server = http.createServer(async (req, res) => {
reply(res, 200, forms.remove(dataDirectory, input.kind, input.name));
return;
}
+ if (req.method === "POST" && /^\/api\/projects\/[a-f0-9]{24}\/hide$/.test(url.pathname)) {
+ const id = url.pathname.split("/")[3],
+ file = path.join(dataDirectory, `${id}.json`),
+ removed = path.join(dataDirectory, "removed");
+ if (!fs.existsSync(file)) {
+ reply(res, 404, { error: "저장된 작업 없음" });
+ return;
+ }
+ fs.mkdirSync(removed, { recursive: true });
+ fs.renameSync(file, path.join(removed, `${id}.json`));
+ reply(res, 200, { id });
+ return;
+ }
if (req.method === "GET" && url.pathname === "/api/projects") {
reply(res, 200, list());
return;