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) => ``).join("")}${g.Gulga.map(() => "").join("")}
${esc(n)}
` + : ""; + 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) => ``).join("")}${list + .map((r) => { + const bold = + num(r.Level) > 0 || num(r.LinkIndex) === 54 ? ' style="font-weight:bold"' : ""; + return `${s.Bigo_Show ? `` : ""}`; + }) + .join("")}
${h}
${esc(`${r.Pr ?? ""}${r.Name ?? ""}`)}${esc(r.Size)}${fmt(r.GN)}${esc(r.Unite)}${fmt(r.TU)}${fmt(r.TC)}${esc(typeof r.Bigo === "string" ? r.Bigo : "")}
`; + const company = s.Company_Show + ? `${[ + ["상 호", s.ComPany_Name], + ["성 명", s.Ceo_Name], + ["사업장주소", s.Addr], + ["업 태", s.Uptae], + ["종 목", s.Jongmog], + ["전화(FAX)", s.Tel], + ] + .map(([k, v]) => ``) + .join("")}
공
급
자
등록번호${esc(s.Reg_Num)}
${k}${esc(v)}
` + : ""; + 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( + `
${esc(form.Name)}
`, + ); + parts.push(`

${esc(form.Name)}

${html}
`); + } + 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) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

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

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

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

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수. function resultFormat(code) { diff --git a/tools/est-test/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) => ``).join("")}${rows + `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${value}
${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((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 `
${value}
${columns.map((key) => ``).join("")}${list.map(({ row, index }) => ` 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}">${columns.map((key) => ``).join("")}`).join("")}
선택${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}
${cell(table, index, key, row[key])}
`; + return `${columns.map((key) => ``).join("")}${list.map(({ row, index }) => ` 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}">${columns.map((key) => ``).join("")}`).join("")}
선택${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}
0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${cell(table, index, key, row[key])}
`; } 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) => + ``, + ) + .join( + "", + )}
구분표코드품명내용
${esc(explain[e.kind])}${esc(EstApp.titles?.[e.table] || e.table)}${esc(e.code)}${esc(e.name)}${esc(e.text)}
${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;