diff --git a/resources/tester/helper_stmate_report.cjs b/resources/tester/helper_stmate_report.cjs
new file mode 100644
index 000000000..39081e778
--- /dev/null
+++ b/resources/tester/helper_stmate_report.cjs
@@ -0,0 +1,22 @@
+// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage } · 연쇄 재계산(계산완료) 뒤 시트 모형
+globalThis.window = globalThis;
+require("../../tools/stmate-test/calc.js");
+require("../../tools/stmate-test/smt_eval.js");
+require("../../tools/stmate-test/smt_engine.js");
+require("../../tools/stmate-test/view_prj_boq.js");
+require("../../tools/stmate-test/report_core.js");
+require("../../tools/stmate-test/report_unit.js");
+require("../../tools/stmate-test/report_boq.js");
+const Book = require("../../tools/stmate-test/report_book.js");
+let buf = "";
+process.stdin.on("data", (d) => (buf += d));
+process.stdin.on("end", () => {
+ const { load, stage } = JSON.parse(buf);
+ const ctx = { dbfData: load, state: load.sim_state, uiState: {} };
+ window.STMATE_ENGINE.cascadeRecalculateAll(ctx);
+ const computed = window.STMATE_CALC.calculateAll(ctx.state);
+ const book = Book.buildBook(load, ctx.state, computed, stage || 1);
+ const out = {};
+ for (const s of book.sheets) out[s.id || s.name] = { name: s.name, rows: s.rows };
+ process.stdout.write(JSON.stringify(out));
+});
diff --git a/resources/tester/helper_stmate_stage.cjs b/resources/tester/helper_stmate_stage.cjs
new file mode 100644
index 000000000..4b95b8ce6
--- /dev/null
+++ b/resources/tester/helper_stmate_stage.cjs
@@ -0,0 +1,102 @@
+// STMATE 재현 앱 단계 계산(설계변경·계약·기성·실행)을 stdin JSON 으로 돌림
+// 입력 { load: /api/load 결과, mode, args } → 출력 JSON
+globalThis.window = globalThis;
+const Calc = require("../../tools/stmate-test/calc.js");
+const Change = require("../../tools/stmate-test/change_calc.js");
+const Contract = require("../../tools/stmate-test/contract_calc.js");
+const Progress = require("../../tools/stmate-test/progress_calc.js");
+const Exec = require("../../tools/stmate-test/exec_calc.js");
+
+const MODES = {
+ change({ load, args }) {
+ const state = load.sim_state;
+ const comp = Calc.calculateAll(state);
+ const stages = args.stages || [1, 2];
+ const tree = Change.buildTree(load.boq_rows, state, comp, stages);
+ const k = (n) => comp.stages[n].kanjub;
+ return {
+ nodes: tree.nodes.map((nd) => ({
+ kind: nd.kind,
+ recno: nd.recno,
+ name: nd.name,
+ itnum: nd.itnum,
+ st: nd.st,
+ })),
+ diff: Change.diffTable(tree, stages[0], stages[1]),
+ statement: Change.changeStatement(tree, stages[0], stages[1]),
+ amount: comp.stages[stages[1]]
+ ? Change.amountTable(tree, k(stages[0]), k(stages[1]), stages[0], stages[1])
+ : null,
+ summary: Change.summaryRows(tree, stages[0], stages[1]),
+ cost: comp.stages[stages[1]] ? Change.costCompare(k(stages[0]), k(stages[1])) : null,
+ };
+ },
+ edit({ load, args }) {
+ const state = load.sim_state;
+ let comp = Calc.calculateAll(state);
+ if (args.start)
+ Change.startStage(state, args.start, comp.stages[1].kanjub, undefined, load.boq_rows);
+ for (const op of args.ops || []) {
+ if (op.op === "qty") Change.setQty(state, op.id, op.stage, op.qty);
+ if (op.op === "unit") Change.setUnit(state, op.id, op.stage, op.mls);
+ if (op.op === "del") Change.deleteInStage(state, op.id, op.stage);
+ if (op.op === "add") Change.addItem(state, op.spec);
+ }
+ comp = Calc.calculateAll(state);
+ const n = args.start || 2;
+ const tree = Change.buildTree(load.boq_rows, state, comp, [1, n]);
+ return {
+ diff: Change.diffTable(tree, 1, n),
+ k1: comp.stages[1].kanjub.grandTotal,
+ kn: comp.stages[n].kanjub.grandTotal,
+ fixed: state.kanjubStages[n].fixed,
+ items: Object.fromEntries(
+ ["KUNBO", "SLVBO", "YUNBO", "KANNO"].map((c) => [
+ c,
+ [comp.stages[1].kanjub.itemMap[c].amount, comp.stages[n].kanjub.itemMap[c].amount],
+ ]),
+ ),
+ };
+ },
+ contract({ load, args }) {
+ const state = load.sim_state;
+ const comp = Calc.calculateAll(state);
+ const tree = Change.buildTree(load.boq_rows, state, comp, [args.stage || 1]);
+ const conv = Contract.convert(tree, args.opts, args.stage || 1, args.codeBase);
+ const out = { rows: conv.rows, totals: conv.totals, codeMap: conv.codeMap };
+ if (args.kanjubCfg) {
+ const kj = Contract.contractKanjub(conv, args.kanjubCfg);
+ out.kanjub = Object.assign(Object.fromEntries(kj.items.map((it) => [it.code, it.amount])), {
+ ILBAN: kj.ilbanAmt,
+ IYUN_M: kj.pretaxProfitAdj,
+ GOV: kj.govFinal,
+ GRAND: kj.grandTotal,
+ });
+ }
+ if (args.target) out.found = Contract.findRate(tree, args.opts, args.target, args.stage || 1);
+ return out;
+ },
+ progress({ load, args }) {
+ const state = load.sim_state;
+ const comp = Calc.calculateAll(state);
+ const tree = Change.buildTree(load.boq_rows, state, comp, [1]);
+ const conv = Contract.convert(tree, args.opts, 1, null);
+ const kj = Contract.contractKanjub(conv, state.kanjubStages[1] || state.kanjubConfig);
+ const base = Progress.contractBase(conv, kj);
+ const rounds = args.rounds.map((r) => ({ pct: r.pct, qtyById: r.qtyById, vatMode: r.vatMode }));
+ return { base: { rates: base.rates, direct: base.direct }, out: Progress.run(base, rounds) };
+ },
+ exec({ args }) {
+ return {
+ unit: Exec.machineExecUnit(args.unit),
+ budget: args.budget ? Exec.budget(args.budget.rows, args.budget.opts) : null,
+ };
+ },
+};
+
+let buf = "";
+process.stdin.on("data", (d) => (buf += d));
+process.stdin.on("end", () => {
+ const inp = JSON.parse(buf);
+ process.stdout.write(JSON.stringify(MODES[inp.mode](inp)));
+});
diff --git a/resources/tester/test_stmate_report_sheets.py b/resources/tester/test_stmate_report_sheets.py
new file mode 100644
index 000000000..e262b08ff
--- /dev/null
+++ b/resources/tester/test_stmate_report_sheets.py
@@ -0,0 +1,173 @@
+"""STMATE 재현 앱 보고서(G5) — 원본 STMATE 가 내보낸 엑셀 시트와 셀 값 대조.
+
+정답 = 실무 STC 옆 원본 출력 엑셀(.xlsx · 계산된 값). 코드에 정답값을 박지 않음.
+앱 쪽 = STC 불러오기 → 연쇄 재계산(계산완료) → 보고서 모형(tools/stmate-test/report_*.js).
+울진 = 「변경 1차분」 STC 의 당초(착공) 차수 ↔ 착공 때 내보낸 엑셀(원가 줄만 — 코드 번호가 뒤에 바뀜).
+영덕 변경 = 변경 차수 원가계산서(당초·변경·증감) — 목록류는 변경분만 고른 출력이라 대조 밖.
+빼는 칸: 원본 부속 칸(STmate 표지·바로가기·숨은 메타) · BDQTY 암호 글 칸(글 줄·비율 줄 명칭) ·
+DBF 읽기에서 앞 빈칸이 깎인 글(앞뒤 빈칸 걷고 맞댐).
+"""
+
+import json
+import subprocess
+import sys
+import warnings
+from functools import lru_cache
+from pathlib import Path
+
+import openpyxl
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+import server # noqa: E402
+
+HELPER = Path(__file__).with_name("helper_stmate_report.cjs")
+DOCS = ROOT / "resources/knowledge/original/실무문서"
+PAIRS = {
+ "봉화2024": (
+ "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC",
+ "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
+ 1,
+ ),
+ "영월2024": (
+ "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
+ "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
+ 1,
+ ),
+ "대흥2024": (
+ "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC",
+ "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
+ 1,
+ ),
+ "울진착공": (
+ "25년 산불진화임도(기번8)(울진소광)/변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC",
+ "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
+ 1,
+ ),
+ "영덕변경": (
+ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC",
+ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
+ 2,
+ ),
+}
+FULL = ("봉화2024", "영월2024", "대흥2024")
+# 시트 id → 대조 열(원본 엑셀 보이는 칸)
+UNIT_SHEETS = {
+ "일위대가목록표": "ABCDEFGHI",
+ "단가산출근거목록표": "ABCDEFGHI",
+ "중기목록표": "ABCDEFGHI",
+ "재료비목록표": "ABCDEF",
+ "노무비목록표": "ABCDEF",
+ "경비목록표": "ABCDEF",
+ "자재단가대비표": "ABCDEFGHIJKLMNOPQ",
+ "환율및기초자료": "ABCDEFGH",
+ "일위대가표": "ABCDEFGHIJKLM",
+ "중기사용료": "ABCDEFGHIJKLM",
+}
+
+
+@lru_cache(maxsize=None)
+def book(name):
+ stc, _, stage = PAIRS[name]
+ load = server.api_load(server.LoadReq(source_path=str(DOCS / stc)))
+ out = subprocess.check_output(
+ ["node", str(HELPER)],
+ input=json.dumps({"load": load, "stage": stage}, ensure_ascii=False),
+ text=True,
+ encoding="utf-8",
+ )
+ return json.loads(out)
+
+
+@lru_cache(maxsize=None)
+def original(name, sheet):
+ warnings.filterwarnings("ignore")
+ wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], data_only=True, read_only=True)
+ return [list(r) for r in wb[sheet].iter_rows(values_only=True)]
+
+
+def norm(v):
+ if v is None or v == "":
+ return None
+ if isinstance(v, (int, float)):
+ return None if abs(v) < 1e-9 else round(float(v), 4)
+ return str(v).strip() or None
+
+
+def cell(row, col):
+ i = ord(col) - 65
+ return row[i] if i < len(row) else None
+
+
+def unreadable(mrow, col):
+ """BDQTY 암호 글 칸 — 설명 줄과 비율(PR) 줄의 명칭·규격."""
+ return col in "AB" and (mrow.get("_k") == "text" or mrow.get("_code") == "PR")
+
+
+def grid_diffs(name, sid, cols):
+ mine = book(name)[sid]
+ orig = original(name, mine["name"])
+ diffs = []
+ for i in range(max(len(orig), len(mine["rows"]))):
+ o = orig[i] if i < len(orig) else []
+ m = mine["rows"][i] if i < len(mine["rows"]) else {}
+ for c in cols:
+ if unreadable(m, c):
+ continue
+ ov, mv = norm(cell(o, c)), norm(m.get(c))
+ if ov != mv:
+ diffs.append((f"{c}{i + 1}", ov, mv))
+ return diffs, len(orig)
+
+
+@pytest.mark.parametrize("name", FULL)
+@pytest.mark.parametrize("sid", list(UNIT_SHEETS))
+def test_unit_sheet_cells_match_original(name, sid):
+ diffs, n = grid_diffs(name, sid, UNIT_SHEETS[sid])
+ assert n > 4, f"{name} {sid} 원본 시트 비어 있음"
+ assert not diffs, f"{name} {sid} 불일치 {len(diffs)}건 {diffs[:5]}"
+
+
+@pytest.mark.parametrize("name", list(PAIRS))
+def test_won_sheet_matches_original(name):
+ cols = "ABCDEFGH" if PAIRS[name][2] > 1 else "ABCDEF"
+ diffs, n = grid_diffs(name, "공사원가계산서", cols)
+ assert n > 20
+ assert not diffs, f"{name} 공사원가계산서 불일치 {diffs[:5]}"
+
+
+def content_rows(rows, cols, get):
+ out = []
+ for r in rows:
+ v = [norm(get(r, c)) for c in cols]
+ if any(v):
+ out.append(v)
+ return out
+
+
+@pytest.mark.parametrize("name", FULL)
+def test_design_boq_rows_match_original(name):
+ """설계내역서 — 쪽 넘김 빈 줄 수는 인쇄 쪽 길이에 달려 내용 줄끼리 차례대로 맞댐."""
+ mine = book(name)["설계내역서"]
+ cols = "ABCDEFGHIJKLMN"
+ orig = content_rows(original(name, mine["name"])[2:], cols, cell)
+ got = content_rows(mine["rows"][2:], cols, lambda r, c: r.get(c))
+ bad = [(i, o, g) for i, (o, g) in enumerate(zip(orig, got)) if o != g]
+ assert len(orig) == len(got) > 50
+ assert not bad, f"{name} 설계내역서 불일치 {len(bad)} {bad[:3]}"
+
+
+@pytest.mark.parametrize("name", FULL + ("울진착공",))
+def test_summary_cost_lines_and_groups_match_original(name):
+ """총괄내역서 — 원가 줄(가.~바.)은 그대로 · 공종 묶음 줄은 원본에 나온 단계만 차례대로(출력 단계 선택)."""
+ mine = book(name)["총괄내역서"]
+ cols = "ABCDEFG"
+ orig = content_rows(original(name, mine["name"])[3:], cols, cell)
+ got = content_rows(mine["rows"][3:], cols, lambda r, c: r.get(c))
+ cut_o = next(i for i, r in enumerate(orig) if r[0] == "가.")
+ cut_g = next(i for i, r in enumerate(got) if r[0] == "가.")
+ assert orig[cut_o:] == got[cut_g:], f"{name} 원가 줄 불일치"
+ it = iter(got[:cut_g])
+ missing = [o for o in orig[:cut_o] if not any(o == g for g in it)]
+ assert cut_o > 2 and not missing, f"{name} 공종 줄 없음 {missing[:3]}"
diff --git a/resources/tester/test_stmate_stage_api.py b/resources/tester/test_stmate_stage_api.py
new file mode 100644
index 000000000..0613c583e
--- /dev/null
+++ b/resources/tester/test_stmate_stage_api.py
@@ -0,0 +1,65 @@
+"""STMATE 재현 앱 단계 서버(api_stage.py) — 공사별 원가설정 · 단계 보고서 엑셀.
+
+정답: 울진 2025 STC 계약 행(PCODE 12)의 PRJ_MEMO 를 smt_parser 로 따로 읽은 원가설정과 같음 ·
+엑셀은 보낸 시트 모형 값이 그대로 셀에 들어감(병합 머리 포함).
+"""
+
+import io
+import json
+import sys
+from pathlib import Path
+
+import openpyxl
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+sys.path.insert(0, str(Path(__file__).parent))
+import api_stage # noqa: E402
+import server # noqa: E402
+from kanjub_cfg import build_kanjub_config # noqa: E402
+from smt_parser import parse_prjlst_smt # noqa: E402
+from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
+
+app = FastAPI()
+app.include_router(api_stage.router)
+client = TestClient(app)
+
+
+def test_projects_config_matches_memo():
+ path = str(DOCS / STCS["울진2025"])
+ res = client.post("/api/stage/projects", json={"source_path": path})
+ assert res.status_code == 200
+ projects = res.json()["projects"]
+ files = server.read_project_files(path)
+ load = server.api_load(server.LoadReq(source_path=path))
+ assert [p["pcode"] for p in projects] == [r["PCODE"] for r in load["prjlst_all"]]
+ for p, r in zip(projects, load["prjlst_all"]):
+ smt = parse_prjlst_smt(files["PRJLST.SMT"], r["PRJ_MEMO_HEX"])
+ assert p["stages"]["1"] == json.loads(json.dumps(build_kanjub_config(smt, 1)))
+ assert p["costs"]["COSTW1"] == r["COSTW1"]
+
+
+def test_projects_bad_path():
+ res = client.post("/api/stage/projects", json={"source_path": str(ROOT / "없는파일.STC")})
+ assert res.status_code == 400
+
+
+def test_export_xlsx_cells():
+ sheet = {
+ "name": "설계변경증감대비표",
+ "title": "설 계 변 경 증 감 대 비 표",
+ "info": ["공사명 : 시험"],
+ "head": [[{"t": "명칭", "r": 2}, {"t": "금액", "c": 2}], ["당초", "변경"]],
+ "rows": [["토공", 1234567, 7654321], ["소수", 1.25, 0]],
+ }
+ res = client.post("/api/stage/export-xlsx", json={"file_name": "시험", "sheets": [sheet]})
+ assert res.status_code == 200
+ ws = openpyxl.load_workbook(io.BytesIO(res.content))["설계변경증감대비표"]
+ assert ws.cell(1, 1).value == sheet["title"]
+ assert ws.cell(2, 1).value == "공사명 : 시험"
+ assert [ws.cell(3, c).value for c in (1, 2)] == ["명칭", "금액"]
+ assert [ws.cell(4, c).value for c in (2, 3)] == ["당초", "변경"]
+ assert [ws.cell(5, c).value for c in (1, 2, 3)] == sheet["rows"][0]
+ assert ws.cell(6, 2).value == 1.25 and ws.cell(5, 2).number_format == "#,##0"
diff --git a/resources/tester/test_stmate_stage_change.py b/resources/tester/test_stmate_stage_change.py
new file mode 100644
index 000000000..8965a971c
--- /dev/null
+++ b/resources/tester/test_stmate_stage_change.py
@@ -0,0 +1,188 @@
+"""STMATE 재현 앱 설계변경 대비(change_calc.js) — 실무 STC 저장값 · STMATE 엑셀 보고서와 1원 대조.
+
+정답은 STC 안 BOQ11 n칸 저장 금액(T/M/L/S_AMTn)과 같은 폴더 STMATE 엑셀
+(설계변경증감대비표 · 설계변경금액대비표 · 설계변경내역서)에서 읽음(코드에 박지 않음).
+근거: 지식DB STmate/old/20_분석 08 §2 · 16 §4.5~4.6 · 19 §6.5 · 35 §4.
+"""
+
+import json
+import subprocess
+import sys
+import warnings
+from pathlib import Path
+
+import openpyxl
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+sys.path.insert(0, str(Path(__file__).parent))
+import server # noqa: E402
+from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
+
+HELPER = Path(__file__).with_name("helper_stmate_stage.cjs")
+CHANGED = ("영덕변경", "소광변경1차")
+
+
+def _load(name):
+ return server.api_load(server.LoadReq(source_path=str(DOCS / STCS[name])))
+
+
+def _run(load, mode, args):
+ out = subprocess.check_output(
+ ["node", str(HELPER)],
+ input=json.dumps({"load": load, "mode": mode, "args": args}, ensure_ascii=False),
+ text=True,
+ encoding="utf-8",
+ )
+ return json.loads(out)
+
+
+def _sheet(name, title):
+ path = (DOCS / STCS[name]).with_suffix(".xlsx")
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
+ ws = next(w for w in wb.worksheets if w.title == title)
+ return [list(r) for r in ws.iter_rows(values_only=True)]
+
+
+def _f(v):
+ return float(v or 0)
+
+
+@pytest.mark.parametrize("name", list(STCS))
+def test_tree_sums_match_stored_boq(name):
+ """머리·계·공종 전 행의 차수별 금액 · 공종 수량·단가 = BOQ11 저장값(n칸)."""
+ load = _load(name)
+ stages = sorted(int(k) for k in load["sim_state"]["kanjubStages"])
+ out = _run(load, "change", {"stages": [1, 2]})
+ rows = {r["_recno"]: r for r in load["boq_rows"]}
+ diffs = []
+ for nd in out["nodes"]:
+ if nd["recno"] is None or nd["kind"] == "blank":
+ continue
+ r = rows[nd["recno"]]
+ for n in stages:
+ for c, k in (("T", "tAmt"), ("M", "mAmt"), ("L", "lAmt"), ("S", "sAmt")):
+ exp, got = _f(r.get(f"{c}_AMT{n}")), nd["st"][str(n)][k]
+ if abs(exp - got) >= 0.5:
+ diffs.append((nd["recno"], nd["name"], n, c, exp, got))
+ if nd["kind"] != "leaf":
+ continue
+ for f, k in (("QTY", "qty"), ("TAMT", "tUnit"), ("MAMT", "mUnit"), ("LAMT", "lUnit")):
+ exp, got = _f(r.get(f"{f}{n}")), nd["st"][str(n)][k]
+ if abs(exp - got) >= 0.00005:
+ diffs.append((nd["recno"], nd["name"], n, f, exp, got))
+ assert not diffs, f"{name} 불일치 {diffs[:8]}"
+
+
+def test_diff_table_matches_xlsx():
+ """설계변경증감대비표 — 행 차례 · 명칭 · 수량 당초/변경/증감 · 금액 당초/변경/증감."""
+ load = _load("영덕변경")
+ diff = _run(load, "change", {"stages": [1, 2]})["diff"]
+ got = [r for r in diff if r["kind"] != "blank"]
+ sheet = _sheet("영덕변경", "설계변경증감대비표")
+ exp = [r for r in sheet[4:] if r[1]]
+ assert len(got) == len(exp)
+ for g, e in zip(got, exp):
+ assert g["name"].strip() == str(e[1]).strip()
+ assert [round(g[k], 4) for k in ("qa", "qb", "qd")] == [round(_f(x), 4) for x in e[3:6]]
+ assert [g[k] for k in ("aa", "ab", "ad")] == [_f(x) for x in e[7:10]], g["name"]
+
+
+def test_amount_table_matches_xlsx():
+ """설계변경금액대비표 — 공종 머리 · 원가계산서 항목 당초/변경/증감 · 당초·변경 총액."""
+ load = _load("영덕변경")
+ amt = _run(load, "change", {"stages": [1, 2]})["amount"]
+ sheet = _sheet("영덕변경", "설계변경금액대비표")
+ start = next(i for i, r in enumerate(sheet) if r[0] and "공" in str(r[0]) and r[3]) + 1
+ exp = [r for r in sheet[start:] if r[1]]
+ assert len(amt["rows"]) == len(exp)
+ for g, e in zip(amt["rows"], exp):
+ assert [g["a"], g["b"], g["d"]] == [_f(x) for x in e[3:6]], (g["name"], e[1])
+ totals = [r[5] for r in sheet if r[0] and "금" in str(r[0]) and r[5]]
+ assert [amt["totalA"], amt["totalB"]] == totals
+
+
+def test_change_statement_matches_xlsx():
+ """설계변경내역서 — 공종마다 당초·변경 두 줄의 수량 · 단가 · 금액(합계·노·재·경)."""
+ load = _load("영덕변경")
+ st = _run(load, "change", {"stages": [1, 2]})["statement"]
+ got = [r for r in st if r["kind"] == "leaf"]
+ sheet = _sheet("영덕변경", "설계변경내역서")
+ exp = [
+ r
+ for r in sheet[4:]
+ if isinstance(r[15], str) and r[15][:1] not in ("E", "T", "C") and "_" in r[15]
+ ]
+ assert len(got) == len(exp)
+ keys = ("tUnit", "tAmt", "lUnit", "lAmt", "mUnit", "mAmt", "sUnit", "sAmt")
+ for g, e in zip(got, exp):
+ assert e[15].endswith(f"_{g['stage']}")
+ assert round(g["qty"], 4) == round(_f(e[3]), 4), e[15]
+ assert [g[k] for k in keys] == [_f(x) for x in e[5:13]], e[15]
+
+
+@pytest.mark.parametrize("name", CHANGED)
+def test_amount_table_totals_match_prjlst(name):
+ """금액대비표 당초·변경 총액 = PRJLST.COSTW1 · COSTW2."""
+ load = _load(name)
+ amt = _run(load, "change", {"stages": [1, 2]})["amount"]
+ p = load["prjlst"]
+ assert [amt["totalA"], amt["totalB"]] == [_f(p["COSTW1"]), _f(p["COSTW2"])]
+
+
+def test_edit_new_stage_ops():
+ """원설계 STC 변경 차수 시작 → 수량 변경 · 삭제(0 유지) · 단가 변경 · 신규 · 당초액 유지."""
+ load = _load("봉화2024")
+ items = [b for b in load["sim_state"]["boqItems"] if b["rowType"] == "normal"]
+ a, b, c = items[0], items[1], items[2]
+ mls = [11.0, 22.0, 33.0]
+ ops = [
+ {"op": "qty", "id": a["id"], "stage": 2, "qty": a["qty"] * 2},
+ {"op": "del", "id": b["id"], "stage": 2},
+ {"op": "unit", "id": c["id"], "stage": 2, "mls": mls},
+ {"op": "add", "spec": {"name": "신규시험", "unit": "m", "stage": 2, "qty": 3, "mls": mls}},
+ ]
+ out = _run(load, "edit", {"start": 2, "ops": ops})
+ rows = {r["id"]: r for r in out["diff"] if r["kind"] == "leaf"}
+ ra, rb, rc, rn = rows[a["id"]], rows[b["id"]], rows[c["id"]], rows["NEW_1"]
+ assert ra["qb"] == ra["qa"] * 2
+ assert rb["qb"] == 0 and rb["ab"] == 0 and rb["state"] == "삭제" and rb["aa"] > 0
+ assert rc["ab"] == sum(int(u * c["qty"]) for u in mls)
+ assert rn["state"] == "신규" and rn["qa"] == 0 and rn["ab"] == sum(int(u * 3) for u in mls)
+ for code in ("KUNBO", "SLVBO", "YUNBO"):
+ assert out["items"][code][0] == out["items"][code][1] == out["fixed"][code]
+ assert out["items"]["KANNO"][0] != out["items"]["KANNO"][1]
+
+
+def test_summary_rows_match_xlsx():
+ """변경 총괄내역서 — 머리마다 당초·변경 두 줄(합계·노·재·경) = 엑셀 총괄내역서 공종 부분."""
+ load = _load("영덕변경")
+ got = _run(load, "change", {"stages": [1, 2]})["summary"]
+ sheet = _sheet("영덕변경", "총괄내역서")
+ end = next(i for i, r in enumerate(sheet) if "순공사비" in str(r[1] or ""))
+ exp = [r for r in sheet[3:end] if isinstance(r[3], (int, float))]
+ assert len(got) == len(exp)
+ for g, e in zip(got, exp):
+ assert [g["tAmt"], g["lAmt"], g["mAmt"], g["sAmt"]] == [_f(x) for x in e[3:7]], e[1]
+ assert (g["name"] or None) == (e[1] or None)
+
+
+@pytest.mark.parametrize("name", CHANGED)
+def test_cost_compare_matches(name):
+ """공사원가계산서 당초/변경 대비 — 차례 · 금액 = 엑셀(있으면) · 총공사비 = COSTW1·COSTW2."""
+ load = _load(name)
+ cost = _run(load, "change", {"stages": [1, 2]})["cost"]
+ p = load["prjlst"]
+ assert [cost[-1]["a"], cost[-1]["b"]] == [_f(p["COSTW1"]), _f(p["COSTW2"])]
+ if not (DOCS / STCS[name]).with_suffix(".xlsx").exists():
+ return
+ sheet = _sheet(name, "공사원가계산서")
+ exp = [r for r in sheet[4:] if isinstance(r[3], (int, float))]
+ assert len(cost) == len(exp)
+ for g, e in zip(cost, exp):
+ label = "".join(str(e[2] or e[0] or "").split())
+ assert "".join(g["label"].split())[:2] == label[:2] or g["label"] == "소계", (g, label)
+ assert [g["a"], g["b"], g["d"]] == [_f(x) for x in e[3:6]], label
diff --git a/resources/tester/test_stmate_stage_contract.py b/resources/tester/test_stmate_stage_contract.py
new file mode 100644
index 000000000..17edd0546
--- /dev/null
+++ b/resources/tester/test_stmate_stage_contract.py
@@ -0,0 +1,169 @@
+"""STMATE 재현 앱 계약 변환·계약 원가계산(contract_calc.js) — 울진 2025 STC 계약내역과 1원 대조.
+
+울진 2025 STC = 설계(BOQ11 · PRJLST PCODE 11 행)
+ + 계약(BOQ12 · PRJLST PCODE 12 행 · 그 행의 PRJLST.SMT).
+정답은 STC 저장값에서 읽음 — 적용율 = COSTN NAGCD
+ · 적용제외 = 설계에서 단가가 그대로인 머리(입력 선택).
+근거: 지식DB STmate/old/20_분석 27 §2 · 26 §4 · 35 §3 · 16 §4.5.
+"""
+
+import json
+import re
+import subprocess
+import sys
+from collections import Counter
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+sys.path.insert(0, str(Path(__file__).parent))
+import server # noqa: E402
+from kanjub_cfg import build_kanjub_config # noqa: E402
+from smt_parser import parse_prjlst_smt # noqa: E402
+from test_stmate_kanjub_stc import DOCS, SMT_KEYS, STCS # noqa: E402
+
+HELPER = Path(__file__).with_name("helper_stmate_stage.cjs")
+PATH = str(DOCS / STCS["울진2025"])
+STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$")
+
+
+def _f(v):
+ return float(v or 0)
+
+
+def _run(load, mode, args):
+ out = subprocess.check_output(
+ ["node", str(HELPER)],
+ input=json.dumps({"load": load, "mode": mode, "args": args}, ensure_ascii=False),
+ text=True,
+ encoding="utf-8",
+ )
+ return json.loads(out)
+
+
+@pytest.fixture(scope="module")
+def case():
+ load = server.api_load(server.LoadReq(source_path=PATH))
+ files = server.read_project_files(PATH)
+ b11, b12 = load["boq_tables"]["BOQ11.DBF"], load["boq_tables"]["BOQ12.DBF"]
+ prj = next(p for p in load["prjlst_all"] if p["PCODE"] == "12")
+ smt = parse_prjlst_smt(files["PRJLST.SMT"], prj["PRJ_MEMO_HEX"])
+ rate = float(
+ Counter(r["NAGCD"] for r in load["costn_rows"] if _f(r["NAGCD"])).most_common(1)[0][0]
+ )
+ # 변환 전 코드 = COSTN 에서 계약에만 쓰인 코드를 뺀 것
+ new = {r["BDCODE1"] for r in b12} - {r["BDCODE1"] for r in b11}
+ base = {}
+ for r in load["costn_rows"]:
+ m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip())
+ if m and r["CODE"].strip() not in new:
+ base[m[1]] = max(base.get(m[1], 0), int(m[2]))
+ # 적용제외 머리 = 계약에서도 단가가 그대로인 공종만 가진 머리(관급 구역은 기본 제외)
+ leaves12 = [r for r in b12 if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"])]
+ kept = {r["BDCODE1"] for r in leaves12 if r["BDCODE1"] in {q["BDCODE1"] for q in b11}}
+ heads, cur = [], None
+ for r in b11:
+ bd = r.get("BDCODE1") or ""
+ if bd.startswith("T"):
+ cur = r
+ elif bd.startswith("E") or bd.startswith("C"):
+ cur = None
+ elif cur is not None and bd and not STRUCT.match(bd) and _f(r.get("TAMT1")):
+ if bd in kept and cur["_recno"] not in heads:
+ heads.append(cur["_recno"])
+ opts = {"rateL": rate, "rateM": rate, "rateS": rate, "excludeHeads": heads}
+ out = _run(
+ load,
+ "contract",
+ {
+ "opts": opts,
+ "codeBase": base,
+ "kanjubCfg": build_kanjub_config(smt, 1),
+ "target": _f(prj["TCOST1"]),
+ },
+ )
+ return {"load": load, "b12": b12, "prj": prj, "smt": smt, "rate": rate, "out": out}
+
+
+def test_contract_leaves_match_boq12(case):
+ """계약 공종 — 새 계약 코드 · 비목별 단가 · 금액 = BOQ12 저장값."""
+ got = [r for r in case["out"]["rows"] if r["kind"] == "leaf"]
+ exp = [r for r in case["b12"] if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"])]
+ assert len(got) == len(exp)
+ for g, e in zip(got, exp):
+ assert g["newCode"] == e["BDCODE1"], g["name"]
+ assert [g["tAmt"], g["mAmt"], g["lAmt"], g["sAmt"]] == [
+ _f(e[k]) for k in ("T_AMT1", "M_AMT1", "L_AMT1", "S_AMT1")
+ ], g["name"]
+ assert [g["mUnit"], g["lUnit"], g["sUnit"]] == [
+ _f(e[k]) for k in ("MAMT1", "LAMT1", "SAMT1")
+ ], g["name"]
+
+
+def test_contract_heads_match_boq12(case):
+ """계약 머리·계 합계 = BOQ12 머리·계 저장 금액(차례 대조)."""
+ got = [r for r in case["out"]["rows"] if r["kind"] in ("head", "close")]
+ exp = [r for r in case["b12"] if re.match(r"^(T\d|E\d|C\d)$", r.get("BDCODE1") or "")]
+ assert len(got) == len(exp)
+ for g, e in zip(got, exp):
+ assert [g["tAmt"], g["lAmt"], g["mAmt"], g["sAmt"]] == [
+ _f(e[k]) for k in ("T_AMT1", "L_AMT1", "M_AMT1", "S_AMT1")
+ ], e.get("NAME1")
+
+
+def test_contract_totals_match_prjlst(case):
+ """계약 직접비 = PRJLST 계약 행 MCOST1·LCOST1·SCOST1·TCOST1."""
+ t, p = case["out"]["totals"], case["prj"]
+ assert [t["directM"], t["directL"], t["directS"], t["directTotal"]] == [
+ _f(p[k]) for k in ("MCOST1", "LCOST1", "SCOST1", "TCOST1")
+ ]
+
+
+def test_contract_kanjub_matches_contract_smt(case):
+ """계약 원가계산서 = 계약 행 PRJLST.SMT 저장 금액 · 총공사비 = 계약 행 COSTW1."""
+ kj, smt = case["out"]["kanjub"], case["smt"]
+ diffs = {
+ k: (kj[k], _f(smt[f"1_{v}"]))
+ for k, v in SMT_KEYS.items()
+ if k in kj and abs(kj[k] - _f(smt[f"1_{v}"])) >= 0.5
+ }
+ assert not diffs, diffs
+ assert kj["GRAND"] == _f(case["prj"]["COSTW1"])
+
+
+def test_contract_unit_equals_costn_nagcd_rule(case):
+ """새 계약 코드 단가 = COSTN 1칸(= trunc(2칸 × NAGCD/100)) — 낙찰율 열 저장 규칙."""
+ costn = {r["CODE"].strip(): r for r in case["load"]["costn_rows"]}
+ checked = 0
+ for g in case["out"]["rows"]:
+ c = costn.get(g.get("newCode") or "")
+ if g["kind"] != "leaf" or not c or not _f(c["NAGCD"]):
+ continue
+ nag = _f(c["NAGCD"])
+ for comp, key in (("M", "mUnit"), ("L", "lUnit"), ("S", "sUnit")):
+ assert g[key] == _f(c[f"{comp}AMT1"]) == int(_f(c[f"{comp}AMT2"]) * nag / 100 + 1e-9)
+ checked += 1
+ assert checked >= 20
+
+
+def test_find_rate_recovers_nagcd(case):
+ """예상적용율 찾기 — 목표 = 계약 직접비 → 적용율 = NAGCD · 값 = 목표."""
+ found = case["out"]["found"]
+ assert found["value"] == _f(case["prj"]["TCOST1"])
+ assert abs(found["rate"] - case["rate"]) < 1e-9
+
+
+def test_zero_rate_makes_w_code_blank_items(case):
+ """적용율 0% → 계약단가 0 · W코드 공내역(같은 머리글자 번호 체계)."""
+ out = _run(
+ case["load"],
+ "contract",
+ {"opts": {"rateL": 0, "rateM": 0, "rateS": 0, "zeroToW": True}, "codeBase": {"W": 100}},
+ )
+ leaves = [r for r in out["rows"] if r["kind"] == "leaf" and not r["excluded"]]
+ priced = [r for r in leaves if r["dUnit"]]
+ assert priced and all(r["tAmt"] == 0 and r["zeroed"] for r in priced)
+ assert all(r["newCode"].startswith("W") for r in priced)
+ assert out["totals"]["directTotal"] == 0
diff --git a/resources/tester/test_stmate_stage_progress.py b/resources/tester/test_stmate_stage_progress.py
new file mode 100644
index 000000000..aada9b3f1
--- /dev/null
+++ b/resources/tester/test_stmate_stage_progress.py
@@ -0,0 +1,132 @@
+"""STMATE 재현 앱 기성·실행 계산(progress_calc.js · exec_calc.js) — 자기 일관성 지킴 시험.
+
+기성·실행 STC/XLSX 표본이 0건(지식DB STmate/old/20_분석 27 §9 · 35 §5)이라 원 단위 정답 대조 없음.
+지키는 것: 전회+금회=누계 · 100% 누계 = 계약 금액 · 잔량 0
+· 금회 제잡비 = trunc(금회직접 × 계약잡비율)
+· 부가세 4택 · 집계수준 머리 합 · 중기 실행단가 성분 합 = 절사 목표 · 실행율 100% = 설계.
+"""
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+sys.path.insert(0, str(Path(__file__).parent))
+import server # noqa: E402
+from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
+
+HELPER = Path(__file__).with_name("helper_stmate_stage.cjs")
+
+
+def _run(load, mode, args):
+ out = subprocess.check_output(
+ ["node", str(HELPER)],
+ input=json.dumps({"load": load, "mode": mode, "args": args}, ensure_ascii=False),
+ text=True,
+ encoding="utf-8",
+ )
+ return json.loads(out)
+
+
+@pytest.fixture(scope="module")
+def prog():
+ load = server.api_load(server.LoadReq(source_path=str(DOCS / STCS["봉화2024"])))
+ items = [b for b in load["sim_state"]["boqItems"] if b["rowType"] == "normal"]
+ first = items[0]
+ rounds = [
+ {"pct": 30, "vatMode": 1},
+ {"pct": 45, "vatMode": 2, "qtyById": {first["id"]: first["qty"] * 0.5}},
+ {"pct": 25, "vatMode": 3, "qtyById": {first["id"]: first["qty"] * 0.2}},
+ ]
+ opts = {"rateL": 88, "rateM": 88, "rateS": 88}
+ out = _run(load, "progress", {"opts": opts, "rounds": rounds})
+ return {"load": load, "out": out, "first": first}
+
+
+def test_round_prev_cur_acc(prog):
+ """회차마다 전회+금회=누계 · 다음 회차 전회 = 앞 회차 누계."""
+ runs = prog["out"]["out"]
+ for k, rd in enumerate(runs):
+ for r in rd["rows"]:
+ for key in ("mAmt", "lAmt", "sAmt", "tAmt"):
+ assert r["prev"][key] + r["cur"][key] == r["acc"][key]
+ if k:
+ prev = next(x for x in runs[k - 1]["rows"] if x["id"] == r["id"])
+ assert r["prev"] == prev["acc"] and r["prevQty"] == prev["accQty"]
+
+
+def test_full_progress_equals_contract(prog):
+ """기성 누계 100% — 공종 누계 = 계약 금액 · 잔량 0 · 기성율 100."""
+ last = prog["out"]["out"][-1]
+ for r in last["rows"]:
+ assert r["acc"] == r["ctr"], r["name"]
+ assert abs(r["remainQty"]) < 1e-9 and (r["ctr"]["tAmt"] == 0 or r["accRate"] == 100)
+ assert last["totals"]["acc"]["tAmt"] == prog["out"]["base"]["direct"]
+
+
+def test_jab_is_cur_direct_times_contract_rate(prog):
+ """금회 제잡비 = trunc(금회직접공사비 × 계약잡비율) · 부가세 4택."""
+ rates = prog["out"]["base"]["rates"]
+ for rd, mode in zip(prog["out"]["out"], (1, 2, 3)):
+ cur, jab = rd["totals"]["cur"], rd["jab"]["cur"]
+ assert jab["direct"] == cur["tAmt"]
+ for it in jab["items"]:
+ assert it["amount"] == int(cur["tAmt"] * rates[it["code"]] + 1e-9)
+ expect_vat = {
+ 1: int(jab["supply"] * 0.1),
+ 2: int(cur["mAmt"] * 0.1),
+ 3: int((cur["mAmt"] + cur["sAmt"]) * 0.1),
+ }[mode]
+ assert jab["vat"] == expect_vat
+ assert jab["total"] == jab["supply"] + jab["vat"]
+ acc = rd["jab"]["acc"]
+ assert acc["total"] == rd["jab"]["prev"]["total"] + jab["total"]
+
+
+def test_partial_qty_override(prog):
+ """공종별 기성수량 직접 입력 — 누계수량 · 잔량."""
+ f = prog["first"]
+ rows = [next(r for r in rd["rows"] if r["id"] == f["id"]) for rd in prog["out"]["out"]]
+ assert rows[0]["curQty"] == round(f["qty"] * 0.3, 4)
+ assert rows[1]["curQty"] == f["qty"] * 0.5
+ assert abs(rows[2]["remainQty"] - (f["qty"] - rows[2]["accQty"])) < 1e-9
+
+
+def test_machine_exec_unit():
+ """중기 실행단가 — 절사 6택 · 성분 합 = 절사 목표 · 차액 보정 성분."""
+ for jeol in (1, 10, 100, 1000, 10000, 100000):
+ for order in ("base", "l", "m", "s"):
+ p = {
+ "fee": 987654,
+ "hours": 8,
+ "init": {"l": 41234, "m": 30111, "s": 52345},
+ "jeol": jeol,
+ "order": order,
+ }
+ u = _run({}, "exec", {"unit": p})["unit"]
+ assert u["T"] == int(987654 / 8 / jeol) * jeol
+ assert sum(u["comps"].values()) == u["T"]
+ others = [k for k in "lms" if k != u["to"]]
+ assert all(u["comps"][k] % jeol == 0 for k in others)
+ assert u["to"] == (order if order != "base" else "s")
+
+
+def test_exec_budget_rate():
+ """실행예산 — 실행율 100% = 설계 · 실행율·절사 · 실행수량 직접 입력."""
+ rows = [
+ {"id": "a", "qty": 12.5, "m": 1234, "l": 5678, "s": 910},
+ {"id": "b", "qty": 3, "m": 0, "l": 77777, "s": 0},
+ ]
+ same = _run({}, "exec", {"unit": {}, "budget": {"rows": rows, "opts": {}}})["budget"]
+ assert same["exec"] == same["design"] and same["rate"] == 100
+ opts = {"rateM": 90, "rateL": 80, "rateS": 70, "jeol": 10, "overrides": {"b": {"qty": 4}}}
+ b = _run({}, "exec", {"unit": {}, "budget": {"rows": rows, "opts": opts}})["budget"]
+ ra, rb = b["rows"]
+ assert ra["exec"]["mUnit"] == int(1234 * 0.9 / 10) * 10
+ assert ra["exec"]["lUnit"] == int(5678 * 0.8 / 10) * 10
+ assert rb["exec"]["qty"] == 4 and rb["exec"]["lAmt"] == int(77777 * 0.8 / 10) * 10 * 4
+ assert b["exec"]["tAmt"] == ra["exec"]["tAmt"] + rb["exec"]["tAmt"]
diff --git a/tools/stmate-test/api_qty.py b/tools/stmate-test/api_qty.py
new file mode 100644
index 000000000..e01224e71
--- /dev/null
+++ b/tools/stmate-test/api_qty.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+"""수량산출서·수량코드·표준중기공식 끼워 넣기 API.
+
+근거: 지식DB STmate/old/20_분석 28 §1.1(중기공식 등록·수정·삭제) · §2(수량산출서 · 기본/확장 수량변수) ·
+02(QCODE 15필드) · REA 28 TWQTYLIST · REA 30 TWM_EDIT_EQ(명칭 · 적용 공식/공식+코드 · EQSYNC).
+쓰기는 tools/stmate-test/work_dbf/ 아래만.
+"""
+
+from __future__ import annotations
+
+import json
+import pathlib
+import re
+import zipfile
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from qty_qcode import build_qcode, read_qcode
+
+router = APIRouter()
+HERE = pathlib.Path(__file__).resolve().parent
+
+
+def _work_root() -> pathlib.Path:
+ import sys
+
+ srv = sys.modules.get("server") # 실행 중 서버가 있으면 그 작업 폴더(시험은 임시 폴더로 바꿈)
+ return pathlib.Path(getattr(srv, "WORK_DBF_ROOT", HERE / "work_dbf"))
+
+
+def _read_files(source_path: str) -> dict[str, bytes]:
+ p = pathlib.Path(source_path)
+ if not p.exists():
+ raise HTTPException(status_code=404, detail=f"경로를 찾을 수 없음: {source_path}")
+ if p.is_file() and p.suffix.upper() == ".STC":
+ with zipfile.ZipFile(p) as zf:
+ return {pathlib.Path(n).name.upper(): zf.read(n) for n in zf.namelist()}
+ if p.is_dir():
+ return {fp.name.upper(): fp.read_bytes() for fp in p.iterdir() if fp.is_file()}
+ raise HTTPException(status_code=400, detail="지원하지 않는 경로 형식")
+
+
+class QcodeReq(BaseModel):
+ source_path: str
+
+
+class QcodeSaveReq(BaseModel):
+ source_path: str
+ target_folder_name: str = "stmate_edited"
+ rows: list[dict] = []
+
+
+@router.post("/api/qty/qcode")
+def api_qty_qcode(req: QcodeReq):
+ """프로젝트의 수량코드(QCODE) 행 — 메모(IFORM · XCODE)는 글자로."""
+ return read_qcode(_read_files(req.source_path))
+
+
+@router.post("/api/qty/qcode-save")
+def api_qty_qcode_save(req: QcodeSaveReq):
+ """수량코드 저장 — work_dbf/<이름>/QCODE.DBF·SMT 와 같은 이름 STC 안의 두 파일을 바꿈."""
+ bad = [
+ r.get("CODE")
+ for r in req.rows
+ if not re.fullmatch(r"Q\d{5}", str(r.get("CODE", "")).strip())
+ ]
+ if bad:
+ raise HTTPException(status_code=400, detail=f"수량코드 형식 오류: {bad[:5]}")
+ files = _read_files(req.source_path)
+ dbf, smt = build_qcode(req.rows, files)
+ safe = (
+ re.sub(r"[^\w\-가-힣]", "_", req.target_folder_name or "stmate_edited").strip("_")
+ or "stmate_edited"
+ )
+ root = _work_root()
+ out_dir = root / safe
+ out_dir.mkdir(parents=True, exist_ok=True)
+ (out_dir / "QCODE.DBF").write_bytes(dbf)
+ (out_dir / "QCODE.SMT").write_bytes(smt)
+ stc = root / f"{safe}.STC"
+ base = _read_files(str(stc)) if stc.exists() else files
+ base.update({"QCODE.DBF": dbf, "QCODE.SMT": smt})
+ with zipfile.ZipFile(stc, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for name, content in base.items():
+ zf.writestr(name, content)
+ return {"ok": True, "saved_dir": str(out_dir), "saved_stc": str(stc), "count": len(req.rows)}
+
+
+# 표준중기공식(EQSYNC) — 설치 폴더 공용 등록부 · 여기서는 work_dbf/EQSYNC.json
+class EqItem(BaseModel):
+ name: str
+ text: str = ""
+ ins_x: int = 0 # 0 공식 · 1 공식+코드
+
+
+class EqSaveReq(BaseModel):
+ items: list[EqItem] = []
+
+
+def _eq_path() -> pathlib.Path:
+ return _work_root() / "EQSYNC.json"
+
+
+@router.get("/api/qty/eq")
+def api_qty_eq():
+ p = _eq_path()
+ items = json.loads(p.read_text(encoding="utf-8")) if p.exists() else []
+ return {"items": items}
+
+
+@router.post("/api/qty/eq")
+def api_qty_eq_save(req: EqSaveReq):
+ names = [i.name.strip() for i in req.items]
+ if any(not n for n in names):
+ raise HTTPException(status_code=400, detail="중기공식 명칭이 비어 있음")
+ if len(set(names)) != len(names):
+ raise HTTPException(status_code=400, detail="같은 명칭의 중기공식이 둘 이상")
+ p = _eq_path()
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(
+ json.dumps([i.model_dump() for i in req.items], ensure_ascii=False, indent=1),
+ encoding="utf-8",
+ )
+ return {"ok": True, "count": len(req.items)}
diff --git a/tools/stmate-test/api_stage.py b/tools/stmate-test/api_stage.py
new file mode 100644
index 000000000..10850b3d0
--- /dev/null
+++ b/tools/stmate-test/api_stage.py
@@ -0,0 +1,167 @@
+# -*- coding: utf-8 -*-
+"""설계 후속 단계 서버 — 공사(PRJLST 행)별 원가설정 · 단계 보고서 엑셀 내보내기 (api_stage.py).
+
+근거: 지식DB STmate/old/20_분석 27(계약·실행·기성·준공) · 16 §4.5(통합형 BOQ11 설계 + BOQ12 계약)
+ · 20 §3(SET_REP 보고서 0623·0624·0632~0638·0643~0648·0661·0673·0674)
+ /api/stage/projects PRJLST 행마다 PCODE·CCODE·차수별 직접비·총공사비
+ · 그 행 PRJ_MEMO 의 차수별 원가설정
+ /api/stage/export-xlsx 화면 보고서 시트 모형(name·title·info·head·rows) → xlsx
+"""
+
+from __future__ import annotations
+
+import io
+import re
+from urllib.parse import quote
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import Response
+from kanjub_cfg import build_kanjub_config, stage_slots
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Border, Font, Side
+from openpyxl.utils import get_column_letter
+from pydantic import BaseModel
+from stc_read import Source, prjlst_smt_all, table_rows
+
+router = APIRouter()
+COST_COLS = re.compile(r"^(MCOST|LCOST|SCOST|TCOST|ACOST|COSTW)\d$")
+
+
+class StagePathReq(BaseModel):
+ source_path: str
+
+
+class StageSheet(BaseModel):
+ name: str
+ title: str = ""
+ info: list[str] = []
+ head: list[list] = []
+ rows: list[list] = []
+
+
+class StageXlsxReq(BaseModel):
+ file_name: str = "STMATE_단계보고서"
+ sheets: list[StageSheet]
+
+
+def _flat(sections: dict) -> dict[str, str]:
+ """PRJ_MEMO 섹션들을 키=값 하나로 · 빈 값은 뺌(smt_parser 와 같게)."""
+ out: dict[str, str] = {}
+ for kv in sections.values():
+ out.update({k: v for k, v in kv.items() if str(v).strip() != ""})
+ return out
+
+
+def project_configs(files: dict[str, bytes]) -> list[dict]:
+ """PRJLST 행마다 공사 정보 + 차수별 원가설정(build_kanjub_config)."""
+ rows = table_rows(files.get("PRJLST.DBF", b""))
+ memos = {m["recno"]: m for m in prjlst_smt_all(files)}
+ out = []
+ for r in rows:
+ kv = _flat(memos.get(r["_recno"], {}).get("sections", {}))
+ out.append(
+ {
+ "recno": r["_recno"],
+ "pcode": str(r.get("PCODE", "")).strip(),
+ "ccode": str(r.get("CCODE", "")).strip(),
+ "pclass": str(r.get("PCLASS", "")).strip(),
+ "k_ord": str(r.get("K_ORD", "")).strip(),
+ "pnum": r.get("PNUM", ""),
+ "pname": r.get("PNAME", ""),
+ "boq_table": f"BOQ{str(r.get('PCODE', '')).strip()}.DBF",
+ "has_boq": f"BOQ{str(r.get('PCODE', '')).strip()}.DBF" in files,
+ "costs": {k: v for k, v in r.items() if COST_COLS.match(k)},
+ "stages": {n: build_kanjub_config(kv, n) for n in stage_slots(kv)} if kv else {},
+ }
+ )
+ return out
+
+
+@router.post("/api/stage/projects")
+def stage_projects(req: StagePathReq):
+ try:
+ src = Source(req.source_path)
+ except (FileNotFoundError, ValueError) as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return {"source_path": req.source_path, "projects": project_configs(src.files)}
+
+
+def build_xlsx(sheets: list[StageSheet]) -> bytes:
+ """시트 모형 → xlsx — 제목 · 안내 줄 · 머리(병합 {t,c,r}) · 값(숫자 #,##0 · 소수 4자리)."""
+ wb = Workbook()
+ wb.remove(wb.active)
+ thin = Side(style="thin")
+ box = Border(left=thin, right=thin, top=thin, bottom=thin)
+ used: set[str] = set()
+ for sh in sheets:
+ name = re.sub(r"[\[\]\*\?/\\:]", "", sh.name)[:31] or "Sheet"
+ base, k = name, 1
+ while name in used:
+ k += 1
+ name = f"{base[:28]}_{k}"
+ used.add(name)
+ ws = wb.create_sheet(name)
+ width = max(
+ [len(r) for r in sh.rows]
+ + [sum(int(c.get("c", 1)) if isinstance(c, dict) else 1 for c in h) for h in sh.head]
+ + [1]
+ )
+ ws.cell(1, 1, sh.title or sh.name).font = Font(bold=True, size=14)
+ ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=width)
+ ws.cell(1, 1).alignment = Alignment(horizontal="center")
+ row = 2
+ for line in sh.info:
+ ws.cell(row, 1, line)
+ row += 1
+ taken: set[tuple[int, int]] = set()
+ for h in sh.head:
+ col = 1
+ for c in h:
+ while (row, col) in taken:
+ col += 1
+ t, cs, rs = (
+ (c.get("t", ""), int(c.get("c", 1)), int(c.get("r", 1)))
+ if isinstance(c, dict)
+ else (c, 1, 1)
+ )
+ cell = ws.cell(row, col, t)
+ cell.font = Font(bold=True)
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+ for rr in range(row, row + rs):
+ for cc in range(col, col + cs):
+ taken.add((rr, cc))
+ ws.cell(rr, cc).border = box
+ if cs > 1 or rs > 1:
+ ws.merge_cells(
+ start_row=row,
+ start_column=col,
+ end_row=row + rs - 1,
+ end_column=col + cs - 1,
+ )
+ col += cs
+ row += 1
+ for r in sh.rows:
+ for j, v in enumerate(r, start=1):
+ cell = ws.cell(row, j, v)
+ cell.border = box
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
+ cell.number_format = "#,##0" if float(v).is_integer() else "#,##0.####"
+ row += 1
+ for j in range(1, width + 1):
+ ws.column_dimensions[get_column_letter(j)].width = 14 if j > 3 else 22
+ bio = io.BytesIO()
+ wb.save(bio)
+ return bio.getvalue()
+
+
+@router.post("/api/stage/export-xlsx")
+def stage_export_xlsx(req: StageXlsxReq):
+ if not req.sheets:
+ raise HTTPException(status_code=400, detail="시트 없음")
+ data = build_xlsx(req.sheets)
+ fname = quote(f"{req.file_name}.xlsx")
+ return Response(
+ content=data,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{fname}"},
+ )
diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js
index 57bb4d9b6..39b6360fa 100644
--- a/tools/stmate-test/app.js
+++ b/tools/stmate-test/app.js
@@ -134,6 +134,8 @@
const sel = $("top-project-select");
if (sel) sel.value = sourcePath;
if (badge) badge.textContent = `DBF/SMT 직결 (${data.costn_count}단가·${data.boq_count}내역)`;
+ Object.assign(ctx.uiState, { boqDeleted: [], boqSel: [] });
+ if (API.noteRecent) API.noteRecent(sourcePath);
renderWorkspace();
} catch (err) {
if (badge) badge.textContent = "로드 오류";
@@ -159,7 +161,7 @@
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
try {
- const saved = await postJson("/api/save", ViewPrjBoq.buildSavePayload(ctx));
+ const saved = await postJson("/api/save", decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)));
renderWorkspace();
alert(
`[계산완료(Act_Calc_All) 및 DBF/SMT/STC 저장 성공]\n• 5단 연쇄재계산: 중기(X) ${stat.xCount}건 · 산근(D) ${stat.dCount}건 · 일위대가(B) ${stat.bCount}건\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`,
@@ -169,6 +171,17 @@
}
}
+ // 저장 묶음 덧붙임 — 내역 행 차례·새 행·지운 행(boq_layout) · 초기값(start_ini) · 공사목록(prjlst_rows)
+ function decorateSavePayload(p) {
+ const d = ctx.dbfData;
+ if (!d) return p;
+ const keys = "_recno,_new,SORTCODE,ENDCODE,BDCODE1,ITNUM,NAME1,SIZE1,UNIT1,QTY1,RATE,RM,RL,RS";
+ const pick = (r) => Object.fromEntries(keys.split(",").map((k) => [k, r[k]]));
+ const deleted = (ctx.uiState.boqDeleted || []).slice();
+ p.boq_layout = { table: d.boq_table_name, deleted, rows: (d.boq_rows || []).map(pick) };
+ return Object.assign(p, { start_ini: d.start_ini || {}, prjlst_rows: d.prjlst_all || [] });
+ }
+
function openModal(title, bodyHtml) {
$("modal-title-text").textContent = title;
$("modal-body-content").innerHTML = bodyHtml;
@@ -185,8 +198,6 @@
ctx.uiState.activeScreen = scr;
renderWorkspace();
};
- const findBoqDrow = (recno) =>
- ctx.dbfData && ctx.dbfData.boq_rows && ctx.dbfData.boq_rows.find((r) => r._recno === recno);
bindEach(".prj-row", "ondblclick", (tr) => {
const p = tr.getAttribute("data-path");
@@ -219,113 +230,8 @@
}
});
- onChange("boq-level-filter", (e) => {
- ctx.uiState.boqLevelFilter = e.target.value;
- renderWorkspace();
- });
- const boqSearchInp = $("boq-search-input");
- if (boqSearchInp)
- boqSearchInp.oninput = () => {
- ctx.uiState.boqSearch = boqSearchInp.value;
- renderWorkspace();
- };
-
- bindEach(".boq-edit-qty, .boq-edit-name, .boq-edit-itnum", "onchange", (inp) => {
- const recno = parseInt(inp.getAttribute("data-recno"), 10);
- const drow = findBoqDrow(recno);
- const leaf = ctx.state.boqItems.find(
- (b) => b._recno === recno || b.code === inp.getAttribute("data-code"),
- );
- if (inp.classList.contains("boq-edit-qty")) {
- const v = num(inp.value, 0);
- if (leaf) leaf.qty = v;
- if (drow) drow.QTY1 = String(v);
- renderWorkspace();
- } else if (inp.classList.contains("boq-edit-name")) {
- if (drow) drow.NAME1 = inp.value;
- if (leaf) leaf.name = inp.value;
- } else if (drow) {
- drow.ITNUM = inp.value;
- }
- });
- bindEach(".boq-jump-code", "onclick", (a, e) => {
- e.preventDefault();
- const code = a.getAttribute("data-code");
- ctx.uiState.selectedIlwiCode = code;
- goScreen(/^[MLS]/.test(code) ? "wM_JabPum" : "wM_Edit_iLWi");
- });
- bindEach(".boq-put-pocket", "onclick", (btn) => {
- ctx.uiState.boqPockets[ctx.uiState.activePocket || 1].push({
- code: btn.getAttribute("data-code"),
- name: btn.getAttribute("data-name"),
- qty: num(btn.getAttribute("data-qty"), 1),
- });
- renderWorkspace();
- });
- bindEach(".pocket-tab-btn", "onclick", (btn) => {
- ctx.uiState.activePocket = parseInt(btn.getAttribute("data-pocket"), 10);
- renderWorkspace();
- });
- bindEach(".pocket-insert-btn", "onclick", (btn) => {
- const item =
- ctx.uiState.boqPockets[ctx.uiState.activePocket || 1][
- parseInt(btn.getAttribute("data-idx"), 10)
- ];
- if (item) insertBoqRow(item.code, item.name, "", "식", item.qty);
- });
- "boq-btn-itnum:Act_ITNumModal,boq-btn-rate:Act_BoqRateModal,boq-btn-mrate:Act_MRateModal,boq-btn-expnd:Act_ExpndModal,dan-btn-boxa:Act_BoxaModal,dan-btn-edthal:Act_EdtHalModal,dan-btn-catalog:Act_CatalogModal,ilwi-btn-load-recipe:Act_CatalogModal"
- .split(",")
- .forEach((p) => {
- const [id, key] = p.split(":");
- bindModalOpener(id, key);
- });
- onClick("boq-btn-add-leaf", () => {
- const f = ctx.state.ilwiItems[0] || {
- code: "D00014",
- rawName: "신규 공종",
- spec: "",
- unit: "m3",
- };
- insertBoqRow(f.code, f.rawName || f.name, f.spec || "", f.unit || "m3", 10);
- });
- onClick("sys-btn-apply", () => {
- Object.assign(ctx.state.sysConfig, {
- usdRate1: num($("sys-usd1").value, 1350),
- usdRate2: num($("sys-usd2").value, 1400),
- useChangedUsd: $("sys-usd-mode").value === "1",
- ecdDigits: parseInt($("sys-ecd").value, 10) || 0,
- chnMode: $("sys-chn").value,
- optOrder: $("sys-opt").value,
- rxFormula: $("sys-rx-formula").value,
- condCodes: $("sys-cond-codes").value,
- });
- cascadeAndRender();
- });
- bindEach(".dan-filter-btn", "onclick", (btn) => {
- ctx.uiState.danFilter = btn.getAttribute("data-filter");
- renderWorkspace();
- });
- const danSearch = $("dan-search-input");
- if (danSearch)
- danSearch.oninput = () => {
- ctx.uiState.danSearch = danSearch.value;
- renderWorkspace();
- };
- bindEach(".dan-edit-m, .dan-edit-l, .dan-edit-s", "onchange", (inp) => {
- const tr = inp.closest("tr");
- ViewPrjBoq.syncCostnUnitPrice(
- ctx,
- inp.getAttribute("data-code"),
- num(tr.querySelector(".dan-edit-m").value),
- num(tr.querySelector(".dan-edit-l").value),
- num(tr.querySelector(".dan-edit-s").value),
- );
- cascadeAndRender();
- });
- bindEach(".dan-jump-ilwi", "onclick", (btn) => {
- ctx.uiState.selectedIlwiCode = btn.getAttribute("data-code");
- goScreen("wM_Edit_iLWi");
- });
+ // 내역서·초기값·단가목록표 화면 묶음은 scr_ui_boq.js · scr_ui_sys.js · scr_ui_dan.js
+ bindModalOpener("ilwi-btn-load-recipe", "Act_CatalogModal");
bindEach(".ilwi-sub-btn", "onclick", (btn) => {
ctx.uiState.ilwiSubFilter = btn.getAttribute("data-sub");
@@ -534,52 +440,20 @@
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
+ Object.assign(API, { saveAll: saveAllToDbfServer, decorateSavePayload, openMappedModal });
async function initStmateApp() {
- bindEach("#main-toolbar .tb-btn[data-screen]", "onclick", (btn) => {
- ctx.uiState.activeScreen = btn.getAttribute("data-screen");
- renderWorkspace();
- });
- onClick("btn-calc-all", saveAllToDbfServer);
onClick("modal-close-btn", closeModal);
- // 끼워 넣은 메뉴 단추 — m.menu = 상단 메뉴 data-menu(m1~m5)
- PLUG.menus.forEach((m) => {
- const head = document.querySelector(`#main-menubar [data-menu="${m.menu}"]`);
- const box = head && head.querySelector(".menu-dropdown");
- if (box && !box.querySelector(`[data-act="${m.act}"]`))
- box.insertAdjacentHTML("beforeend", ``);
- });
- bindEach("#main-menubar [data-act]", "onclick", (btn) => {
- const act = btn.getAttribute("data-act");
- if (PLUG.actions[act]) return PLUG.actions[act](ctx, API, btn);
+ // 원본 메뉴·툴바·단축키 — ui_app_menu.js(끼워 넣은 메뉴·동작 포함) · 옛 동작 이름은 아래 표로
+ global.STMATE_UI_APP_MENU.install(API);
+ $("main-menubar").addEventListener("click", (e) => {
+ const btn = e.target.closest("[data-act]");
+ const act = btn && btn.getAttribute("data-act");
+ if (!act || btn.disabled || PLUG.actions[act] || global.STMATE_UI_APP_MENU.H[act]) return;
if (MENU_SCREEN_MAP[act]) ctx.uiState.activeScreen = MENU_SCREEN_MAP[act];
else if (MENU_MODAL_MAP[act]) return openMappedModal(act);
- else if (act === "Act_ReloadDbf" && ctx.currentSourcePath)
- return loadProjectFromServer(ctx.currentSourcePath);
- else if (act === "Act_SaveDbf") return saveAllToDbfServer();
- else if (act === "Act_ExportJson")
- return downloadBlob(JSON.stringify(ctx.state), "stmate_snapshot.json", "application/json");
- else if (act === "Act_DanList")
- Object.assign(ctx.uiState, {
- danFilter: btn.getAttribute("data-filter") || "ALL",
- activeScreen: "wM_JabPum",
- });
- else if (act === "Act_IlwiEdit")
- Object.assign(ctx.uiState, {
- ilwiSubFilter: btn.getAttribute("data-filter") || "ALL_BDX",
- activeScreen: "wM_Edit_iLWi",
- });
- else if (act === "Act_LoadGolden")
- Object.assign(ctx, { state: Calc.createDefaultState(), dbfData: null });
renderWorkspace();
});
- onChange("json-file-input", (e) => {
- const file = e.target.files && e.target.files[0];
- if (!file) return;
- const r = new FileReader();
- r.onload = () => ((ctx.state = JSON.parse(String(r.result))), renderWorkspace());
- r.readAsText(file);
- });
try {
const [srcRes, catRes] = await Promise.all([fetch("/api/sources"), fetch("/api/catalog")]);
diff --git a/tools/stmate-test/app_modals.js b/tools/stmate-test/app_modals.js
index b74a5e149..b6483d335 100644
--- a/tools/stmate-test/app_modals.js
+++ b/tools/stmate-test/app_modals.js
@@ -1,158 +1,59 @@
/**
- * STMATE 도구 창(wITNum · wBoqRate · wM_Rate · wExpnd · wQtyList · wM_Boxa · wM_Edt_Hal) 바인딩 (app_modals.js)
- * app.js 에서 떼어 냄 — api = window.STMATE_APP
+ * STMATE 도구 창 공통 바인딩 · 내역 행 끼워 넣기 (app_modals.js) — api = window.STMATE_APP
+ * 내역 도구(wITNum · wBoqRate · wM_Rate · wExpnd)는 scr_ui_boq_dlg.js · 가격지 비교(wM_Boxa)·기준단가 수정(wM_Edt_Hal)은 scr_ui_dan.js
+ * 여기 남은 것 = 수량산출서 연결(wQtyList 옛 창) · 표준 카탈로그 특수산식 불러오기
*/
(function (global) {
"use strict";
- const ViewPrjBoq = () => global.STMATE_VIEW_PRJ_BOQ;
-
function bindModalEvents(api) {
- const { ctx, Calc, num, $, onClick, bindEach, closeModal, renderWorkspace, cascadeAndRender } =
- api;
- const insertBoqRow = (...a) => api.insertBoqRow(...a);
- onClick("modal-apply-itnum", () => {
- if (!ctx.dbfData || !ctx.dbfData.boq_rows) return;
- let t4 = 0,
- t2 = 0,
- lf = 0;
- const fmtVal = $("itnum-leaf-fmt").value;
- ctx.dbfData.boq_rows.forEach((r) => {
- const bd = String(r.BDCODE1 || "").trim();
- if (bd === "T4") ((t4 += 1), (t2 = 0), (r.ITNUM = `${t4}.`));
- else if (bd === "T2") ((t2 += 1), (lf = 0), (r.ITNUM = `${t4}.${t2}`));
- else if (bd && !/^(T\d|E\d|C\d|:)$/.test(bd)) {
- lf += 1;
- r.ITNUM = fmtVal === "01" ? String(lf).padStart(2, "0") : fmtVal === "-1" ? `-${lf}` : "";
- }
- });
- closeModal();
- renderWorkspace();
- });
- onClick("modal-apply-boqrate", () => {
- const [rm, rl, rs] = ["m", "l", "s"].map((k) => num($(`boqrate-${k}`).value, 100) / 100);
- ctx.state.resources.forEach((r) => {
- const mult = r.kind === "재료비" ? rm : r.kind === "노무비" ? rl : rs;
- r.manualPrice = Math.floor(num(r.manualPrice) * mult);
- });
- closeModal();
- renderWorkspace();
- });
- onClick("modal-apply-mrate", () => {
- ctx.state.sysConfig.ecdDigits = parseInt($("mrate-digits").value, 10) || 0;
- closeModal();
- renderWorkspace();
- });
- onClick("modal-apply-expnd", () => {
- const code = ($("expnd-code") || {}).value;
- const ilwi =
- (ctx.state.ilwiItems || []).find((x) => x.code === code) || ctx.state.ilwiItems[0];
- ((ilwi && ilwi.lines) || []).forEach((ln) => {
- const r = ctx.state.resources.find((x) => x.id === ln.refId || x.code === ln.refId);
- insertBoqRow(
- r ? r.code : ln.refId,
- `[전개] ${r ? r.name : ln.refId}`,
- r ? r.spec : "",
- r ? r.unit : "식",
- ln.qty || 1,
- );
- });
- closeModal();
- renderWorkspace();
- });
+ const { ctx, Calc, bindEach, closeModal, renderWorkspace } = api;
bindEach(".btn-apply-qty-expr", "onclick", (btn) => {
const id = btn.getAttribute("data-id");
const inp = document.querySelector(`.qty-expr-input[data-id="${id}"]`);
const val = inp ? Calc.evalFormula(inp.value, {}) : 0;
const item = ctx.state.boqItems.find((b) => b.id === id);
- if (item && val > 0) {
- item.qty = Math.round(val * 100) / 100;
- const drow =
- ctx.dbfData &&
- ctx.dbfData.boq_rows &&
- ctx.dbfData.boq_rows.find((x) => x._recno === item._recno);
- if (drow) drow.QTY1 = String(item.qty);
- renderWorkspace();
- }
- });
- onClick("modal-apply-boxa", () => {
- const mode = $("boxa-adopt-mode").value;
- (ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
- const code = String(r.CODE || "").trim();
- if (!code.startsWith("M")) return;
- const nonZero = [
- num(r.AMT_11),
- num(r.AMT_21),
- num(r.AMT_31),
- num(r.AMT_41),
- num(r.AMT_51),
- ].filter((x) => x > 0);
- if (nonZero.length) {
- const picked =
- mode === "min" ? Math.min(...nonZero) : nonZero[parseInt(mode, 10)] || nonZero[0];
- ViewPrjBoq().syncCostnUnitPrice(ctx, code, picked, 0, 0);
- }
- });
- closeModal();
- cascadeAndRender();
- });
- onClick("modal-apply-edthal", () => {
- const kind = $("edthal-kind").value,
- pct = num($("edthal-pct").value, 100) / 100,
- rmode = $("edthal-round").value;
- (ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => {
- const code = String(r.CODE || "").trim();
- if (!code || r.UC === "T" || (kind !== "ALL" && code[0].toUpperCase() !== kind)) return;
- const fn = (v) => (rmode === "round" ? Math.round(v * pct) : Math.floor(v * pct));
- ViewPrjBoq().syncCostnUnitPrice(
- ctx,
- code,
- fn(num(r.MAMT1)),
- fn(num(r.LAMT1)),
- fn(num(r.SAMT1)),
- );
- });
- closeModal();
- cascadeAndRender();
+ if (!item || !(val > 0) || !ctx.dbfData) return;
+ const B = global.STMATE_UI_BOQ;
+ const model = { rows: ctx.dbfData.boq_rows, items: ctx.state.boqItems, deleted: [] };
+ B.setCell(model, item._recno, "QTY1", Math.round(val * 100) / 100);
+ renderWorkspace();
});
bindEach(".btn-use-special-san", "onclick", (btn) => {
const spec = (ctx.catalogData.special_san || [])[parseInt(btn.getAttribute("data-idx"), 10)];
- if (!spec) return;
- ctx.uiState.editedSmtMap[ctx.uiState.selectedIlwiCode || "D00016"] = (
- spec["산식행"] || []
- ).join("\r\n");
+ if (!spec || !ctx.uiState.selectedIlwiCode) return;
+ ctx.uiState.editedSmtMap[ctx.uiState.selectedIlwiCode] = (spec["산식행"] || []).join("\r\n");
ctx.uiState.activeScreen = "wM_Edit_iLWi";
closeModal();
renderWorkspace();
});
}
+ /**
+ * 내역 행 끼워 넣기 — 선택 행 앞(선택 없으면 끝) · 저장 원천(boq_rows)·계산 원천(boqItems) 함께
+ * 코드가 단가목록에 있으면 명칭·규격·단위를 단가목록 값으로
+ */
function insertBoqRow(api, code, name, spec, unit, qty) {
const { ctx, renderWorkspace } = api;
- const recno = Date.now() % 100000;
- ctx.state.boqItems.push({
- id: "BOQ_" + recno,
- code,
- name,
- refId: code,
- rowType: "normal",
- qty,
- changedQty: qty,
- _recno: recno,
+ const B = global.STMATE_UI_BOQ;
+ if (!ctx.dbfData || !ctx.dbfData.boq_rows) return alert("열린 내역서 없음");
+ ctx.uiState.boqDeleted = ctx.uiState.boqDeleted || [];
+ const model = { rows: ctx.dbfData.boq_rows, items: ctx.state.boqItems, deleted: ctx.uiState.boqDeleted };
+ const c = String(code || "").trim();
+ const cr = (ctx.dbfData.costn_rows || []).find((r) => String(r.CODE || "").trim() === c);
+ const row = B.makeRow(model, {
+ BDCODE1: c,
+ NAME1: cr ? cr.NAME || "" : name || "",
+ SIZE1: cr ? cr.SIZE || "" : spec || "",
+ UNIT1: cr ? cr.UNIT || "" : unit || "",
+ QTY1: String(Number(qty) || 0),
});
- if (ctx.dbfData && ctx.dbfData.boq_rows) {
- ctx.dbfData.boq_rows.splice(4, 0, {
- _recno: recno,
- ITNUM: "",
- BDCODE1: code,
- NAME1: name,
- SIZE1: spec,
- UNIT1: unit,
- QTY1: String(qty),
- ENDCODE: "0.0000",
- });
- }
+ const ix = B.indexesOf(model.rows, ctx.uiState.boqSel || []);
+ const res = B.insertRows(model, ix.length ? ix[0] : model.rows.length, [row]);
+ if (!res.ok) alert(res.error);
+ ctx.uiState.boqSel = [row._recno];
renderWorkspace();
+ return row;
}
global.STMATE_APP_MODALS = { bindModalEvents, insertBoqRow };
diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js
index 712990db2..2bfacb5c1 100644
--- a/tools/stmate-test/calc.js
+++ b/tools/stmate-test/calc.js
@@ -20,35 +20,42 @@ const StmateCalc = (() => {
}).format(num(val, 0));
}
+ // 자리 맞춤 — 음수 자리(십·백·천원)는 나눗셈으로 정수 결과를 냄(10^-n 곱셈 오차 방지)
+ const scaleOf = (digits) =>
+ digits >= 0
+ ? { mul: (v) => v * Math.pow(10, digits), back: (s) => s / Math.pow(10, digits) }
+ : { mul: (v) => v / Math.pow(10, -digits), back: (s) => s * Math.pow(10, -digits) };
+
function roundDown(val, digits = 0) {
const n = Number(val) || 0,
- f = Math.pow(10, digits),
- s = n * f;
- return Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)) / f;
+ f = scaleOf(digits),
+ s = f.mul(n);
+ return f.back(Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)));
}
function roundUp(val, digits = 0) {
const n = Number(val) || 0;
if (n === 0) return 0;
- const f = Math.pow(10, digits),
- s = n * f;
- return (n > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9)) / f;
+ const f = scaleOf(digits),
+ s = f.mul(n);
+ return f.back(n > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9));
}
function roundHalfUp(val, digits = 0) {
const n = Number(val) || 0,
- f = Math.pow(10, digits);
- const r = Math.floor(Math.abs(n) * f + 0.5 + 1e-9) / f;
+ f = scaleOf(digits);
+ const r = f.back(Math.floor(f.mul(Math.abs(n)) + 0.5 + 1e-9));
return n < 0 ? -r : r;
}
+ // 은행가 반올림(Delphi RoundTo · FPU 최근접-짝수 — 11번 §4.2)
function roundBanker(val, digits = 0) {
const n = Number(val) || 0,
- f = Math.pow(10, digits),
- s = n * f,
+ f = scaleOf(digits),
+ s = f.mul(n),
fl = Math.floor(s);
- if (Math.abs(s - fl - 0.5) < 1e-9) return (fl % 2 === 0 ? fl : fl + 1) / f;
- return Math.round(s) / f;
+ if (Math.abs(s - fl - 0.5) < 1e-9) return f.back(fl % 2 === 0 ? fl : fl + 1);
+ return f.back(Math.round(s));
}
function applyRoundMode(val, mode, digits = 0) {
@@ -66,7 +73,9 @@ const StmateCalc = (() => {
num,
fmt,
roundDown,
+ roundUp,
roundHalfUp,
+ roundBanker,
applyRoundMode,
});
@@ -371,7 +380,7 @@ const StmateCalc = (() => {
// 새 공사 = 빈 상태 · 요율만 초기값(지식DB 06 · 2024 기준표) — 특정 공사 금액을 담지 않음
function createDefaultState() {
const ratePairs =
- "KANNO:14.5,SANBO:3.56,KOBO:1.01,KUNBO:3.545,SLVBO:12.95,YUNBO:4.5,TBUGUM:0,ANJUN:1.86,KITA:6,HWAN:0.8,EQIP:0.4,ILBAN:6,IYUN:15,S_SUDO:0,S_BOKRI:0,S_SOMO:0,S_TONGSIN:0,S_SEGUM:0,S_PEGI:0,S_DOSEO:0,S_JIGUP:0";
+ "KANNO:14.5,SANBO:3.56,KOBO:1.01,KUNBO:3.545,SLVBO:12.95,YUNBO:4.5,TBUGUM:0,ANJUN:1.86,ANJUN1:1.86,ANJUN2:1.86,KITA:6,HWAN:0.8,EQIP:0.4,ILBAN:6,IYUN:15,S_SUDO:0,S_BOKRI:0,S_SOMO:0,S_TONGSIN:0,S_SEGUM:0,S_PEGI:0,S_DOSEO:0,S_JIGUP:0";
const rates = Object.fromEntries(
ratePairs.split(",").map((p) => [p.split(":")[0], Number(p.split(":")[1])]),
);
@@ -394,21 +403,23 @@ const StmateCalc = (() => {
optOrder: "SML",
};
sysConfig.kOrdSim = { pclass: 0, regMode: 7, flag: 0, currentK: 1 };
+ // 원본 키 초기값 — 일반형식 · 안전비 관급포함 자동 · 고용 7등급 · 환경 기타토목 · 사급 재료비 포함 · 관급 천원 절상
+ const keys = Object.fromEntries(
+ "JAB_TYPE:0,CAL_ACTL:0,I_KONG_TYPE:0,I_SET_BASE:0,I_KONG_KIKAN:0,I_KOBO_TYPE:7,I_TBUGUM:3,I_ANJUN_TYPE:1,I_HWAN:14,I_EQIP:0,I_POS_SA:3,I_KN_SA_TYPE:0,I_ANJUN_K:1,I_ANJUN_B:0,I_ANJUNAUTO:1,I_BUGA:0,I_TOT_JEOL:3,I_KWN_JEOLSA:3,I_KWN_K_JEOLSA:3,I_KWN_UPDN:0,I_KWN_K_UPDN:0,I_KAN_JEOLSA:0,I_JEOLSA:0,I_DOG_JEOL:0,C_ANJUN_G:0,C_ANJUN1_G:0,C_ANJUN2_G:0,C_KWANGUP_D:0,C_KWANGUP_K:0,C_T_JODAL:0,C_IYUN_M:0"
+ .split(",")
+ .map((p) => [p.split(":")[0], Number(p.split(":")[1])]),
+ );
const kanjubConfig = {
+ slot: 1,
scheme: "T",
- indirectTruncDigits: 0,
- anjunBaseAdd: 0,
- anjunGovVatExcluded: true,
- anjunCap12: true,
- vatMode: "supply_10",
- totJeolDigits: -3,
- enableProfitEndAdj: true,
- iyunManualAdj: 0,
- govMat: 0,
- govFee: 0,
+ keys,
+ addRows: [],
addAmounts: [],
+ fixed: {},
+ anjunAuto: 1,
+ anjunBaseAdd: 0,
};
- Object.assign(kanjubConfig, { govRoundMode: "up", govRoundDigits: -3, rates, revModes });
+ Object.assign(kanjubConfig, { rates, revModes });
return Object.assign(
{ projectMeta, sysConfig, kanjubConfig },
{ resources: [], machines: [], sanItems: [], ilwiItems: [], boqItems: [] },
@@ -427,18 +438,21 @@ const StmateCalc = (() => {
kanjubRev = st[2]
? calcKanjub(data, boqRev, kanjubOrig, st[2])
: calcKanjub(data, boqRev, kanjubOrig);
+ // 차수 n 원가계산서 — 변경 계열은 바로 앞 차수(n−1)를 당초로 씀(REA 18 직전 계열 = 현재−1)
const stages = {};
- for (const n of Object.keys(st).map(Number)) {
+ for (const n of Object.keys(st)
+ .map(Number)
+ .sort((a, b) => a - b)) {
const boq =
n === 1
? boqOrig
: n === 2
? boqRev
: calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, n);
+ const prev = (stages[n - 1] && stages[n - 1].kanjub) || kanjubOrig;
stages[n] = {
boq,
- kanjub:
- n === 1 ? kanjubOrig : n === 2 ? kanjubRev : calcKanjub(data, boq, kanjubOrig, st[n]),
+ kanjub: n === 1 ? kanjubOrig : n === 2 ? kanjubRev : calcKanjub(data, boq, prev, st[n]),
};
}
const revMap = Object.fromEntries((boqRev.rows || []).map((r) => [r.id, r]));
diff --git a/tools/stmate-test/change_calc.js b/tools/stmate-test/change_calc.js
new file mode 100644
index 000000000..a33a5d8a1
--- /dev/null
+++ b/tools/stmate-test/change_calc.js
@@ -0,0 +1,561 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 설계변경 차수 대비 계산 (change_calc.js) — 내역 머리·계 구조 위에 차수별 수량·단가·금액을 얹어 당초/변경/증감을 냄
+ * 근거: 지식DB STmate/old/20_분석 08 §2(변경설계 데이터 모델 · 삭제 0 유지 · 신규 안정 ID) · 16 §4.5~4.6(차수 칸)
+ * · 19 §6.5(변경 직접비 당초·변경·증감) · 35 §4(당초액 유지 4항목) · 24 §7(차수 4벌)
+ * 정답 대조: resources/tester/test_stmate_stage_change.py
+ * (영덕 변경 STC BOQ11 n칸 저장 금액 · 같은 폴더 STMATE 엑셀 설계변경증감대비표·금액대비표·설계변경내역서)
+ */
+const StmateChange = (() => {
+ const STRUCT = /^(T\d|E\d|C\d|:)$/;
+ const CARRY_DEFAULT = ["KUNBO", "SLVBO", "YUNBO", "TBUGUM"];
+ const n0 = (v) => Number(v) || 0;
+ const clone = (o) => JSON.parse(JSON.stringify(o));
+
+ // 차수별 계산 내역 행 — 1·2 차수는 calculateAll 기본 결과, 3·4 는 stages 에 있을 때만
+ function stageRows(computed, n) {
+ const boq =
+ (computed.stages && computed.stages[n] && computed.stages[n].boq) ||
+ (n === 1 ? computed.boqOrig : n === 2 ? computed.boqRev : null);
+ return boq ? boq.rows || [] : [];
+ }
+
+ function emptySum() {
+ return { mAmt: 0, lAmt: 0, sAmt: 0, tAmt: 0 };
+ }
+
+ // 내역 머리(T)·계(E/C)·빈 줄·공종 행을 차례대로 — 머리마다 짝이 되는 닫는 행과 하위 공종 합계
+ function buildTree(boqRows, state, computed, stageList) {
+ const stagesN = stageList && stageList.length ? stageList : [1, 2];
+ const items = (state.boqItems || []).slice();
+ const byRecno = new Map(items.filter((b) => b._recno != null).map((b) => [b._recno, b]));
+ const calcBy = {};
+ for (const n of stagesN) calcBy[n] = new Map(stageRows(computed, n).map((r) => [r.id, r]));
+ const nodes = [];
+ const pushLeaf = (b, dbf) => {
+ const st = {};
+ for (const n of stagesN) {
+ const r = calcBy[n].get(b.id);
+ // 수량 0 칸은 단가도 0(STMATE 저장 · 설계변경내역서 표기 — 삭제·신규 공종)
+ const z = r && n0(r.qty) === 0 ? 0 : 1;
+ st[n] = r
+ ? {
+ qty: n0(r.qty),
+ mUnit: z * n0(r.mUnit),
+ lUnit: z * n0(r.lUnit),
+ sUnit: z * n0(r.sUnit),
+ tUnit: z * n0(r.tUnit),
+ mAmt: n0(r.M),
+ lAmt: n0(r.L),
+ sAmt: n0(r.S),
+ tAmt: n0(r.T),
+ }
+ : Object.assign({ qty: 0, mUnit: 0, lUnit: 0, sUnit: 0, tUnit: 0 }, emptySum());
+ }
+ nodes.push({
+ kind: "leaf",
+ id: b.id,
+ recno: b._recno,
+ code: b.code,
+ itnum: (dbf && dbf.ITNUM) || b.itnum || "",
+ name: b.name,
+ spec: b.spec || "",
+ unit: b.unit || "",
+ gov: !!b.rowType && b.rowType !== "normal",
+ rowType: b.rowType || "normal",
+ isNew: !!b.isNew,
+ st,
+ });
+ if (String(b.unit || "").endsWith("%")) {
+ const nd = nodes[nodes.length - 1];
+ nd.pct = true;
+ nd.pctQty = Object.fromEntries(
+ stagesN.map((n) => {
+ const own = Array.isArray(b.pctQtys) ? b.pctQtys[n - 1] : null;
+ return [n, own != null ? n0(own) : n0(dbf && dbf[`QTY${n}`])];
+ }),
+ );
+ }
+ };
+ // 새 공종 = 소속 머리의 닫는 행 앞에 끼움(없으면 맨 끝 총합계 앞)
+ const added = items.filter((b) => b._recno == null);
+ const addedBy = new Map();
+ for (const b of added) {
+ const k = b.parentRecno != null ? b.parentRecno : "__END__";
+ if (!addedBy.has(k)) addedBy.set(k, []);
+ addedBy.get(k).push(b);
+ }
+ const sortMap = new Map(boqRows.map((r) => [String(r.SORTCODE), r]));
+ const closerOf = new Map();
+ for (const r of boqRows) {
+ const bd = String(r.BDCODE1 || "").trim();
+ if (/^[EC]\d$/.test(bd) && sortMap.has(String(r.ENDCODE)))
+ closerOf.set(r._recno, sortMap.get(String(r.ENDCODE))._recno);
+ }
+ let lastCloser = null;
+ boqRows.forEach((r) => {
+ const bd = String(r.BDCODE1 || "").trim();
+ if (/^[EC]\d$/.test(bd)) lastCloser = r._recno;
+ });
+ boqRows.forEach((r) => {
+ const bd = String(r.BDCODE1 || "").trim();
+ if (/^[EC]\d$/.test(bd)) {
+ const head = closerOf.get(r._recno);
+ const extra = (addedBy.get(head) || []).concat(
+ r._recno === lastCloser ? addedBy.get("__END__") || [] : [],
+ );
+ extra.forEach((b) => pushLeaf(b, null));
+ }
+ if (!bd || bd === ":") {
+ nodes.push({ kind: "blank", recno: r._recno, itnum: r.ITNUM || "", name: r.NAME1 || "" });
+ } else if (STRUCT.test(bd)) {
+ const isHead = bd[0] === "T";
+ nodes.push({
+ kind: isHead ? "head" : "close",
+ code: bd,
+ level: Number(bd.slice(1)),
+ zone: bd[0] === "C",
+ recno: r._recno,
+ sortcode: String(r.SORTCODE),
+ endcode: String(r.ENDCODE),
+ itnum: r.ITNUM || "",
+ name: r.NAME1 || "",
+ spec: r.SIZE1 || "",
+ st: {},
+ });
+ } else {
+ const b = byRecno.get(r._recno);
+ if (b) pushLeaf(b, r);
+ else
+ nodes.push({
+ kind: "leaf",
+ missing: true,
+ recno: r._recno,
+ code: bd,
+ itnum: r.ITNUM || "",
+ name: r.NAME1 || "",
+ spec: r.SIZE1 || "",
+ unit: r.UNIT1 || "",
+ st: Object.fromEntries(stagesN.map((n) => [n, Object.assign({ qty: 0 }, emptySum())])),
+ });
+ }
+ });
+ if (!boqRows.length) added.forEach((b) => pushLeaf(b, null));
+ applyPct(nodes, stagesN);
+ sumHeads(nodes, stagesN);
+ return { nodes, stages: stagesN };
+ }
+
+ // % 단위 공종(조달수수료 등) — 같은 머리 안 앞선 공종 금액 합 × 수량% · 단가 칸 = 밑수(STMATE 저장 방식)
+ function applyPct(nodes, stagesN) {
+ let group = [];
+ for (const nd of nodes) {
+ if (nd.kind === "head" || nd.kind === "close") group = [];
+ if (nd.kind !== "leaf") continue;
+ if (!nd.pct) {
+ group.push(nd);
+ continue;
+ }
+ for (const n of stagesN) {
+ const base = { m: 0, l: 0, s: 0 };
+ for (const g of group) for (const c of ["m", "l", "s"]) base[c] += n0(g.st[n][c + "Amt"]);
+ const q = n0(nd.pctQty[n]);
+ const a = {};
+ for (const c of ["m", "l", "s"]) a[c] = Math.trunc((base[c] * q) / 100 + 1e-9);
+ nd.st[n] = {
+ qty: q,
+ mUnit: base.m,
+ lUnit: base.l,
+ sUnit: base.s,
+ tUnit: base.m + base.l + base.s,
+ mAmt: a.m,
+ lAmt: a.l,
+ sAmt: a.s,
+ tAmt: a.m + a.l + a.s,
+ };
+ }
+ }
+ }
+
+ // 머리 합계 — 닫는 행이 C(제외금액 구역)이면 관급 공종까지, 아니면 일반 공종만
+ function sumHeads(nodes, stagesN) {
+ const idxBySort = new Map();
+ nodes.forEach((nd, i) => nd.kind === "head" && idxBySort.set(nd.sortcode, i));
+ const spans = [];
+ nodes.forEach((nd, j) => {
+ if (nd.kind !== "close") return;
+ const i = idxBySort.get(nd.endcode);
+ if (i == null) return;
+ nodes[i].closeIdx = j;
+ nodes[i].zone = nd.zone;
+ nd.headIdx = i;
+ spans.push([i, j, nd.zone]);
+ });
+ const inZone = new Array(nodes.length).fill(false);
+ spans.forEach(([i, j, z]) => z && inZone.fill(true, i, j + 1));
+ nodes.forEach((nd, i) => {
+ if (nd.kind !== "head") return;
+ const end = nd.closeIdx != null ? nd.closeIdx : nodes.length;
+ const s = Object.fromEntries(stagesN.map((n) => [n, emptySum()]));
+ for (let k = i + 1; k < end; k++) {
+ const lf = nodes[k];
+ if (lf.kind !== "leaf" || (lf.gov && !inZone[i])) continue;
+ for (const n of stagesN)
+ for (const key of ["mAmt", "lAmt", "sAmt", "tAmt"]) s[n][key] += n0(lf.st[n][key]);
+ }
+ nd.st = s;
+ if (nd.closeIdx != null) nodes[nd.closeIdx].st = clone(s);
+ });
+ }
+
+ // 설계변경증감대비표 — 닫는 행 빼고 · 머리는 금액만 · 공종은 수량·금액 당초/변경/증감
+ function diffTable(tree, a = 1, b = 2) {
+ return tree.nodes
+ .filter((nd) => nd.kind !== "close")
+ .map((nd) => {
+ const sa = (nd.st && nd.st[a]) || emptySum(),
+ sb = (nd.st && nd.st[b]) || emptySum();
+ const leaf = nd.kind === "leaf";
+ const qa = leaf ? n0(sa.qty) : 0,
+ qb = leaf ? n0(sb.qty) : 0;
+ return {
+ kind: nd.kind,
+ id: nd.id,
+ itnum: nd.kind === "head" ? nd.itnum : "",
+ name: nd.name,
+ spec: nd.spec || "",
+ unit: leaf ? nd.unit : "",
+ code: leaf ? nd.code : "",
+ qa,
+ qb,
+ qd: Math.round((qb - qa) * 1e6) / 1e6,
+ aa: n0(sa.tAmt),
+ ab: n0(sb.tAmt),
+ ad: n0(sb.tAmt) - n0(sa.tAmt),
+ state: !leaf ? "" : qa === 0 && qb !== 0 ? "신규" : qa !== 0 && qb === 0 ? "삭제" : "",
+ };
+ });
+ }
+
+ // 설계변경내역서 — 공종마다 당초 줄·변경 줄(수량·단가·금액 4벌)
+ function changeStatement(tree, a = 1, b = 2) {
+ const out = [];
+ for (const nd of tree.nodes) {
+ if (nd.kind === "close" || nd.kind === "blank") continue;
+ for (const n of [a, b]) {
+ const s = (nd.st && nd.st[n]) || emptySum();
+ out.push({
+ kind: nd.kind,
+ stage: n,
+ first: n === a,
+ itnum: n === a && nd.kind === "head" ? nd.itnum : "",
+ name: n === a ? nd.name : "",
+ spec: n === a ? nd.spec || "" : "",
+ unit: n === a && nd.kind === "leaf" ? nd.unit : "",
+ qty: nd.kind === "leaf" ? n0(s.qty) : null,
+ tUnit: nd.kind === "leaf" ? n0(s.tUnit) : 0,
+ lUnit: nd.kind === "leaf" ? n0(s.lUnit) : 0,
+ mUnit: nd.kind === "leaf" ? n0(s.mUnit) : 0,
+ sUnit: nd.kind === "leaf" ? n0(s.sUnit) : 0,
+ tAmt: n0(s.tAmt),
+ lAmt: n0(s.lAmt),
+ mAmt: n0(s.mAmt),
+ sAmt: n0(s.sAmt),
+ });
+ }
+ }
+ return out;
+ }
+
+ // 원가계산서 항목 순서(금액대비표) — 0원 항목은 빼고 차례 번호
+ function kanjubRows(kjA, kjB) {
+ const codes = kjA.items.map((it) => it.code);
+ return codes
+ .map((c) => {
+ const ia = kjA.itemMap[c],
+ ib = kjB.itemMap[c] || { amount: 0 };
+ return { code: c, label: ia.label, a: n0(ia.amount), b: n0(ib.amount) };
+ })
+ .filter((r) => r.a !== 0 || r.b !== 0)
+ .map((r) => Object.assign(r, { d: r.b - r.a }));
+ }
+
+ // 설계변경금액대비표 — 머리(최상위 아래 2단까지) + 순공사비 · 간접비 · 일반관리비 · 이윤 · 부가세 · 도급
+ function amountTable(tree, kjA, kjB, a = 1, b = 2, maxDepth = 2) {
+ const work = workHeads(tree, maxDepth).map((h) => ({
+ kind: "head",
+ itnum: h.itnum,
+ name: h.name,
+ spec: h.spec,
+ a: n0(h.st[a] && h.st[a].tAmt),
+ b: n0(h.st[b] && h.st[b].tAmt),
+ }));
+ const line = (label, va, vb, kind = "sum") => ({ kind, name: label, a: va, b: vb });
+ const ind = kanjubRows(kjA, kjB).map((r, i) =>
+ line(`${String(i + 1).padStart(2)}. ${r.label}`, r.a, r.b, "item"),
+ );
+ const nextNo = ind.length + 1;
+ const sub1 = (k) => k.dT + k.kanno + k.indirectExpenseSum;
+ const rows = work.concat([
+ line("가. 순공사비계", kjA.dT, kjB.dT),
+ ...ind,
+ line("나. 소계", sub1(kjA), sub1(kjB)),
+ line(`${nextNo}. 일반관리비`, kjA.ilbanAmt, kjB.ilbanAmt, "item"),
+ line("다. 소계", sub1(kjA) + kjA.ilbanAmt, sub1(kjB) + kjB.ilbanAmt),
+ line(`${nextNo + 1}. 이윤`, kjA.iyunFinal, kjB.iyunFinal, "item"),
+ line("라. 공급가액", kjA.supplyFinal, kjB.supplyFinal),
+ line(`${nextNo + 2}. 부가가치세`, kjA.vatFinal, kjB.vatFinal, "item"),
+ line("마. 도급공사비", kjA.contractFinal, kjB.contractFinal),
+ ]);
+ if (kjA.grandTotal !== kjA.contractFinal || kjB.grandTotal !== kjB.contractFinal) {
+ rows.push(line("바. 관급자재대", kjA.govFinal, kjB.govFinal));
+ rows.push(line("총공사비", kjA.grandTotal, kjB.grandTotal));
+ }
+ rows.forEach((r) => (r.d = n0(r.b) - n0(r.a)));
+ return { rows, totalA: kjA.grandTotal, totalB: kjB.grandTotal };
+ }
+
+ // 머리 고르기 — 관급 구역 빼고 최상위에서 maxDepth 단 아래까지
+ function workHeads(tree, maxDepth = 2) {
+ const heads = tree.nodes.filter((nd) => nd.kind === "head");
+ const top = heads.length ? Math.max(...heads.map((h) => h.level)) : 0;
+ return heads.filter((h) => !h.zone && top - h.level <= maxDepth);
+ }
+
+ // 변경 총괄내역서(0661) — 머리마다 당초 줄·변경 줄(합계·노무비·재료비·경비)
+ function summaryRows(tree, a = 1, b = 2, maxDepth = 2) {
+ const out = [];
+ for (const h of workHeads(tree, maxDepth))
+ for (const n of [a, b]) {
+ const s = h.st[n] || emptySum();
+ out.push({
+ stage: n,
+ itnum: n === a ? h.itnum : "",
+ name: n === a ? h.name : "",
+ spec: n === a ? h.spec : "",
+ tAmt: n0(s.tAmt),
+ lAmt: n0(s.lAmt),
+ mAmt: n0(s.mAmt),
+ sAmt: n0(s.sAmt),
+ });
+ }
+ return out;
+ }
+
+ // 공사원가계산서 당초/변경 대비(PM_Won_A_C) — 비목 차례 · 0원 간접비 항목 뺌 · onlyChanged = 변경분만
+ function costCompare(kA, kB, onlyChanged = false) {
+ const L = (group, label, f, note = "") => ({ group, label, a: n0(f(kA)), b: n0(f(kB)), note });
+ const amt = (code) => (k) => (k.itemMap[code] ? k.itemMap[code].amount : 0);
+ const add = (st) => (k) => (k.adds || []).filter((x) => x.stage === st);
+ const addRows = (st, group) => {
+ const names = [
+ ...new Set(
+ add(st)(kA)
+ .concat(add(st)(kB))
+ .map((x) => x.name),
+ ),
+ ];
+ return names.map((nm) =>
+ L(group, nm, (k) =>
+ add(st)(k)
+ .filter((x) => x.name === nm)
+ .reduce((acc, x) => acc + n0(x.amount), 0),
+ ),
+ );
+ };
+ const items = kA.items
+ .filter((it) => it.code !== "KANNO")
+ .filter((it) => n0(it.amount) !== 0 || n0(amt(it.code)(kB)) !== 0)
+ .map((it) => L("경비", it.label, amt(it.code), it.formula));
+ const rows = [
+ L("재료비", "직접재료비", (k) => k.dM),
+ L("재료비", "간접재료비", () => 0),
+ L("재료비", "작업설,부산물등(△)", () => 0),
+ L("재료비", "소계", (k) => k.dM),
+ L("노무비", "직접노무비", (k) => k.dL),
+ L("노무비", "간접노무비", (k) => k.kanno),
+ L("노무비", "소계", (k) => k.totalLabor),
+ L("경비", "산출경비", (k) => k.dS),
+ ...items,
+ ...addRows(1, "경비"),
+ L("경비", "소계", (k) => k.totalExpense),
+ L("", "일반관리비", (k) => k.ilbanAmt),
+ L("", "이윤", (k) => k.iyunFinal),
+ ...addRows(2, ""),
+ L("", "총원가", (k) => k.supplyFinal),
+ L("", "부가가치세", (k) => k.vatFinal),
+ ...addRows(3, ""),
+ L("", "도급공사비", (k) => k.contractFinal),
+ ];
+ if (kA.govFinal || kB.govFinal) rows.push(L("", "관급자재대", (k) => k.govFinal));
+ rows.push(...addRows(4, ""));
+ rows.push(L("", "총공사비", (k) => k.grandTotal));
+ rows.forEach((r) => (r.d = r.b - r.a));
+ return onlyChanged ? rows.filter((r) => r.d !== 0) : rows;
+ }
+
+ // 변경율 내역서(0645) — 머리별 증감율(%)
+ function changeRateTable(tree, a = 1, b = 2) {
+ return diffTable(tree, a, b)
+ .filter((r) => r.kind === "head")
+ .map((r) =>
+ Object.assign(r, { rate: r.aa ? Math.round((r.ad / r.aa) * 100000) / 1000 : null }),
+ );
+ }
+
+ // ── 입력(설계변경 작업) ──
+ const findItem = (state, id) => (state.boqItems || []).find((b) => b.id === id);
+ const ensureQtys = (b) => {
+ if (!Array.isArray(b.qtys)) b.qtys = [n0(b.qty), n0(b.changedQty ?? b.qty), 0, 0];
+ while (b.qtys.length < 4) b.qtys.push(0);
+ if (!b.units) b.units = {};
+ return b;
+ };
+
+ function setQty(state, id, stage, qty) {
+ const b = ensureQtys(findItem(state, id));
+ if (String(b.unit || "").endsWith("%")) {
+ if (!Array.isArray(b.pctQtys)) b.pctQtys = [null, null, null, null];
+ b.pctQtys[stage - 1] = n0(qty);
+ return b;
+ }
+ b.qtys[stage - 1] = n0(qty);
+ if (stage === 1) b.qty = n0(qty);
+ if (stage === 2) b.changedQty = n0(qty);
+ return b;
+ }
+
+ // 변경 단가 — 차수 2~4 칸에 [재,노,경]
+ function setUnit(state, id, stage, mls) {
+ if (stage < 2) throw new Error("당초(1차수) 단가는 단가 화면에서 바꿈");
+ const b = ensureQtys(findItem(state, id));
+ b.units[stage] = mls.map(n0);
+ return b;
+ }
+
+ // 삭제 = 그 차수 수량 0(행은 남김 · 코드 대응 보존)
+ const deleteInStage = (state, id, stage) => setQty(state, id, stage, 0);
+
+ // 신규 공종 — 앞 차수 수량 0 · 안정 ID(NEW_n) · 소속 머리 recno
+ function addItem(state, spec) {
+ const used = new Set((state.boqItems || []).map((b) => b.id));
+ let k = 1;
+ while (used.has(`NEW_${k}`)) k++;
+ const stage = spec.stage || 2;
+ const qtys = [0, 0, 0, 0];
+ qtys[stage - 1] = n0(spec.qty);
+ const units = {};
+ if (spec.mls) for (let n = Math.max(2, stage); n <= 4; n++) units[n] = spec.mls.map(n0);
+ for (let n = stage; n < 4; n++) qtys[n] = qtys[n] || 0;
+ const item = {
+ id: `NEW_${k}`,
+ code: spec.code || "",
+ itnum: "",
+ name: spec.name || "신규 공종",
+ spec: spec.spec || "",
+ unit: spec.unit || "",
+ refId: spec.refId || spec.code || "",
+ rowType: "normal",
+ qty: qtys[0],
+ changedQty: qtys[1],
+ qtys,
+ units,
+ isNew: true,
+ parentRecno: spec.parentRecno ?? null,
+ };
+ state.boqItems.push(item);
+ return item;
+ }
+
+ // 차수 시작 — 앞 차수 수량·단가·원가설정을 복사, 당초액 유지 항목은 당초 금액 고정
+ function startStage(state, n, kjOrig, carry = CARRY_DEFAULT, boqRows = []) {
+ if (n < 2 || n > 4) throw new Error("변경 차수는 2~4");
+ const dbfBy = new Map(boqRows.map((r) => [r._recno, r]));
+ for (const b of state.boqItems || []) {
+ ensureQtys(b);
+ if (String(b.unit || "").endsWith("%")) {
+ const r = dbfBy.get(b._recno) || {};
+ if (!Array.isArray(b.pctQtys))
+ b.pctQtys = [1, 2, 3, 4].map((k) => (r[`QTY${k}`] ? n0(r[`QTY${k}`]) : null));
+ b.pctQtys[n - 1] = b.pctQtys[n - 2] != null ? b.pctQtys[n - 2] : n0(r[`QTY${n - 1}`]);
+ }
+ b.qtys[n - 1] = b.qtys[n - 2];
+ if (b.units[n - 1]) b.units[n] = b.units[n - 1].slice();
+ if (n === 2) b.changedQty = b.qtys[1];
+ }
+ const st = (state.kanjubStages = state.kanjubStages || {});
+ const prev = st[n - 1] || state.kanjubConfig || {};
+ const cfg = clone(prev);
+ cfg.slot = n;
+ cfg.fixed = {};
+ if (kjOrig)
+ for (const c of carry) if (kjOrig.itemMap[c]) cfg.fixed[c] = kjOrig.itemMap[c].amount;
+ if (cfg.anjunAuto == null || cfg.anjunAuto === 1) cfg.anjunAuto = 2;
+ st[n] = cfg;
+ if (!st[1]) st[1] = Object.assign(clone(state.kanjubConfig || {}), { slot: 1 });
+ return cfg;
+ }
+
+ // 간접비 항목 변경 계산방식 — recalculated · carry_original · proportional(방법 A) · delta_rate(방법 B)
+ function setRevMode(state, n, code, mode, kjOrig) {
+ const cfg = state.kanjubStages[n];
+ cfg.revModes = cfg.revModes || {};
+ cfg.fixed = cfg.fixed || {};
+ if (mode === "carry_original" && kjOrig && kjOrig.itemMap[code])
+ cfg.fixed[code] = kjOrig.itemMap[code].amount;
+ else delete cfg.fixed[code];
+ cfg.revModes[code] = mode === "carry_original" ? "recalculated" : mode;
+ return cfg;
+ }
+
+ // 저장용 — 차수 n 칸 BOQ 행 갱신값(QTYn·xAMTn·x_AMTn)
+ function boqUpdates(tree, n) {
+ return tree.nodes
+ .filter((nd) => nd.recno != null && nd.kind !== "blank")
+ .map((nd) => {
+ const s = nd.st[n] || emptySum();
+ const up = { _recno: nd.recno };
+ if (nd.kind === "leaf") {
+ up[`QTY${n}`] = s.qty;
+ Object.assign(up, {
+ [`TAMT${n}`]: s.tUnit,
+ [`LAMT${n}`]: s.lUnit,
+ [`MAMT${n}`]: s.mUnit,
+ [`SAMT${n}`]: s.sUnit,
+ });
+ }
+ return Object.assign(up, {
+ [`T_AMT${n}`]: s.tAmt,
+ [`L_AMT${n}`]: s.lAmt,
+ [`M_AMT${n}`]: s.mAmt,
+ [`S_AMT${n}`]: s.sAmt,
+ });
+ });
+ }
+
+ return {
+ CARRY_DEFAULT,
+ stageRows,
+ buildTree,
+ applyPct,
+ sumHeads,
+ diffTable,
+ changeStatement,
+ kanjubRows,
+ amountTable,
+ workHeads,
+ summaryRows,
+ costCompare,
+ changeRateTable,
+ setQty,
+ setUnit,
+ deleteInStage,
+ addItem,
+ startStage,
+ setRevMode,
+ boqUpdates,
+ };
+})();
+
+if (typeof window !== "undefined") window.STMATE_CHANGE = StmateChange;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateChange;
diff --git a/tools/stmate-test/contract_calc.js b/tools/stmate-test/contract_calc.js
new file mode 100644
index 000000000..eedd61cd9
--- /dev/null
+++ b/tools/stmate-test/contract_calc.js
@@ -0,0 +1,269 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 계약 변환·계약 원가계산 (contract_calc.js) — 설계 내역 → 계약 내역(비목별 단가적용율 · 적용제외 공정 · W코드 공내역)
+ * 근거: 지식DB STmate/old/20_분석 27 §2(wM_Mk_Cont 노·재·경 적용율 · 옵션 · 적용제외 · 예상적용율 찾기)
+ * · 26 §4(NAGCD 낙찰율 열 · 계약단가) · 35 §3(계약 단계) · 16 §4.5(BOQ11 설계 + BOQ12 계약)
+ * 규칙(울진 2025 STC 실증): 계약 단가 = 비목별 trunc(설계 단가 × 적용율/100) · 금액 = 비목별 trunc(단가 × 수량)
+ * · 새 계약 코드 = 머리글자별 기존 최대 번호 다음부터 내역 차례 · 적용제외 공정은 코드·단가 그대로
+ * · 계약 원가계산 = 계약 직접비 위에 계약 차수 원가설정(당초액 유지 항목 고정)
+ * 정답 대조: resources/tester/test_stmate_stage_contract.py (BOQ12 계약내역 · PRJLST 계약 행 · 계약 SMT)
+ */
+const StmateContract = (() => {
+ const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js");
+ const KF =
+ (typeof window !== "undefined" && window.STMATE_KANJUB_FACTORY) || require("./kanjub_calc.js");
+ const Change =
+ (typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js");
+ const { roundDown, roundHalfUp, roundUp } = Calc;
+ const applyRoundMode = (v, mode, d = 0) =>
+ mode === "up" ? roundUp(v, d) : mode === "half" ? roundHalfUp(v, d) : roundDown(v, d);
+ const calcKanjub = KF(Object.assign({}, Calc, { applyRoundMode }));
+ const n0 = (v) => Number(v) || 0;
+ const COMPS = [
+ ["m", "M", "rateM"],
+ ["l", "L", "rateL"],
+ ["s", "S", "rateS"],
+ ];
+ // 계약 원가계산에서 설계 금액을 그대로 쓰는 항목(D_ 표시) — 울진 2025 계약 SMT 실증 · 35번 §4
+ const CONTRACT_CARRY = ["KUNBO", "SLVBO", "YUNBO", "TBUGUM", "ANJUN"];
+
+ const DEFAULT_OPTS = {
+ rateL: 100,
+ rateM: 100,
+ rateS: 100,
+ digits: 0,
+ excludeHeads: [],
+ excludeLeaves: [],
+ excludeGov: true,
+ zeroToW: true,
+ sameCodeSeparate: false,
+ };
+
+ // 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터
+ function codeBaseFromCostn(costnRows) {
+ const base = {};
+ for (const r of costnRows || []) {
+ const m = /^([A-Z])(\d+)$/.exec(String(r.CODE || "").trim());
+ if (m) base[m[1]] = Math.max(base[m[1]] || 0, Number(m[2]));
+ }
+ return base;
+ }
+
+ // 적용제외 판정 — 고른 머리 아래 전부 · 고른 공종 · (옵션) 관급 구역
+ function excludedSet(tree, o) {
+ const ex = new Set();
+ const heads = new Set((o.excludeHeads || []).map(String));
+ const leaves = new Set((o.excludeLeaves || []).map(String));
+ tree.nodes.forEach((nd, i) => {
+ if (nd.kind === "head" && heads.has(String(nd.recno))) {
+ const end = nd.closeIdx != null ? nd.closeIdx : tree.nodes.length;
+ for (let k = i; k <= end && k < tree.nodes.length; k++) ex.add(k);
+ }
+ if (nd.kind === "leaf") {
+ if (leaves.has(String(nd.id)) || leaves.has(String(nd.recno))) ex.add(i);
+ if (o.excludeGov && nd.gov) ex.add(i);
+ }
+ });
+ return ex;
+ }
+
+ // 설계 → 계약 변환 — 행별 단가·금액과 머리 합계 · 직접비·관급 합계
+ function convert(tree, opts, stage = 1, codeBase = null) {
+ const o = Object.assign({}, DEFAULT_OPTS, opts || {});
+ const ex = excludedSet(tree, o);
+ const counter = codeBase ? Object.assign({}, codeBase) : null;
+ const codeMap = {};
+ const nextCode = (code) => {
+ const p = code[0];
+ counter[p] = (counter[p] || 0) + 1;
+ return p + String(counter[p]).padStart(Math.max(5, code.length - 1), "0");
+ };
+ const nodes = tree.nodes.map((nd, i) => {
+ const out = Object.assign({}, nd, { st: {} });
+ if (nd.kind !== "leaf") return out;
+ const d = nd.st[stage] || {};
+ const excluded = ex.has(i);
+ const unit = {};
+ for (const [c, , rk] of COMPS) {
+ const u = n0(d[c + "Unit"]);
+ unit[c] = excluded ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits));
+ }
+ const qty = n0(d.qty);
+ const amt = {};
+ for (const [c] of COMPS) amt[c] = roundDown(unit[c] * qty, 0);
+ const tUnit = unit.m + unit.l + unit.s;
+ let newCode = nd.code;
+ const designT = n0(d.tUnit);
+ const zeroed = !excluded && o.zeroToW && designT !== 0 && tUnit === 0;
+ if (!excluded && counter && nd.code) {
+ const key = zeroed ? "W" + nd.code.slice(1) : nd.code;
+ if (!o.sameCodeSeparate && codeMap[key]) newCode = codeMap[key];
+ else {
+ newCode = nextCode(zeroed ? "W" + nd.code.slice(1) : nd.code);
+ codeMap[key] = newCode;
+ }
+ } else if (zeroed) newCode = "W" + nd.code.slice(1);
+ out.excluded = excluded;
+ out.zeroed = zeroed;
+ out.newCode = newCode;
+ out.design = d;
+ out.st[stage] = {
+ qty,
+ mUnit: unit.m,
+ lUnit: unit.l,
+ sUnit: unit.s,
+ tUnit,
+ mAmt: amt.m,
+ lAmt: amt.l,
+ sAmt: amt.s,
+ tAmt: amt.m + amt.l + amt.s,
+ };
+ return out;
+ });
+ nodes.forEach((nd) => nd.pct && (nd.pctQty = { [stage]: n0(nd.pctQty && nd.pctQty[stage]) }));
+ Change.applyPct(nodes, [stage]);
+ Change.sumHeads(nodes, [stage]);
+ let directM = 0,
+ directL = 0,
+ directS = 0,
+ govRawTotal = 0,
+ govFeeTotal = 0;
+ for (const nd of nodes) {
+ if (nd.kind !== "leaf" || nd.missing) continue;
+ const s = nd.st[stage];
+ if (nd.gov) {
+ if (nd.rowType === "gov_fee") govFeeTotal += s.tAmt;
+ else govRawTotal += s.tAmt;
+ } else ((directM += s.mAmt), (directL += s.lAmt), (directS += s.sAmt));
+ }
+ const directTotal = directM + directL + directS;
+ const totals = { directM, directL, directS, directT: directTotal, directTotal };
+ Object.assign(totals, { govRawTotal, govFeeTotal, govCombinedRaw: govRawTotal + govFeeTotal });
+ return { nodes, rows: rowsOf(nodes, stage), totals, codeMap, opts: o, stage };
+ }
+
+ function rowsOf(nodes, stage) {
+ return nodes
+ .filter((nd) => nd.kind !== "blank")
+ .map((nd) => {
+ const s = nd.st[stage] || {};
+ const r = { kind: nd.kind, recno: nd.recno, itnum: nd.itnum, name: nd.name };
+ Object.assign(r, {
+ mAmt: n0(s.mAmt),
+ lAmt: n0(s.lAmt),
+ sAmt: n0(s.sAmt),
+ tAmt: n0(s.tAmt),
+ });
+ if (nd.kind === "leaf")
+ Object.assign(r, {
+ id: nd.id,
+ code: nd.code,
+ newCode: nd.newCode,
+ excluded: nd.excluded,
+ zeroed: nd.zeroed,
+ qty: n0(s.qty),
+ unit: nd.unit,
+ spec: nd.spec,
+ mUnit: n0(s.mUnit),
+ lUnit: n0(s.lUnit),
+ sUnit: n0(s.sUnit),
+ tUnit: n0(s.tUnit),
+ dUnit: n0(nd.design && nd.design.tUnit),
+ dAmt: n0(nd.design && nd.design.tAmt),
+ });
+ return r;
+ });
+ }
+
+ // 계약 원가설정 기본 — 설계 설정 복사 · 당초액 유지 항목은 설계 금액 고정 · 이윤율만 따로
+ function defaultContractCfg(designCfg, designKj, opts = {}) {
+ const cfg = JSON.parse(JSON.stringify(designCfg || {}));
+ cfg.fixed = {};
+ for (const c of opts.carry || CONTRACT_CARRY)
+ if (designKj && designKj.itemMap[c]) cfg.fixed[c] = designKj.itemMap[c].amount;
+ if (opts.iyunRate != null)
+ cfg.rates = Object.assign({}, cfg.rates, { IYUN: n0(opts.iyunRate) });
+ cfg.anjunAuto = 1;
+ return cfg;
+ }
+
+ // 계약 원가계산서 — 계약 직접비 + 계약 원가설정
+ function contractKanjub(conv, cfg) {
+ return calcKanjub({ kanjubConfig: cfg }, conv.totals, null, cfg);
+ }
+
+ // 예상적용율 찾기 — 노·재·경 같은 율 · 0.001% 단위 · 목표 이하 최대
+ // what = "direct"(직접공사비) · "grand"(총공사비, cfg 필요)
+ function findRate(tree, opts, target, stage = 1, what = "direct", cfg = null) {
+ const value = (k) => {
+ const r = k / 1000;
+ const conv = convert(tree, Object.assign({}, opts, { rateL: r, rateM: r, rateS: r }), stage);
+ return what === "grand" && cfg
+ ? contractKanjub(conv, cfg).grandTotal
+ : conv.totals.directTotal;
+ };
+ let lo = 0,
+ hi = 100000;
+ if (value(hi) <= target) return { rate: 100, value: value(hi) };
+ while (hi - lo > 1) {
+ const mid = (lo + hi) >> 1;
+ if (value(mid) <= n0(target)) lo = mid;
+ else hi = mid;
+ }
+ return { rate: lo / 1000, value: value(lo) };
+ }
+
+ // 적용금액 미리계산 — 비목별 설계·계약 합과 실제 적용율
+ function preview(conv) {
+ const d = { m: 0, l: 0, s: 0 },
+ c = { m: 0, l: 0, s: 0 };
+ for (const nd of conv.nodes) {
+ if (nd.kind !== "leaf" || nd.gov) continue;
+ for (const k of ["m", "l", "s"]) {
+ d[k] += n0(nd.design && nd.design[k + "Amt"]);
+ c[k] += n0(nd.st[conv.stage][k + "Amt"]);
+ }
+ }
+ const rate = (a, b) => (a ? Math.round((b / a) * 1e7) / 1e5 : 0);
+ return {
+ design: d,
+ contract: c,
+ rates: { m: rate(d.m, c.m), l: rate(d.l, c.l), s: rate(d.s, c.s) },
+ total: { design: d.m + d.l + d.s, contract: c.m + c.l + c.s },
+ };
+ }
+
+ // 낙찰율 열(NAGCD) 저장값 — 새 계약 코드마다 [코드, 설계 코드, 적용율, 단가]
+ function nagcdRows(conv) {
+ const seen = new Set(),
+ out = [];
+ for (const nd of conv.nodes) {
+ if (nd.kind !== "leaf" || nd.excluded || seen.has(nd.newCode)) continue;
+ seen.add(nd.newCode);
+ const s = nd.st[conv.stage];
+ out.push({
+ code: nd.newCode,
+ from: nd.code,
+ rates: { L: conv.opts.rateL, M: conv.opts.rateM, S: conv.opts.rateS },
+ unit: [s.mUnit, s.lUnit, s.sUnit],
+ design: [nd.design.mUnit, nd.design.lUnit, nd.design.sUnit],
+ });
+ }
+ return out;
+ }
+
+ return {
+ CONTRACT_CARRY,
+ DEFAULT_OPTS,
+ codeBaseFromCostn,
+ convert,
+ defaultContractCfg,
+ contractKanjub,
+ findRate,
+ preview,
+ nagcdRows,
+ };
+})();
+
+if (typeof window !== "undefined") window.STMATE_CONTRACT = StmateContract;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateContract;
diff --git a/tools/stmate-test/exec_calc.js b/tools/stmate-test/exec_calc.js
new file mode 100644
index 000000000..75f76788a
--- /dev/null
+++ b/tools/stmate-test/exec_calc.js
@@ -0,0 +1,149 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 실행예산 계산 (exec_calc.js) — 실행단가·실행수량 · 중기 실행단가(절사 6택 · 차액 보정 순서)
+ * 근거: 지식DB STmate/old/20_분석 27 §4(wM_Boq_ExecX · 1단위당 중기사용료 · 1단위=시간 · 최초단가 → 실행단가
+ * · 절사 1~100,000원 · 차액 보정 기본/노/재/경 · 최초수량 → 실행수량 · 실행예산서 Ⅰ/Ⅱ 0673·0674) · 35 §3
+ * 미확인(표본 0건, 27 §9): 성분 나눔 방식 — 여기서는 최초단가 비례 · 「기본보정」 = 가장 큰 성분에 차액
+ * 시험: resources/tester/test_stmate_stage_exec.py (자기 일관성 — 성분 합 = 절사 목표 · 절사 단위 배수)
+ */
+const StmateExec = (() => {
+ const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js");
+ const Change =
+ (typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js");
+ const { roundDown } = Calc;
+ const n0 = (v) => Number(v) || 0;
+ // 절사 기준 6택 — 1원 · 10원 · 100원 · 1,000원 · 10,000원 · 100,000원 미만 절사
+ const JEOL = [1, 10, 100, 1000, 10000, 100000];
+ // 차액 보정 순서 — 기본보정 · 노무비 · 재료비 · 경비
+ const ORDERS = [
+ ["base", "기본보정"],
+ ["l", "노무비"],
+ ["m", "재료비"],
+ ["s", "경비"],
+ ];
+ const cut = (v, j) => Math.trunc(n0(v) / (n0(j) || 1) + 1e-9) * (n0(j) || 1);
+
+ // 중기 실행단가 — 1단위당 사용료 ÷ 시간 → 절사 → 최초단가 비례 나눔 → 성분 절사 → 차액 한 성분에
+ function machineExecUnit(p) {
+ const j = n0(p.jeol) || 1;
+ const hours = n0(p.hours) || 1;
+ const target = n0(p.fee) / hours;
+ const T = cut(target, j);
+ const init = {
+ l: n0(p.init && p.init.l),
+ m: n0(p.init && p.init.m),
+ s: n0(p.init && p.init.s),
+ };
+ const sum = init.l + init.m + init.s;
+ const comps = {};
+ for (const k of ["l", "m", "s"]) comps[k] = sum ? cut((T * init[k]) / sum, j) : 0;
+ if (!sum) comps.s = T;
+ const diff = T - (comps.l + comps.m + comps.s);
+ let to = p.order || "base";
+ if (to === "base") to = ["l", "m", "s"].reduce((a, k) => (init[k] > init[a] ? k : a), "l");
+ comps[to] += diff;
+ const ratio = n0(p.initQty) ? n0(p.execQty || p.initQty) / n0(p.initQty) : 1;
+ return {
+ target,
+ T,
+ comps,
+ diff,
+ to,
+ initT: sum,
+ execQty: n0(p.execQty || p.initQty),
+ qtyRatio: ratio,
+ };
+ }
+
+ // 실행예산 행 — 실행단가 = 직접 입력 또는 trunc(설계단가 × 실행율, 절사 단위) · 실행수량 = 입력 또는 설계수량
+ function execRow(r, o) {
+ const ov = (o.overrides || {})[r.id] || {};
+ const j = n0(o.jeol) || 1;
+ const rate = { m: n0(o.rateM ?? 100), l: n0(o.rateL ?? 100), s: n0(o.rateS ?? 100) };
+ const u = {};
+ ["m", "l", "s"].forEach((k, i) => {
+ u[k] = ov.mls ? n0(ov.mls[i]) : cut((n0(r[k]) * rate[k]) / 100, j);
+ });
+ const q = ov.qty != null ? n0(ov.qty) : n0(r.qty);
+ const a = {
+ mAmt: roundDown(u.m * q, 0),
+ lAmt: roundDown(u.l * q, 0),
+ sAmt: roundDown(u.s * q, 0),
+ };
+ a.tAmt = a.mAmt + a.lAmt + a.sAmt;
+ return { qty: q, mUnit: u.m, lUnit: u.l, sUnit: u.s, tUnit: u.m + u.l + u.s, ...a };
+ }
+
+ // 평면 행 실행예산 — rows [{id, qty, m, l, s, gov}]
+ function budget(rows, opts = {}) {
+ const out = rows.map((r) => {
+ const x = execRow(r, opts);
+ const d = {
+ mAmt: roundDown(n0(r.m) * n0(r.qty), 0),
+ lAmt: roundDown(n0(r.l) * n0(r.qty), 0),
+ sAmt: roundDown(n0(r.s) * n0(r.qty), 0),
+ };
+ d.tAmt = d.mAmt + d.lAmt + d.sAmt;
+ return Object.assign({}, r, { design: d, exec: x });
+ });
+ const tot = (key) =>
+ out
+ .filter((r) => !r.gov)
+ .reduce(
+ (a, r) => ({
+ mAmt: a.mAmt + r[key].mAmt,
+ lAmt: a.lAmt + r[key].lAmt,
+ sAmt: a.sAmt + r[key].sAmt,
+ tAmt: a.tAmt + r[key].tAmt,
+ }),
+ { mAmt: 0, lAmt: 0, sAmt: 0, tAmt: 0 },
+ );
+ const design = tot("design"),
+ exec = tot("exec");
+ return {
+ rows: out,
+ design,
+ exec,
+ diff: exec.tAmt - design.tAmt,
+ rate: design.tAmt ? Math.round((exec.tAmt / design.tAmt) * 1e5) / 1e3 : 0,
+ };
+ }
+
+ // 내역 구조 위 실행예산(실행예산서 Ⅰ: 공종 | 설계(계획) | 실행예산 | 구분) — 머리 합계 포함
+ function budgetFromTree(tree, stage, opts = {}) {
+ const nodes = tree.nodes.map((nd) => {
+ const o = Object.assign({}, nd, { st: {} });
+ if (nd.kind !== "leaf") return o;
+ const s = nd.st[stage] || {};
+ o.st.d = {
+ qty: n0(s.qty),
+ mUnit: n0(s.mUnit),
+ lUnit: n0(s.lUnit),
+ sUnit: n0(s.sUnit),
+ tUnit: n0(s.tUnit),
+ mAmt: n0(s.mAmt),
+ lAmt: n0(s.lAmt),
+ sAmt: n0(s.sAmt),
+ tAmt: n0(s.tAmt),
+ };
+ o.st.x = execRow(
+ { id: nd.id, qty: s.qty, m: s.mUnit, l: s.lUnit, s: s.sUnit },
+ nd.gov ? Object.assign({}, opts, { rateM: 100, rateL: 100, rateS: 100 }) : opts,
+ );
+ return o;
+ });
+ Change.sumHeads(nodes, ["d", "x"]);
+ const tops = nodes.filter((nd) => nd.kind === "head" && !nd.zone);
+ const top = tops.reduce((a, h) => (!a || h.level > a.level ? h : a), null);
+ return {
+ nodes,
+ design: top ? top.st.d.tAmt : 0,
+ exec: top ? top.st.x.tAmt : 0,
+ };
+ }
+
+ return { JEOL, ORDERS, machineExecUnit, execRow, budget, budgetFromTree };
+})();
+
+if (typeof window !== "undefined") window.STMATE_EXEC = StmateExec;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateExec;
diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html
index 9c38caef5..a60321d9a 100644
--- a/tools/stmate-test/index.html
+++ b/tools/stmate-test/index.html
@@ -5,87 +5,15 @@
STMATE w 8.0 — 경영정보(주) 원가·내역 통합 시스템 (독립 재현판)
+
STMATE w 8.0
-
-
-
-
-
+
+
-
-
+
+
@@ -178,6 +56,12 @@
+
+
+
+
+
+
diff --git a/tools/stmate-test/kanjub_calc.js b/tools/stmate-test/kanjub_calc.js
index 778e01e22..653d99e81 100644
--- a/tools/stmate-test/kanjub_calc.js
+++ b/tools/stmate-test/kanjub_calc.js
@@ -1,264 +1,513 @@
/* -*- coding: utf-8 -*- */
/**
- * 원가계산서(제잡비) 계산 (kanjub_calc.js) — 직접비 → 간접비 → 일반관리비 → 이윤 → 부가세 → 끝전 → 관급 → 총공사비
- * 근거: 지식DB STmate/old/20_분석 06 · 16 · 25 · REA 17 §5 · REA 20(부가세·관급·추가금액 순서)
- * 정답 대조: resources/tester/test_stmate_kanjub_stc.py
+ * 원가계산서(제잡비) 계산 (kanjub_calc.js) — STMATE Calc_Kanjub 네이티브 순서
+ * 직접비 → 간접노무비 → 보험 → 안전 → 경비 → (추가1) → 일반관리비 → 이윤 → 이윤 뒤 가산(추가2)
+ * → 공사보험 → 작업설 감액 → 부가세 → 세후 가산(추가3) → 도급 절사 → 관급 → 사급 → (추가4) → 총공사비
+ * 근거: 지식DB STmate/old/20_분석 06 · 11 · 12 · 20(KAN_ORD) · 25 · 35
+ * REA 18(보험·안전·수공 경비·변경 차액) · REA 20(일반관리비·이윤 절사·공사보험·부가세·관급·추가금액 순서)
+ * 설정: cfg.keys = PRJLST.SMT [n_KANJUB] 원본 키(차수 접두 뗌) · cfg.rates/fixed/scheme 등 옛 칸이 있으면 그 값이 앞섬
+ * 정답 대조: resources/tester/test_stmate_kanjub_stc.py · test_stmate_kanjub_opts.py
*/
-const StmateKanjubFactory = ({ num, fmt, roundDown, roundHalfUp, applyRoundMode }) => {
+const StmateKanjubFactory = (h) => {
+ const { num, roundDown, roundHalfUp, roundBanker } = h;
+ // 설정 읽개·항목 표·결과 묶음 — kanjub_parts.js
+ const P = (globalThis.STMATE_KANJUB_PARTS || require("./kanjub_parts.js"))(h);
+ const { keyOf, S8, reader, addList, finish } = P;
function calcKanjub(data, boqRes, origKanjubRes = null, stageCfg = null) {
- const cfg = stageCfg || data.kanjubConfig || {},
- fixed = cfg.fixed || {},
- scheme = cfg.scheme || "T",
- rates = cfg.rates || {},
- revModes = cfg.revModes || {};
- const indTruncDigits = Number(cfg.indirectTruncDigits) || 0;
- const dM = boqRes.directM,
- dL = boqRes.directL,
- dS = boqRes.directS,
- dT = boqRes.directTotal,
- govMat = cfg.govMat ?? boqRes.govCombinedRaw,
- govFee = cfg.govFee ?? boqRes.govFeeTotal,
- govPureMat = govMat - govFee;
- const adds = cfg.addAmounts || [];
- const addSum = (stage) =>
- adds.filter((a) => a.stage === stage).reduce((acc, a) => acc + num(a.amount), 0);
- const truncInd = (v) => roundDown(v, indTruncDigits);
-
- function resolveRow(code, label, baseDesc, baseAmt, ratePct, rawCalcAmt) {
- // D_ 표시 항목 = 저장 금액 고정(변경 차수의 당초액 유지)
- const truncAmt = fixed[code] != null ? fixed[code] : truncInd(rawCalcAmt);
- const base = Object.assign(
- { key: code, code, label, formula: baseDesc },
- { baseDesc, baseAmt, base: baseAmt, rate: ratePct, ratePct },
- );
- if (!origKanjubRes) return Object.assign(base, { amount: truncAmt, modeUsed: "원설계" });
- const origRow = origKanjubRes.itemMap[code],
- origAmt = origRow ? origRow.amount : 0,
- origBase = origRow ? origRow.baseAmt : 0;
- const mode = revModes[code] || "recalculated";
- const finalAmt =
- mode === "carry_original"
- ? origAmt
- : mode === "proportional"
- ? origAmt + truncInd(origAmt * (origBase > 0 ? (baseAmt - origBase) / origBase : 0))
- : mode === "delta_rate"
- ? origAmt + truncInd(((baseAmt - origBase) * ratePct) / 100)
- : truncAmt;
- return Object.assign(base, {
- amount: finalAmt,
- origAmt,
- deltaAmt: finalAmt - origAmt,
- modeUsed: mode,
- });
- }
-
- const items = [],
- itemMap = {};
- const pushItem = (row) => (items.push(row), (itemMap[row.code] = row), row.amount);
- const pushRate = (code, label, desc, base, defR) => {
- const r = num(rates[code], defR);
- return pushItem(resolveRow(code, label, desc, base, r, (base * r) / 100));
+ const cfg = stageCfg || (data && data.kanjubConfig) || {};
+ const o = reader(cfg, data),
+ slot = num(cfg.slot, 1),
+ adds = addList(cfg, slot);
+ const addSum = (g, vatOnly = false) =>
+ adds
+ .filter((a) => a.stage === g && !(g === 1 && a.addt) && (!vatOnly || a.vat))
+ .reduce((s, a) => s + num(a.amount), 0);
+ const B = {
+ M: num(boqRes.directM),
+ L: num(boqRes.directL),
+ S: num(boqRes.directS),
+ govAll: num(boqRes.govCombinedRaw),
+ govFee: num(boqRes.govFeeTotal),
};
-
- const kanno = pushRate("KANNO", "간접노무비", "직접노무비", dL, 14.5);
- const totalLabor = dL + kanno;
- pushRate("SANBO", "산재보험료", "노무비(직노+간노)", totalLabor, 3.56);
- pushRate("KOBO", "고용보험료", "노무비(직노+간노)", totalLabor, 1.01);
- const kunbo = pushRate("KUNBO", "건강보험료", "직접노무비", dL, 3.545);
- pushRate("SLVBO", "노인장기요양보험료", "건강보험료(확정액)", kunbo, 12.95);
- pushRate("YUNBO", "연금보험료", "직접노무비", dL, 4.5);
- pushRate("TBUGUM", "퇴직공제부금비", "직접노무비", dL, 0);
-
- const rAnjun = num(rates.ANJUN, 1.86),
- anjunBaseAdd = num(cfg.anjunBaseAdd, 0);
- const anjunGovDiv = cfg.anjunGovVatExcluded !== false ? govPureMat / 1.1 : govPureMat;
- const anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL,
- anjunBaseWithGov = anjunBaseNoGov + anjunGovDiv;
- const anjunNormal = truncInd((anjunBaseWithGov * rAnjun) / 100) + anjunBaseAdd;
- const anjunCap12 = truncInd(((anjunBaseNoGov * rAnjun) / 100 + anjunBaseAdd) * 1.2);
- // 변경 차수 자동(I_ANJUNAUTO=2) = 당초액 + 당초액 × 밑수 증감율(지식DB 11 §10 방법 A)
- const o = origKanjubRes;
- const anjunChange =
- cfg.anjunAuto === 2 && o && o.anjunBaseRaw > 0
- ? o.itemMap.ANJUN.amount +
- truncInd((o.itemMap.ANJUN.amount * (anjunBaseWithGov - o.anjunBaseRaw)) / o.anjunBaseRaw)
- : null;
- const anjunDesc =
- scheme === "TA" || scheme === "SA"
- ? "직접공사비+관급원자재/1.1 (+기초액)"
- : "직재+직노+관급원자재/1.1 (+기초액)";
- pushItem(
- resolveRow(
- "ANJUN",
- "산업안전보건관리비",
- anjunDesc,
- roundHalfUp(anjunBaseWithGov, 0),
- rAnjun,
- fixed.ANJUN != null
- ? fixed.ANJUN
- : anjunChange != null
- ? anjunChange
- : cfg.anjunCap12 !== false
- ? Math.min(anjunNormal, anjunCap12)
- : anjunNormal,
- ),
- );
-
- if (scheme === "S" || scheme === "SA") {
- const suBase = dM + totalLabor;
- [
- ["S_SUDO", "수도광열비", 0.45],
- ["S_BOKRI", "복리후생비", 1.15],
- ["S_SOMO", "소모품·사무용품비", 0.65],
- ["S_TONGSIN", "여비·교통·통신비", 0.85],
- ["S_SEGUM", "세금·공과금", 0.55],
- ["S_PEGI", "폐기물처리비", 0.35],
- ["S_DOSEO", "도서인쇄비", 0.25],
- ["S_JIGUP", "지급수수료", 0.45],
- ].forEach(([sc, sl, defR]) =>
- pushRate(sc, `[수공] ${sl}`, "재료비+노무비(직노+간노)", suBase, defR),
- );
- } else {
- pushRate(
- "KITA",
- "기타경비",
- scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)",
- scheme === "TA" ? dT : dM + totalLabor,
- 6.0,
- );
+ const run1 = (m, up) => runOnce(o, cfg, B, origKanjubRes, adds, addSum, m, up);
+ let r = run1(o.k("C_IYUN_M"), o.k("B_BUGA_UP") ? 1 : 0);
+ // 총액 절사(I_TOT_JEOL) — 총공사비를 자리 절사한 목표에 맞도록 이윤보정액(C_IYUN_M)을 1원씩 조정 · 못 맞추면 부가세 +1(B_BUGA_UP)
+ const tj = o.k("I_TOT_JEOL");
+ if (tj > 0 && !o.k("I_JEOLSA")) {
+ let up = o.k("B_BUGA_UP") ? 1 : 0;
+ const first = run1(0, up),
+ target = roundDown(first.grand, -tj),
+ diff = first.grand - target;
+ const m0 = o.k("I_BUGA") === 0 ? roundHalfUp(diff / 1.1, 0) : diff;
+ let m = m0,
+ seen = new Set();
+ r = run1(m, up);
+ for (let i = 0; i < 60 && r.grand !== target; i++) {
+ seen.add(m);
+ m += r.grand > target ? 1 : -1;
+ if (seen.has(m)) {
+ if (up) break;
+ ((up = 1), (m = m0), (seen = new Set()));
+ }
+ r = run1(m, up);
+ }
+ Object.assign(r, { target, diffTotal: diff, bugaUp: up });
}
-
- const hwanBase = scheme === "TA" ? dT : dM + dL + dS,
- hwanDesc = scheme === "TA" ? "직접공사비(실적일반)" : "재료비+직접노무비+산출경비";
- pushRate("HWAN", "환경보전비", hwanDesc, hwanBase, 0.8);
- pushRate("EQIP", "건설기계대여대금 지급보증", hwanDesc, hwanBase, 0.4);
-
- const indirectExpenseSum = items
- .filter((x) => x.code !== "KANNO")
- .reduce((acc, x) => acc + x.amount, 0);
- const add1 = addSum(1),
- totalExpense = dS + indirectExpenseSum + add1,
- pureConstructionCost = dM + totalLabor + totalExpense;
-
- const rIlban = num(rates.ILBAN, 6.0),
- ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost;
- const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)",
- ilbanAmt = fixed.ILBAN != null ? fixed.ILBAN : truncInd((ilbanBase * rIlban) / 100);
-
- const rIyun = num(rates.IYUN, 15.0);
- const iyunBase =
- scheme === "TA" || scheme === "SA"
- ? pureConstructionCost + ilbanAmt
- : totalLabor + totalExpense + ilbanAmt;
- const iyunDesc =
- scheme === "TA" || scheme === "SA"
- ? "직접비+간접비+일반관리비(실적형식)"
- : "노무비+경비+일반관리비(재료비 제외)";
- const iyunRaw = truncInd((iyunBase * rIyun) / 100);
-
- const vatMode = cfg.vatMode || "supply_10",
- totJeolDigits = num(cfg.totJeolDigits, -3),
- add2 = addSum(2),
- add3 = addSum(3),
- add4 = addSum(4);
- // 부가세(I_BUGA · REA 20) — 0 공급가액 · 1 재료비 · 2 없음 · 3 산림조합(직접비−노무비+부가세 붙는 추가금액)
- // · 4 산림조합 면세품(3 에서 면세품 C_BUGA_M 뺌) · 올림 표시(B_BUGA_UP)면 +1원
- const vatUp = cfg.vatRoundUp ? 1 : 0,
- addVat = adds
- .filter((a) => a.stage === 2 && a.vat)
- .reduce((acc, a) => acc + num(a.amount), 0),
- forestBase = dT - dL + addVat - (vatMode === "forest_exempt" ? num(cfg.vatExempt) : 0);
- const calcVat = (sVal) =>
- vatMode === "zero"
- ? 0
- : vatMode === "mat_10"
- ? roundDown(dM * 0.1, 0) + vatUp
- : vatMode === "forest" || vatMode === "forest_exempt"
- ? roundDown(forestBase * 0.1, 0) + vatUp
- : roundDown(sVal * 0.1, 0) + vatUp;
-
- const govFinal =
- govMat > 0
- ? applyRoundMode(govMat, cfg.govRoundMode || "up", num(cfg.govRoundDigits, -3))
- : 0;
- const iyunManual = num(cfg.iyunManualAdj, 0),
- supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw - iyunManual + add2,
- vatBeforeAdj = calcVat(supplyBeforeAdj),
- contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj + add3;
- let contractTarget = contractBeforeAdj,
- diffTotal = 0,
- pretaxProfitAdj = iyunManual,
- iyunFinal = iyunRaw - iyunManual,
- supplyFinal = supplyBeforeAdj,
- vatFinal = vatBeforeAdj;
-
- if (totJeolDigits < 0 && cfg.enableProfitEndAdj !== false) {
- // 총액 절사 = 총공사비(도급 + 관급 + 단계6 추가) 기준 · 차액은 도급에서 뺌
- contractTarget =
- roundDown(contractBeforeAdj + govFinal + add4, totJeolDigits) - govFinal - add4;
- diffTotal = contractBeforeAdj - contractTarget;
- pretaxProfitAdj = vatMode === "supply_10" ? roundHalfUp(diffTotal / 1.1, 0) : diffTotal;
- iyunFinal = iyunRaw - pretaxProfitAdj;
- supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal + add2;
- vatFinal = calcVat(supplyFinal);
- if (supplyFinal + vatFinal + add3 !== contractTarget)
- vatFinal = contractTarget - supplyFinal - add3;
- }
- const contractFinal = supplyFinal + vatFinal + add3;
- const grandTotal = contractFinal + govFinal + add4;
- const mkLine = (key, label, formula, baseAmt, rate, amount) =>
- Object.fromEntries([
- ["key", key],
- ["label", label],
- ["formula", formula],
- ["baseAmt", baseAmt],
- ["base", baseAmt],
- ["rate", rate],
- ["amount", amount],
- ]);
- const totStr = "재료비 + 노무비 + 경비",
- iyunStr = `${iyunDesc} (보정 -${fmt(pretaxProfitAdj)})`,
- supStr = "순공사원가 + 일반관리비 + 이윤",
- finStr = "공급가액 + 부가가치세 (천원절사)",
- govStr = `관급원자재(${fmt(govPureMat)}) + 수수료(${fmt(govFee)})`;
- const pCost = pureConstructionCost;
- const lines = [
- mkLine("DIRECT_M", "직접재료비", "내역서 재료비 합계", dM, null, dM),
- mkLine("DIRECT_L", "직접노무비", "내역서 직접노무비 합계", dL, null, dL),
- mkLine("DIRECT_S", "산출경비", "내역서 산출경비 합계", dS, null, dS),
- mkLine("DIRECT_COST", "직접공사비 계", "직재 + 직노 + 산출경비", dT, null, dT),
- ...items.map((it) => mkLine(it.key, it.label, it.formula, it.baseAmt, it.rate, it.amount)),
- mkLine("TOTAL_COST", "순공사원가 계", totStr, pCost, null, pCost),
- mkLine("ILBAN", "일반관리비", ilbanDesc, ilbanBase, rIlban, ilbanAmt),
- mkLine("IYUN", "이윤 (끝전보정 후)", iyunStr, iyunBase, rIyun, iyunFinal),
- mkLine("SUPPLY_VAL", "총원가 (공급가액)", supStr, supplyFinal, null, supplyFinal),
- mkLine("VAT", "부가가치세", "공급가액 × 10%", supplyFinal, 10, vatFinal),
- mkLine("FINAL_SUPPLY", "도급공사비", finStr, contractBeforeAdj, null, contractFinal),
- mkLine("GOV_MAT", "관급자재대", govStr, govMat, null, govFinal),
- mkLine("GRAND_TOTAL", "총공사비 (COSTW1)", "도급 + 관급", grandTotal, null, grandTotal),
- ];
-
- const out = { scheme, dM, dL, dS, dT, kanno, totalLabor };
- Object.assign(out, { indirectExpenseSum, totalExpense, pureConstructionCost: pCost });
- Object.assign(out, { ilbanBase, ilbanDesc, ilbanAmt, iyunBase, iyunDesc, iyunRaw });
- Object.assign(out, { pretaxProfitAdj, iyunFinal, supplyBeforeAdj, vatBeforeAdj });
- Object.assign(out, { contractBeforeAdj, diffTotal, supplyFinal, vatFinal, contractFinal });
- Object.assign(out, { govRawTotal: govPureMat, govFeeTotal: govFee, adds });
- out.anjunBaseRaw = anjunBaseWithGov;
- Object.assign(out, { govFinal, grandTotal, items, itemMap, lines });
- Object.assign(out, { DIRECT_COST: dT, KANNO: kanno, IND_EXP: kanno + indirectExpenseSum });
- Object.assign(out, { TOTAL_COST: pCost, ILBAN: ilbanAmt, IYUN_INIT: iyunRaw });
- Object.assign(out, { PROFIT_ADJ: -pretaxProfitAdj, IYUN: iyunFinal, SUPPLY_VAL: supplyFinal });
- return Object.assign(out, {
- VAT: vatFinal,
- FINAL_SUPPLY: contractFinal,
- GOV_MAT: govFinal,
- GRAND_TOTAL: grandTotal,
- });
+ return finish(o, cfg, r, origKanjubRes);
}
+ // 한 번 계산(이윤보정액 m · 부가세 +1 up 고정)
+ function runOnce(o, cfg, B, prev, adds, addSum, iyunM, bugaUp) {
+ const J = o.k("I_KAN_JEOLSA"),
+ tr = (v) => roundDown(v, -J),
+ ctx = o.ctx,
+ actl = o.actl;
+ const std = ctx === 0 || ctx === 1 || ctx === 2 || ctx === 5,
+ su = ctx === 3 || ctx === 4;
+ const rows = {},
+ keysOut = {};
+ // 항목 한 줄 — 후보(cand) · D_ 고정 · 옛 변경 계산방식(revModes)
+ const put = (code, base, rate, cand, desc, tpl, group) => {
+ const fx = o.fix(code);
+ let amount = fx != null ? fx : cand;
+ const mode = o.revModes[code];
+ const pr = prev && prev.itemMap && prev.itemMap[code];
+ if (fx == null && pr && mode && mode !== "recalculated") {
+ const ob = num(pr.baseAmt);
+ amount =
+ mode === "carry_original"
+ ? pr.amount
+ : mode === "proportional"
+ ? pr.amount + tr(pr.amount * (ob > 0 ? (base - ob) / ob : 0))
+ : mode === "delta_rate"
+ ? pr.amount + tr(((base - ob) * rate) / 100)
+ : amount;
+ }
+ const row = { key: code, code, label: LABEL[code] || code, group: group || "exp" };
+ Object.assign(row, {
+ formula: desc,
+ baseDesc: desc,
+ baseAmt: base,
+ base,
+ rate,
+ ratePct: rate,
+ });
+ Object.assign(row, { cand, amount, fixed: fx != null, tpl: tpl || "" });
+ if (pr) Object.assign(row, { origAmt: pr.amount, deltaAmt: amount - pr.amount });
+ row.modeUsed = prev ? mode || "recalculated" : "원설계";
+ rows[code] = row;
+ keysOut["C_" + keyOf(code)] = amount;
+ keysOut["S_" + keyOf(code)] = cand;
+ return amount;
+ };
+ // 율 항목 — 후보 = ROUNDDOWN(밑수×율/100, −간접비 절사자리) · 식 템플릿과 그 변수(25번 §2.1)를 함께 남김
+ const pctItem = (code, base, desc, tplBase, vars) => {
+ const r = o.R(code),
+ amt = put(
+ code,
+ base,
+ r,
+ tr((base * r) / 100),
+ desc,
+ `ROUNDDOWN(${tplBase}*[RAT_1]/100,-[J])`,
+ );
+ rows[code].tplVars = Object.assign({ RAT_1: r, J }, vars);
+ return amt;
+ };
+
+ // 0) 직접비 틀 — 노무비 가산(DOSEOH) · 사급 위치(I_POS_SA=3 재료비 포함) · 실적 대체(CAL_ACTL)
+ let M = B.M,
+ L = B.L,
+ S = B.S;
+ if (o.R("DOSEOH")) L += pctItem("DOSEOH", L, "직접노무비", "[LAMT]", { LAMT: L });
+ const posSa = o.k("I_POS_SA"),
+ sagup = o.k("C_SAGUP");
+ let Kres = sagup;
+ if (posSa === 3) ((M += sagup), (Kres = 0));
+ let T = M + L + S,
+ c98 = null;
+ if (actl) {
+ const DM = o.R("R_DM"),
+ DL = o.R("R_DL");
+ if (o.k("I_MTYP") === 1) S = T - L - M;
+ else if (o.k("I_LTYP")) {
+ M = roundDown(T * DM, 0);
+ if (M + L > T) M = T - L;
+ S = T - M - L;
+ } else {
+ M = roundDown(T * DM, 0);
+ L = roundDown(T * DL, 0);
+ S = (1 - DM - DL) * T;
+ L = T * o.R("R_L");
+ c98 = T * o.R("R_DML");
+ }
+ }
+ const c90 = L + M,
+ hol = o.k("C_EIS_HOLLAB"),
+ U = std ? o.k("C_NBUGAM") : 0;
+ let expAcc = actl ? 0 : S,
+ Lp = L;
+
+ // 1) 간접노무비 — 일반은 노무비, 실적은 간접공사비(I_LTYP≠0 이면 노무비 밑수에도)
+ const kanno = pctItem("KANNO", L, "직접노무비", "[LAMT]", { LAMT: L });
+ if (!actl) Lp += kanno;
+ else ((expAcc += kanno), o.k("I_LTYP") && (Lp += kanno));
+ if (su) expAcc += put("S_PUMJIL", 0, 0, o.k("C_S_PUMJIL"), "별도내역");
+
+ // 2) 산재·고용 = (노무비 + 고용개선 주휴노무비) × 율
+ expAcc += pctItem("SANBO", Lp + hol, "직접노무비+간접노무비", "([L_AMT][+LAB])", {
+ L_AMT: Lp,
+ LAB: hol,
+ });
+ expAcc += pctItem("KOBO", Lp + hol, "직접노무비+간접노무비", "([L_AMT][+LAB])", {
+ L_AMT: Lp,
+ LAB: hol,
+ });
+
+ // 3) 건강·연금·장기요양·퇴직공제 — 변경 차수 당초액 + 증감(I_BF_CH_BO) · 직접입력 유지(I_COST_BO) · REA 18
+ const lab = L + hol,
+ bfch = o.k("I_BF_CH_BO"),
+ costBo = o.k("I_COST_BO");
+ const chg = prev && (o.pclass < 0 || o.pclass === 7) && (bfch === 1 || bfch === 2);
+ const prevBase = prev ? num(prev.dL) + num(prev.hollab) : 0;
+ const prevAmt = (c) => (prev && prev.itemMap[c] ? num(prev.itemMap[c].amount) : 0);
+ const hp = (code, sel) => {
+ const base = o.k(sel) ? o.k("C_SANGSI") : lab,
+ r = o.R(code);
+ if (costBo === 1 || costBo === 2) return put(code, base, r, o.k("C_" + code), "직접입력");
+ if (chg) {
+ const old = prevAmt(code),
+ d =
+ bfch === 2 && prevBase >= 1
+ ? ((lab - prevBase) * old) / prevBase
+ : ((lab - prevBase) * r) / 100;
+ return put(code, base, r, old + tr(d), "당초액 + 직노증감", "[C_AMT_B]+ROUNDDOWN(...)");
+ }
+ return pctItem(code, base, o.k(sel) ? "상시근로자노무비" : "직접노무비", "[LAMT]", {
+ LAMT: base,
+ });
+ };
+ const kunbo = hp("KUNBO", "I_KUNBO");
+ expAcc += kunbo;
+ const oldKun = prevAmt("KUNBO");
+ expAcc +=
+ chg && bfch === 2 && oldKun >= 1
+ ? put(
+ "SLVBO",
+ kunbo,
+ o.R("SLVBO"),
+ prevAmt("SLVBO") + tr(((kunbo - oldKun) * prevAmt("SLVBO")) / oldKun),
+ "당초액 + 증감(비율)",
+ )
+ : pctItem("SLVBO", kunbo, "건강보험료", "[C_KUNBO]", { C_KUNBO: kunbo });
+ expAcc += hp("YUNBO", "I_YUNBO");
+ expAcc +=
+ costBo === 2
+ ? put("TBUGUM", lab, o.R("TBUGUM"), o.k("C_TBUGUM"), "직접입력")
+ : prev && bfch === 2 && prevBase >= 1
+ ? put(
+ "TBUGUM",
+ lab,
+ o.R("TBUGUM"),
+ prevAmt("TBUGUM") + tr(((lab - prevBase) * prevAmt("TBUGUM")) / prevBase),
+ "당초액 + 증감(비율)",
+ )
+ : pctItem("TBUGUM", lab, "직접노무비", "[LAMT]", { LAMT: lab });
+
+ // 4) 산업안전보건관리비 — 관급 대상액(C_KN_SA 8갈래) · 후보 3개 · 1.2배 비교 · 변경 비율(I_ANJUNAUTO=2) · REA 18
+ const gD = o.k("B_KWANGUP_D") ? B.govAll : o.k("C_KWANGUP_D"),
+ gK = o.k("C_KWANGUP_K");
+ let jodal = o.k("B_T_JODAL") ? B.govFee : o.k("C_T_JODAL");
+ if (jodal > gD) jodal = gD;
+ const gAll = gD + gK,
+ gA = gAll - gK - jodal,
+ knT = o.k("I_KN_SA_TYPE");
+ const kDiv = posSa === 1 ? Kres : Kres / 1.1;
+ const knSa =
+ [gA / 1.1, gA, kDiv, Kres, gA / 1.1 + Kres, gA + kDiv, gA / 1.1 + kDiv, gA + Kres][knT] ?? 0;
+ const busan = actl ? 0 : o.k("C_BUSAN"),
+ a0 = roundDown(T * o.R("R_MxA"), 0);
+ const b0 =
+ o.k("I_MTYP") === 1
+ ? actl
+ ? T
+ : c90 + busan
+ : actl && o.k("I_LTYP") === 1 && ((c90 > a0 && o.k("I_MXA") === 0) || o.k("I_MXA") === 1)
+ ? a0
+ : c90 + busan;
+ const Dn = std ? o.k("C_T_ANJUN_N") : 0,
+ selK = [2, 5, 6].includes(knT) ? kDiv : Kres;
+ let c190 = ((b0 + U - Dn) * o.R("ANJUN1")) / 100 + o.G("C_ANJUN1_G"),
+ c198 = ((b0 + U + knSa - Dn) * o.R("ANJUN2")) / 100 + o.G("C_ANJUN2_G"),
+ c1a0 = ((b0 + U + selK - Dn) * o.R("ANJUN")) / 100 + o.k("C_ANJUN_G");
+ if (ctx !== 4 && o.k("I_AN_DN_TYPE") !== 1)
+ [c190, c198, c1a0] = [c190, c198, c1a0].map((v) => roundDown(v, 0));
+ const ak = o.k("I_ANJUN_K"),
+ auto = o.k("I_ANJUNAUTO");
+ const l18 = ak === 1 ? c198 : ak === 2 ? c1a0 : c190;
+ let l10 = c190,
+ cap = o.k("I_ANJUN_B") === 1,
+ capAuto = false;
+ if (auto === 0 && (ak === 1 || ak === 2)) l10 = ak === 1 ? c198 : c1a0;
+ else if (cfg.anjunCap12 !== false && l10 !== 0 && 1.2 * l10 < l18) cap = capAuto = true;
+ const anjBasis = b0 + U + knSa - Dn;
+ let anjCand = cap ? tr(1.2 * l10) : tr(l18),
+ anjDesc = cap
+ ? "(직노+직재+간재)×율 + 기초액의 1.2배"
+ : "(직노+직재+간재+관급재/1.1)×율 + 기초액";
+ if (prev && auto === 2 && (o.pclass < 0 || o.pclass === 7) && num(prev.anjunBaseRaw) >= 1) {
+ const pa = prevAmt("ANJUN"),
+ pb = num(prev.anjunBaseRaw);
+ anjCand = pa + tr(((anjBasis - pb) * pa) / pb);
+ anjDesc = "당초금액 + 안전관리비 증감액";
+ }
+ expAcc += put("ANJUN", roundHalfUp(anjBasis, 0), o.R("ANJUN"), anjCand, anjDesc);
+ if (su) expAcc += put("S_ANJUN_B", 0, 0, o.k("C_S_ANJUN_B"), "별도내역");
+ const anjB = o.k("C_ANJUN_B"),
+ pumB = o.k("C_PUMJIL_B");
+ expAcc += put("ANJUN_B", 0, 0, anjB, "별도내역") + put("PUMJIL_B", 0, 0, pumB, "별도내역");
+
+ // 5) 기타경비(일반) · 수공 세부경비 8 — 공통 밑수(재료비+노무비 · 실적은 직접공사비 또는 조정계수)
+ const b98 = actl && !o.k("I_LTYP") && c98 != null ? c98 : c90 + kanno;
+ const a8 = roundDown(o.R("R_MxK") * T, 0),
+ gL = actl && o.k("I_LTYP") === 1;
+ const bKita =
+ o.k("I_MTYP") === 1
+ ? actl
+ ? T
+ : b98
+ : gL && ((b98 > a8 && o.k("I_MXK") === 0) || o.k("I_MXK") === 1)
+ ? a8
+ : b98;
+ const kitaDesc = actl && o.k("I_MTYP") === 1 ? "직접공사비" : "직접노무비+간접노무비+재료비";
+ if (std)
+ expAcc += pctItem("KITA", bKita + U + hol, kitaDesc, "([M_AMT]+[L_AMT][+LAB])", {
+ M_AMT: bKita + U,
+ L_AMT: 0,
+ LAB: hol,
+ });
+ if (su)
+ for (const c of S8)
+ expAcc += pctItem(c, bKita, kitaDesc, "([L_AMT]+[M_AMT])", { L_AMT: bKita, M_AMT: 0 });
+
+ // 6) 공사이행보증 · 하도급 · 환경 · 건설기계 — 밑수 직접공사비(+안전품질 별도·비과세)
+ const F = o.k("I_ANPUM") === 1 ? anjB + pumB : 0,
+ tBase = T + F + U;
+ const ks = tr(
+ (o.k("C_KONGSU_A") + ((T + F + U - o.k("C_KONGSU_MA")) * o.R("KONGSU")) / 100) *
+ o.k("Y_KONGSU"),
+ );
+ expAcc += put("KONGSU", T, o.R("KONGSU"), ks, "[기초금액+(직접공사비-기준금액)×율]×공기");
+ const t3 = "재료비 + 직접노무비 + 산출경비";
+ expAcc += pctItem("HADOSU", tBase, t3, "[T_AMT]", { T_AMT: tBase });
+ expAcc += pctItem("HWAN", tBase, t3, "[T_AMT]", { T_AMT: tBase });
+ expAcc += pctItem("EQIP", tBase, t3, "[T_AMT]", { T_AMT: tBase });
+ expAcc += pctItem("BUDM_SM", Lp + hol, "노무비", "[LAMT]", { LAMT: Lp + hol });
+ expAcc += pctItem("BUDM_IC", Lp + hol, "노무비", "[LAMT]", { LAMT: Lp + hol });
+
+ // 고용개선지원비(서울 5형식) — 주휴수당·사회보험·장려금 각 절사 후 합 · 이윤 뒤 가산 · REA 20
+ let eisAdd = 0;
+ if (ctx === 5) {
+ const i1 = o.R("EIS_HOLIR1"),
+ i2 = o.R("EIS_HOLIR2");
+ const hpay = roundDown(((((L * i1) / 100) * i2) / 100) * 1.4, 0);
+ const keep = (c) => num(o.keys["D_" + c]) === 1;
+ const soc = keep("EIS_SOCINS")
+ ? o.k("C_EIS_SOCINS")
+ : roundDown(((L * (o.R("KUNBO") + o.R("YUNBO"))) / 100) * (o.R("EIS_SOCIR2") / 100), 0);
+ const inc = keep("EIS_INCENT")
+ ? o.k("C_EIS_INCENT")
+ : roundDown(((((L * i1) / 100) * i2) / 100) * (o.R("EIS_INCENT") / 100), 0);
+ keysOut.C_EIS_HOLPAY = hpay;
+ const sup = put(
+ "EIS_SUPPAY",
+ L,
+ 0,
+ tr(hpay + soc + inc),
+ "주휴수당+사회보험+장려금",
+ "",
+ "post",
+ );
+ eisAdd = sup + (keep("EIS_SOCINS") ? put("EIS_SOCINS", L, 0, soc, "", "", "post") : 0);
+ eisAdd += keep("EIS_INCENT") ? put("EIS_INCENT", L, 0, inc, "", "", "post") : 0;
+ Lp += hol;
+ }
+
+ // 순공사원가(헬퍼 합) — 일반 = 재료비 + 노무비(직+간) + 경비 · 실적 = 직접공사비 + 간접공사비
+ const add1 = addSum(1);
+ expAcc += add1;
+ const total30 = actl ? T + expAcc : M + Lp + expAcc;
+ const anjunp = put(
+ "ANJUNP",
+ total30 + knSa,
+ o.R("ANJUNP"),
+ tr(((total30 + knSa) * o.R("ANJUNP")) / 100),
+ "(순공사원가+관급)×율",
+ "",
+ "post",
+ );
+
+ // 7) 일반관리비 — 일반 계열은 비과세자재·관리품목자재 포함
+ const ilbanBase = std ? total30 + o.k("C_NBUGAM") + o.k("C_T_K_MAT") : total30;
+ const ilban = put(
+ "ILBAN",
+ ilbanBase,
+ o.R("ILBAN"),
+ tr((ilbanBase * o.R("ILBAN")) / 100),
+ "순공사원가",
+ "",
+ "ilban",
+ );
+ const A = total30 + ilban;
+
+ // 8) 이윤 — 일반 (계−재료비)×율 · 실적 계×율 · 원 단위 절사 · 이윤보정액 차감 · 공급가액 절사(I_JEOLSA)
+ const rIyun = o.R("IYUN"),
+ P = actl ? roundDown((A * rIyun) / 100, 0) : roundDown(((A - M) * rIyun) / 100, 0);
+ const fixedAdds =
+ anjunp +
+ put("PSAMT_B", 0, 0, o.k("C_PSAMT_B"), "별도", "", "post") +
+ put("EXCEPT_B", 0, 0, o.k("C_EXCEPT_B"), "별도", "", "post") +
+ (std && posSa === 1 ? Kres : 0) +
+ eisAdd +
+ addSum(2);
+ const k4 = (o.R("S_SIGONG") / 100) * o.R("S_SIGONG_1"),
+ jeol = o.k("I_JEOLSA");
+ const sig = (x) =>
+ ctx === 4 ? (o.fix("S_SIGONG") ?? roundDown((A + P - x + Kres) * k4, 0)) : 0;
+ let mAdj = iyunM,
+ sigong = 0,
+ RT;
+ if (!jeol) {
+ sigong = ctx === 4 ? (o.fix("S_SIGONG") ?? roundDown((A + (P - mAdj) + Kres) * k4, 0)) : 0;
+ RT = A + (P - mAdj) + fixedAdds + sigong;
+ } else {
+ const s0 = sig(0),
+ gross = A + P + fixedAdds + s0,
+ target = roundDown(gross, -jeol),
+ delta = gross - target;
+ if (ctx !== 4) ((mAdj = delta), (sigong = s0));
+ else {
+ let x = roundBanker(delta / (1 + k4) + 0.5, 0);
+ if (x + s0 - sig(x) !== delta) x = roundDown(delta / (1 + k4), 0);
+ sigong = x + s0 - sig(x) === delta ? sig(x) : s0 - (delta - x);
+ mAdj = x;
+ }
+ RT = A + (P - mAdj) + fixedAdds + sigong;
+ }
+ if (ctx === 4)
+ put(
+ "S_SIGONG",
+ A + P + Kres,
+ o.R("S_SIGONG"),
+ sigong,
+ "(순공사원가+일반관리비+이윤+사급비)×율×계수",
+ "",
+ "post",
+ );
+ const iyunFinal = P - mAdj,
+ supply308 = RT;
+ keysOut.C_IYUN_M = mAdj;
+
+ // 9) 공사손해보험 — 대상액 C_KONGSA_J 8갈래(B_KONGSA=1 이면 저장값) · 공급가액 절사 자리
+ const G23 = posSa === 2 || posSa === 3 ? 1 : 0;
+ const kjCase = [
+ gA / 1.1 + supply308,
+ gA + supply308,
+ supply308 + (G23 * Kres) / 1.1,
+ supply308 + G23 * Kres,
+ gA / 1.1 + supply308 + G23 * Kres,
+ gA + supply308 + (G23 * Kres) / 1.1,
+ gA / 1.1 + supply308 + (G23 * Kres) / 1.1,
+ gA + supply308 + G23 * Kres,
+ ];
+ const kongsaJ = o.k("B_KONGSA")
+ ? o.k("C_KONGSA_J")
+ : knT > 7
+ ? supply308
+ : roundDown(kjCase[knT], 0);
+ keysOut.C_KONGSA_J = kongsaJ;
+ keysOut.C_KN_SA = knSa;
+ RT += put(
+ "KONGSA",
+ kongsaJ,
+ o.R("KONGSA"),
+ roundDown((kongsaJ * o.R("KONGSA")) / 100, -jeol),
+ "공사손해보험 대상액",
+ "",
+ "post",
+ );
+ RT -= put("BUSAN_B", 0, 0, o.k("C_BUSAN_B"), "작업설 등", "", "post");
+
+ // 10) 부가세(I_BUGA · REA 20) — 0 공급가액 · 1 재료비 · 2 없음 · 3 산림조합(직접비−노무비+안전품질+과세 추가) · 4 3에서 면세품 뺌
+ const buga = o.k("I_BUGA"),
+ up = bugaUp ? 1 : 0,
+ E = addSum(2, true);
+ const forest = T - L + F + E - (buga === 4 ? o.k("C_BUGA_M") : 0);
+ const vat =
+ buga === 0
+ ? roundDown(0.1 * RT, 0) + up
+ : buga === 1
+ ? roundDown(0.1 * M, 0) + up
+ : buga === 3 || buga === 4
+ ? roundDown(forest * 0.1, 0) + up
+ : 0;
+ const supplyFinal = RT;
+ RT += vat;
+
+ // 11) 세후 가산 · 도급 절사 · 관급(도급자설치·관급자설치 따로 자리·절상/반올림/절사) · 사급 · 추가4
+ RT += put("PURCHAS", 0, 0, o.k("C_PURCHAS"), "", "", "post");
+ if (std)
+ for (const c of ["T_PEGI", "T_KOLJAE", "NBUGAM"])
+ RT += put(c, 0, 0, o.k("C_" + c), "", "", "post");
+ if (std && posSa === 2) RT += Kres;
+ const add3 = addSum(3);
+ RT += add3;
+ const contractRaw = RT;
+ if (o.k("I_DOG_JEOL") > 0) RT = roundDown(RT, -o.k("I_DOG_JEOL"));
+ const contractFinal = RT;
+ const rnd = (v, digits, updn) =>
+ updn === 1
+ ? roundBanker(v, -digits)
+ : updn === 2
+ ? roundDown(v, -digits)
+ : roundUp(v, -digits);
+ const comb = o.k("I_KWN_COMB");
+ const govFinal = rnd(comb === 0 ? gD : gAll, o.k("I_KWN_JEOLSA"), o.k("I_KWN_UPDN"));
+ const govK = comb === 0 ? rnd(gK, o.k("I_KWN_K_JEOLSA"), o.k("I_KWN_K_UPDN")) : 0;
+ RT += govFinal + govK;
+ Object.assign(keysOut, { C_UPDNKWN: govFinal, C_KWANGUP: gAll, C_KWANGUP_D: gD });
+ Object.assign(keysOut, { C_KWANGUP_K: gK, C_T_JODAL: jodal });
+ for (const c of [
+ "DOSEOH",
+ "S_SIGONG",
+ "EIS_SUPPAY",
+ "EIS_SOCINS",
+ "EIS_INCENT",
+ "EIS_HOLPAY",
+ ...S8,
+ ])
+ if (keysOut["C_" + c] == null) keysOut["C_" + c] = o.fix(c) ?? 0;
+ const sagAfter = std && (posSa === 1 || posSa === 2) ? 0 : Kres;
+ RT += sagAfter;
+ RT += addSum(4);
+ const anjunCase = { c190, c198, c1a0, l10, l18, cap, capAuto, b0, U, Dn, knSa };
+ return Object.assign(
+ { rows, keysOut, M, L, S, T, Lp, hol, kanno, expAcc, add1, total30, ilbanBase, ilban, A, P },
+ { mAdj, iyunFinal, fixedAdds, sigong, supply308, kongsaJ, supplyFinal, vat, contractRaw },
+ { contractFinal, govFinal, govK, sagAfter, gD, gK, jodal, knSa, anjBasis, anjunCase },
+ { grand: RT, add3, rIyun, ctx, actl, adds },
+ );
+ }
+
+ Object.assign(calcKanjub, { evalTpl: P.evalTpl, LABEL: P.LABEL, ORD_CODE: P.ORD_CODE });
+ Object.assign(calcKanjub, { KAN_ORD: P.KAN_ORD, keyOf, codeOf: P.codeOf });
return calcKanjub;
};
-if (typeof window !== "undefined") window.STMATE_KANJUB_FACTORY = StmateKanjubFactory;
+if (typeof window !== "undefined") {
+ window.STMATE_KANJUB_FACTORY = StmateKanjubFactory;
+ // 화면 — 부속 모듈을 calc.js 보다 먼저 싣음(index.html 은 kanjub_calc.js 만 부름)
+ if (!window.STMATE_KANJUB_PARTS && document.readyState === "loading")
+ document.write('');
+}
if (typeof module !== "undefined" && module.exports) module.exports = StmateKanjubFactory;
diff --git a/tools/stmate-test/kanjub_cfg.py b/tools/stmate-test/kanjub_cfg.py
index 5fb57e175..992908025 100644
--- a/tools/stmate-test/kanjub_cfg.py
+++ b/tools/stmate-test/kanjub_cfg.py
@@ -1,34 +1,17 @@
# -*- coding: utf-8 -*-
-"""PRJLST.SMT [1_KANJUB]·[KANJUB_ADD] 키 → 원가계산서(제잡비) 계산 설정 (kanjub_cfg.py).
+"""PRJLST.SMT [n_KANJUB]·[KANJUB_ADD] 키 → 원가계산서(제잡비) 계산 설정 (kanjub_cfg.py).
-근거: 지식DB STmate/old/20_분석 06(간접비·끝전) · 11 §9(추가금액 단계) · 25 §1(저장키).
+설정 = 원본 저장키 그대로(keys · 차수 접두 뗌) + 다른 화면이 쓰는 옛 칸(rates·fixed·scheme·anjunAuto 등).
+되돌려 쓰기 = smt_updates(cfg, slot) — 키 왕복(F2-58).
+근거: 지식DB STmate/old/20_분석 12 §10(선택축) · 20 §2(KAN_ORD) · 25 §1(저장키 241종)
+ · REA 18(D_ 고정 · 추가금액 키 꼴 ADD_AMT_g_ii_n) · REA 20(JAB_TYPE 계산 분류 · CAL_ACTL 실적).
"""
from __future__ import annotations
-RATE_KEYS = {
- "KANNO": "1_R_KANNO",
- "SANBO": "1_R_SANBO",
- "KOBO": "1_R_KOBO",
- "KUNBO": "1_R_KUNBO",
- "SLVBO": "1_R_SLVBO",
- "YUNBO": "1_R_YUNBO",
- "TBUGUM": "1_R_TBUGUM",
- "ANJUN": "1_R_ANJUN",
- "KITA": "1_R_T_KITA",
- "HWAN": "1_R_HWAN",
- "EQIP": "1_R_EQIPSU",
- "ILBAN": "1_R_ILBAN",
- "IYUN": "1_R_IYUN",
- "S_SUDO": "1_R_S_SUDO",
- "S_BOKRI": "1_R_S_BOKRI",
- "S_SOMO": "1_R_S_SOMO",
- "S_TONGSIN": "1_R_S_TONGSIN",
- "S_SEGUM": "1_R_S_SEGUM",
- "S_PEGI": "1_R_S_PEGI",
- "S_DOSEO": "1_R_S_DOSEO",
- "S_JIGUP": "1_R_S_JIGUP",
-}
+# 원본 저장키 몸통 → 옛 항목코드(화면·다른 계산 모듈이 쓰는 이름)
+CODE_OF = {"T_KITA": "KITA", "EQIPSU": "EQIP"}
+KEY_OF = {v: k for k, v in CODE_OF.items()}
REV_KEYS = (
"KANNO",
"SANBO",
@@ -42,6 +25,13 @@ REV_KEYS = (
"HWAN",
"EQIP",
)
+# 차수와 무관한 공통 키(JAB_TYPE 계산 분류 · CAL_ACTL 실적 · VIS_TOTL 총괄표시)
+GLOBAL_KEYS = ("JAB_TYPE", "CAL_ACTL", "VIS_TOTL")
+# 간접비 순서 코드표 기본값(Start8x.Ini · 20번 §2.1)
+KAN_ORD = {
+ 1: ";301;302;303;310;304;305;306;312;313;321;307;308;309;311;361;362;363;364;365;366;367;368;369;370;",
+ 2: ";351;301;302;303;310;304;305;306;312;313;331;332;333;334;335;336;337;338;307;308;309;311;361;362;363;364;365;366;367;368;369;370;",
+}
def _num(kv: dict, key: str, default: float = 0.0) -> float:
@@ -52,41 +42,42 @@ def _num(kv: dict, key: str, default: float = 0.0) -> float:
return default
-# D_ 표시(=1) 항목은 저장 금액(C_) 고정 — 변경 차수에서 당초액 유지(건강·장기요양·연금·퇴직공제 등)
-FIXED_KEYS = {
- "KANNO": "KANNO",
- "SANBO": "SANBO",
- "KOBO": "KOBO",
- "KUNBO": "KUNBO",
- "SLVBO": "SLVBO",
- "YUNBO": "YUNBO",
- "TBUGUM": "TBUGUM",
- "ANJUN": "ANJUN",
- "KITA": "T_KITA",
- "HWAN": "HWAN",
- "EQIP": "EQIPSU",
- "ILBAN": "ILBAN",
-}
+def _val(v):
+ """숫자로 읽히면 숫자, 아니면 글자 그대로."""
+ s = str(v).strip()
+ try:
+ f = float(s.replace(",", ""))
+ return int(f) if f.is_integer() and "." not in s else f
+ except ValueError:
+ return s
-def add_amounts(kv: dict, slot: int = 1) -> list[dict]:
- """ADD_AMT_<단계>_<행>_<차수> — 단계 1~4 = 화면 단계 3~6(일반관리비 앞 · 이윤 뒤 · 도급 앞 · 총공사비 앞)."""
+def add_rows(kv: dict) -> list[dict]:
+ """추가금액 행 — ADD_CNT_g(묶음 1~4) · ADD_NAME/UNIT/BIGO_g_ii · ADD_AMT_g_ii_1~5(차수 칸) · ADD_BUGA(묶음2) · ADD_ADDT(묶음1)."""
out = []
- for stage in range(1, 5):
- for i in range(1, int(_num(kv, f"ADD_CNT_{stage}")) + 1):
- row = f"{stage}_{i:02d}"
+ for g in range(1, 5):
+ for i in range(1, int(_num(kv, f"ADD_CNT_{g}")) + 1):
+ row = f"{g}_{i:02d}"
out.append(
{
- "stage": stage,
+ "group": g,
"name": kv.get(f"ADD_NAME_{row}", ""),
- "amount": _num(kv, f"ADD_AMT_{row}_{slot}"),
+ "unit": kv.get(f"ADD_UNIT_{row}", ""),
+ "bigo": kv.get(f"ADD_BIGO_{row}", ""),
+ "amts": [_num(kv, f"ADD_AMT_{row}_{s}") for s in range(1, 6)],
"vat": _num(kv, f"ADD_BUGA_{row}") == 1,
+ "addt": int(_num(kv, f"ADD_ADDT_{row}")),
}
)
return out
-VAT_MODES = {0: "supply_10", 1: "mat_10", 2: "zero", 3: "forest", 4: "forest_exempt"}
+def add_amounts(kv: dict, slot: int = 1) -> list[dict]:
+ """옛 칸 — 차수 slot 금액만 뽑은 추가금액 목록(stage = 묶음 1~4)."""
+ return [
+ {"stage": r["group"], "name": r["name"], "amount": r["amts"][slot - 1], "vat": r["vat"]}
+ for r in add_rows(kv)
+ ]
def stage_slots(kv: dict) -> list[int]:
@@ -94,36 +85,75 @@ def stage_slots(kv: dict) -> list[int]:
return [n for n in range(1, 5) if any(k.startswith(f"{n}_C_") for k in kv)] or [1]
+def scheme_of(keys: dict) -> str:
+ """4형식 — JAB_TYPE 3(수자원)·4(지역난방) = 수공, CAL_ACTL=1 = 실적(REA 18·20 · 12번 §2)."""
+ su = int(_num(keys, "JAB_TYPE")) in (3, 4)
+ actl = int(_num(keys, "CAL_ACTL")) == 1
+ return ("SA" if su else "TA") if actl else ("S" if su else "T")
+
+
def build_kanjub_config(kv: dict, slot: int = 1) -> dict:
- jab_type = int(_num(kv, "JAB_TYPE"))
- cal_actl = int(_num(kv, "CAL_ACTL"))
- scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T")
p = f"{slot}_"
- tot_jeol = int(_num(kv, p + "I_TOT_JEOL"))
+ keys = {k[len(p) :]: _val(v) for k, v in kv.items() if k.startswith(p)}
+ keys.update({k: _val(kv[k]) for k in GLOBAL_KEYS if k in kv})
+ rates = {
+ CODE_OF.get(k[2:], k[2:]): float(_val(v) or 0)
+ for k, v in keys.items()
+ if k.startswith("R_") and isinstance(_val(v), (int, float))
+ }
+ fixed = {
+ CODE_OF.get(k[2:], k[2:]): _num(keys, "C_" + k[2:])
+ for k in keys
+ if k.startswith("D_") and _num(keys, k) == 1
+ }
return {
"slot": slot,
- "scheme": scheme,
- "indirectTruncDigits": 0,
- "anjunBaseAdd": _num(kv, p + "C_ANJUN_G"),
- "anjunGovVatExcluded": True,
- "anjunCap12": True,
- "vatMode": VAT_MODES.get(int(_num(kv, p + "I_BUGA")), "zero"),
- "vatExempt": _num(kv, p + "C_BUGA_M"),
- "vatRoundUp": _num(kv, p + "B_BUGA_UP") == 1,
- "anjunAuto": int(_num(kv, p + "I_ANJUNAUTO")),
- "totJeolDigits": -tot_jeol,
- "enableProfitEndAdj": tot_jeol > 0,
- "iyunManualAdj": 0 if tot_jeol > 0 else _num(kv, p + "C_IYUN_M"),
- "govMat": _num(kv, p + "C_KWANGUP") if p + "C_KWANGUP" in kv else None,
- "govFee": _num(kv, p + "C_T_JODAL") if p + "C_T_JODAL" in kv else None,
- "govRoundMode": "up",
- "govRoundDigits": -int(_num(kv, p + "I_KWN_JEOLSA", 3)),
- "addAmounts": add_amounts(kv, slot),
- "rates": {k: _num(kv, v.replace("1_", p, 1)) for k, v in RATE_KEYS.items()},
- "fixed": {
- code: _num(kv, p + "C_" + key)
- for code, key in FIXED_KEYS.items()
- if _num(kv, p + "D_" + key) == 1
- },
+ "scheme": scheme_of(keys),
+ "keys": keys,
+ "rates": rates,
+ "fixed": fixed,
"revModes": {k: "recalculated" for k in REV_KEYS},
+ "anjunAuto": int(_num(keys, "I_ANJUNAUTO")),
+ "anjunBaseAdd": _num(keys, "C_ANJUN_G"),
+ "addRows": add_rows(kv),
+ "addAmounts": add_amounts(kv, slot),
+ "kanOrd": dict(KAN_ORD),
}
+
+
+def smt_updates(cfg: dict, slot: int | None = None, keys_out: dict | None = None) -> dict:
+ """설정(+계산 결과 키) → PRJLST.SMT 갱신 사전(n_KEY · 공통 키 · ADD_*) — 저장 때 server.save 의 smt_updates 로 넘김."""
+ n = slot or int(cfg.get("slot", 1))
+ keys = dict(cfg.get("keys") or {})
+ for code, r in (cfg.get("rates") or {}).items():
+ keys["R_" + KEY_OF.get(code, code)] = r
+ if cfg.get("anjunAuto") is not None:
+ keys["I_ANJUNAUTO"] = cfg["anjunAuto"]
+ keys.update(keys_out or {})
+ out = {}
+ for k, v in keys.items():
+ out[k if k in GLOBAL_KEYS else f"{n}_{k}"] = _fmt(v)
+ rows = cfg.get("addRows") or []
+ for g in range(1, 5):
+ mine = [r for r in rows if int(r.get("group", 0)) == g]
+ out[f"ADD_CNT_{g}"] = str(len(mine))
+ for i, r in enumerate(mine, 1):
+ row = f"{g}_{i:02d}"
+ out[f"ADD_NAME_{row}"] = r.get("name", "")
+ out[f"ADD_UNIT_{row}"] = r.get("unit", "")
+ out[f"ADD_BIGO_{row}"] = r.get("bigo", "")
+ for s, a in enumerate((list(r.get("amts") or []) + [0] * 5)[:5], 1):
+ out[f"ADD_AMT_{row}_{s}"] = _fmt(a)
+ if g == 2:
+ out[f"ADD_BUGA_{row}"] = "1" if r.get("vat") else "0"
+ if g == 1:
+ out[f"ADD_ADDT_{row}"] = str(int(r.get("addt") or 0))
+ return out
+
+
+def _fmt(v) -> str:
+ if isinstance(v, bool):
+ return "1" if v else "0"
+ if isinstance(v, float) and v.is_integer():
+ return str(int(v))
+ return str(v)
diff --git a/tools/stmate-test/kanjub_parts.js b/tools/stmate-test/kanjub_parts.js
new file mode 100644
index 000000000..3fc2fc1bb
--- /dev/null
+++ b/tools/stmate-test/kanjub_parts.js
@@ -0,0 +1,362 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 원가계산서(제잡비) 부속 (kanjub_parts.js) — 항목 이름·간접비 순서 코드표·식 템플릿 평가·설정 읽개·결과 묶음
+ * 근거: 지식DB STmate/old/20_분석 12 §3~7 · 20 §2.1~2.2(KAN_ORD) · 25 §2(식 템플릿 26) · REA 18·20
+ * 부르는 곳: kanjub_calc.js(계산) · scr_kanjub.js(화면)
+ */
+const StmateKanjubParts = ({ num, fmt, roundDown, roundUp, roundBanker }) => {
+ // 항목코드(옛 화면 코드) → 원본 저장키 몸통(C_·R_·D_·S_ 뒤)
+ const KEY = { KITA: "T_KITA", EQIP: "EQIPSU" };
+ const keyOf = (code) => KEY[code] || code;
+ const codeOf = (key) => Object.keys(KEY).find((c) => KEY[c] === key) || key;
+ // 항목 이름 — 화면·출력 표기(봉화 2024 공사원가계산서 · 12번 §3~6)
+ const LABEL = {
+ DOSEOH: "노무비 가산",
+ KANNO: "간접노무비",
+ SANBO: "산재보험료",
+ KOBO: "고용보험료",
+ KUNBO: "건강보험료",
+ SLVBO: "노인장기요양보험료",
+ YUNBO: "연금보험료",
+ TBUGUM: "퇴직공제부금비",
+ ANJUN: "산업안전보건관리비",
+ ANJUN_B: "안전관리비(별도내역)",
+ PUMJIL_B: "품질관리비(별도내역)",
+ S_ANJUN_B: "안전관리비(수공 별도)",
+ S_PUMJIL: "품질관리비(수공)",
+ KITA: "기타경비",
+ S_SUDO: "수도광열비",
+ S_BOKRI: "복리후생비",
+ S_SOMO: "소모품·사무용품비",
+ S_TONGSIN: "여비·교통비·통신비",
+ S_SEGUM: "세금·공과금",
+ S_PEGI: "폐기물처리비",
+ S_DOSEO: "도서인쇄비",
+ S_JIGUP: "지급수수료",
+ KONGSU: "공사이행보증수수료",
+ HADOSU: "하도급대금지급보증수수료",
+ HWAN: "환경보전비",
+ EQIP: "건설기계대여금지급보증 금액",
+ BUDM_SM: "석면피해구제분담금",
+ BUDM_IC: "임금채권부담금",
+ ILBAN: "일반관리비",
+ ANJUNP: "안전점검비",
+ PSAMT_B: "미확정설계공종(PS)",
+ EXCEPT_B: "제비율 적용제외공종",
+ EIS_SUPPAY: "고용개선지원비",
+ EIS_SOCINS: "고용개선 사회보험",
+ EIS_INCENT: "고용개선 장려금",
+ S_SIGONG: "시공도작성비",
+ KONGSA: "공사손해보험료",
+ BUSAN_B: "작업설 등 감액(△)",
+ PURCHAS: "매입세",
+ T_PEGI: "폐기물처리수수료",
+ T_KOLJAE: "골재사용료",
+ NBUGAM: "비과세자재대",
+ SAGUP: "사급자재대",
+ };
+ // 간접비 순서 코드표(Start8x.Ini KAN_ORD_1 일반 · KAN_ORD_2 수공 — 20번 §2.1~2.2) · 코드 → 항목
+ const ORD_CODE = {
+ 301: "KANNO",
+ 302: "SANBO",
+ 303: "KOBO",
+ 310: "KUNBO",
+ 304: "SLVBO",
+ 305: "YUNBO",
+ 306: "TBUGUM",
+ 312: "ANJUN",
+ 313: "ANJUN_B",
+ 321: "KITA",
+ 307: "KONGSU",
+ 308: "HADOSU",
+ 309: "HWAN",
+ 311: "EQIP",
+ 331: "S_SUDO",
+ 332: "S_BOKRI",
+ 333: "S_SOMO",
+ 334: "S_TONGSIN",
+ 335: "S_SEGUM",
+ 336: "S_PEGI",
+ 337: "S_DOSEO",
+ 338: "S_JIGUP",
+ 351: "S_PUMJIL",
+ };
+ const KAN_ORD_1 = "301;302;303;310;304;305;306;312;313;321;307;308;309;311";
+ const KAN_ORD_2 =
+ "351;301;302;303;310;304;305;306;312;313;331;332;333;334;335;336;337;338;307;308;309;311";
+ const S8 = "S_SUDO S_BOKRI S_SOMO S_TONGSIN S_SEGUM S_PEGI S_DOSEO S_JIGUP".split(" ");
+ // 옛 화면 칸 → 원본 키 기본값(새 공사 · 옛 설정 호환)
+ const VAT_IDX = { supply_10: 0, mat_10: 1, zero: 2, forest: 3, forest_exempt: 4 };
+ const UPDN_IDX = { up: 0, half: 1, down: 2 };
+ const DEF = { I_ANJUN_K: 1, I_ANJUNAUTO: 1, I_TOT_JEOL: 3, I_KWN_JEOLSA: 3, I_KWN_K_JEOLSA: 3 };
+
+ // 간접비 식 템플릿 평가(25번 §2) — [변수] 치환 · ROUNDDOWN/ROUND/ROUNDUP/IF · 사칙
+ function evalTpl(tpl, vars) {
+ let s = String(tpl).replace(/\[([A-Z0-9_+]+)\]/g, (_, v) =>
+ v[0] === "+" ? `+(${num(vars[v.slice(1)])})` : `(${num(vars[v])})`,
+ );
+ s = s
+ .replace(/ROUNDDOWN\(/g, "RD(")
+ .replace(/ROUNDUP\(/g, "RU(")
+ .replace(/ROUND\(/g, "RN(")
+ .replace(/\bIF\(/g, "IFF(");
+ if (/[^0-9.+\-*/(),<>=!&| RDUNIF]/.test(s)) return NaN;
+ const fn = Function("RD", "RU", "RN", "IFF", `"use strict"; return (${s});`);
+ return fn(
+ (v, d = 0) => roundDown(v, d),
+ (v, d = 0) => roundUp(v, d),
+ (v, d = 0) => roundBanker(v, d),
+ (c, a, b) => (c ? a : b),
+ );
+ }
+
+ // 설정 → 원본 키 읽개(옛 칸 우선) · 차수 접두 없는 키
+ function reader(cfg, data) {
+ const keys = cfg.keys || {},
+ rates = cfg.rates || {},
+ fixed = cfg.fixed || {};
+ const raw = (k) => (keys[k] != null && keys[k] !== "" ? num(keys[k]) : DEF[k] || 0);
+ const ov = {};
+ if (cfg.vatMode != null) ov.I_BUGA = VAT_IDX[cfg.vatMode] ?? 2;
+ if (cfg.vatRoundUp != null) ov.B_BUGA_UP = cfg.vatRoundUp ? 1 : 0;
+ if (cfg.vatExempt != null) ov.C_BUGA_M = num(cfg.vatExempt);
+ if (cfg.totJeolDigits != null) ov.I_TOT_JEOL = Math.max(0, -num(cfg.totJeolDigits));
+ if (cfg.enableProfitEndAdj === false) ov.I_TOT_JEOL = 0;
+ if (cfg.anjunAuto != null) ov.I_ANJUNAUTO = num(cfg.anjunAuto);
+ if (cfg.iyunManualAdj != null) ov.C_IYUN_M = num(cfg.iyunManualAdj);
+ if (cfg.indirectTruncDigits) ov.I_KAN_JEOLSA = Math.max(0, -num(cfg.indirectTruncDigits));
+ if (cfg.govRoundMode != null) ov.I_KWN_UPDN = UPDN_IDX[cfg.govRoundMode] ?? 0;
+ if (cfg.govRoundDigits != null) ov.I_KWN_JEOLSA = Math.max(0, -num(cfg.govRoundDigits));
+ if (keys.C_ANJUN_G == null && cfg.anjunBaseAdd != null)
+ ov.C_ANJUN_G = ov.C_ANJUN1_G = ov.C_ANJUN2_G = num(cfg.anjunBaseAdd);
+ if (keys.I_KN_SA_TYPE == null && cfg.anjunGovVatExcluded === false) ov.I_KN_SA_TYPE = 1;
+ if (keys.C_KWANGUP_D == null && cfg.govMat != null)
+ ov.C_KWANGUP_D = num(cfg.govMat) - num(keys.C_KWANGUP_K);
+ if (keys.C_T_JODAL == null && cfg.govFee != null) ov.C_T_JODAL = num(cfg.govFee);
+ // 형식(옛 scheme) — S·SA = 수자원·지역난방(3·4) · TA·SA = 실적(CAL_ACTL)
+ let ctx = raw("JAB_TYPE"),
+ actl = raw("CAL_ACTL") ? 1 : 0;
+ if (cfg.scheme) {
+ const su = cfg.scheme === "S" || cfg.scheme === "SA";
+ if (su && ctx !== 3 && ctx !== 4) ctx = 3;
+ if (!su && (ctx === 3 || ctx === 4)) ctx = 0;
+ actl = cfg.scheme === "TA" || cfg.scheme === "SA" ? 1 : 0;
+ }
+ const k = (name) => (name in ov ? ov[name] : raw(name));
+ const R = (code) =>
+ rates[code] != null
+ ? num(rates[code])
+ : keys["R_" + keyOf(code)] != null
+ ? num(keys["R_" + keyOf(code)])
+ : code === "ANJUN1" || code === "ANJUN2"
+ ? num(rates.ANJUN ?? keys.R_ANJUN)
+ : 0;
+ const G = (name) => (keys[name] == null && name !== "C_ANJUN_G" ? k("C_ANJUN_G") : k(name));
+ // D_ 표시 = 저장 금액(C_) 고정 · fixed 칸이 있으면 그 표가 정본(변경 차수 시작 때 다시 짬)
+ const fix = (code) =>
+ cfg.fixed
+ ? fixed[code] != null
+ ? num(fixed[code])
+ : null
+ : num(keys["D_" + keyOf(code)]) === 1
+ ? num(keys["C_" + keyOf(code)])
+ : null;
+ const pclass = data && data.projectMeta ? num(data.projectMeta.pclass, -1) : -1;
+ return { k, R, G, fix, ctx, actl, pclass, revModes: cfg.revModes || {}, keys };
+ }
+
+ // 추가금액 — 묶음 1~4(일반관리비 앞 · 이윤 뒤 · 도급 앞 · 총공사비 앞) · 차수 칸(ADD_AMT_g_ii_n) · 25번 §1.3 · REA 18
+ function addList(cfg, slot) {
+ if (Array.isArray(cfg.addRows))
+ return cfg.addRows.map((r) => ({
+ stage: num(r.group || r.stage),
+ name: r.name || "",
+ amount: num((r.amts || [])[slot - 1]),
+ vat: !!r.vat,
+ addt: num(r.addt),
+ }));
+ return (cfg.addAmounts || []).map((a) => Object.assign({ addt: 0 }, a));
+ }
+
+ // 결과 묶음 — 옛 출력 칸(items·lines·대문자 합계) 유지 · KAN_ORD 순서 · 저장키(keysOut)
+ function finish(o, cfg, r, prev) {
+ const sch = r.actl
+ ? r.ctx === 3 || r.ctx === 4
+ ? "SA"
+ : "TA"
+ : r.ctx === 3 || r.ctx === 4
+ ? "S"
+ : "T";
+ const ordStr =
+ (cfg.kanOrd && cfg.kanOrd[sch[0] === "S" ? 2 : 1]) ||
+ (sch[0] === "S" ? KAN_ORD_2 : KAN_ORD_1);
+ const ord = String(ordStr)
+ .split(/[;,\s]+/)
+ .map((c) => ORD_CODE[c])
+ .filter(Boolean);
+ const pre = ["KANNO", ...ord.filter((c) => c !== "KANNO")];
+ for (const c of ["PUMJIL_B", "S_ANJUN_B", "BUDM_SM", "BUDM_IC"])
+ if (!pre.includes(c)) pre.push(c);
+ if (r.rows.DOSEOH) pre.unshift("DOSEOH");
+ const always = new Set([
+ "KANNO",
+ "SANBO",
+ "KOBO",
+ "KUNBO",
+ "SLVBO",
+ "YUNBO",
+ "TBUGUM",
+ "ANJUN",
+ "HWAN",
+ "EQIP",
+ ]);
+ if (r.ctx !== 3 && r.ctx !== 4) always.add("KITA");
+ else S8.forEach((c) => always.add(c));
+ const items = pre
+ .filter((c) => r.rows[c] && (always.has(c) || r.rows[c].amount !== 0))
+ .map((c) => r.rows[c]);
+ const itemMap = Object.assign({}, r.rows);
+ const kanno = r.kanno,
+ indirectExpenseSum = items
+ .filter((x) => x.code !== "KANNO")
+ .reduce((s, x) => s + x.amount, 0);
+ const totalLabor = r.L + kanno + (r.ctx === 5 ? r.hol : 0),
+ totalExpense = r.S + indirectExpenseSum + r.add1;
+ const pCost = r.total30,
+ pretaxProfitAdj = r.mAdj;
+ const iyunDesc = r.actl ? "직접공사비+간접공사비+일반관리비" : "노무비+경비+일반관리비";
+ const mk = (key, label, formula, baseAmt, rate, amount) =>
+ Object.assign({ key, label, formula, baseAmt, base: baseAmt, rate, amount });
+ const post = (codes) =>
+ codes
+ .filter((c) => r.rows[c] && r.rows[c].amount !== 0)
+ .map((c) => {
+ const x = r.rows[c];
+ return mk(
+ c,
+ x.label,
+ x.formula,
+ x.baseAmt,
+ x.rate || null,
+ c === "BUSAN_B" ? -x.amount : x.amount,
+ );
+ });
+ const addLines = (g) =>
+ r.adds
+ .filter((a) => a.stage === g && num(a.amount) !== 0)
+ .map((a, i) =>
+ mk(`ADD_${g}_${i + 1}`, a.name || `추가금액 ${g}`, "추가금액", null, null, num(a.amount)),
+ );
+ const govStr = `원자재대: ${fmt(r.gD)} (조달수수료: ${fmt(r.jodal)} 포함)`;
+ const lines = [
+ mk("DIRECT_M", "직접재료비", "내역서 재료비 합계", r.M, null, r.M),
+ mk("DIRECT_L", "직접노무비", "내역서 직접노무비 합계", r.L, null, r.L),
+ mk("DIRECT_S", "산출경비", "내역서 산출경비 합계", r.S, null, r.S),
+ mk("DIRECT_COST", "직접공사비 계", "직재 + 직노 + 산출경비", r.T, null, r.T),
+ ...items.map((it) => mk(it.key, it.label, it.formula, it.baseAmt, it.rate, it.amount)),
+ ...addLines(1),
+ mk("TOTAL_COST", "순공사원가 계", "재료비 + 노무비 + 경비", pCost, null, pCost),
+ mk("ILBAN", "일반관리비", "순공사원가", r.ilbanBase, r.rows.ILBAN.rate, r.ilban),
+ mk(
+ "IYUN",
+ "이윤",
+ `${iyunDesc} (보정 -${fmt(pretaxProfitAdj)})`,
+ r.A - (r.actl ? 0 : r.M),
+ r.rIyun,
+ r.iyunFinal,
+ ),
+ ...post([
+ "ANJUNP",
+ "PSAMT_B",
+ "EXCEPT_B",
+ "EIS_SUPPAY",
+ "EIS_SOCINS",
+ "EIS_INCENT",
+ "S_SIGONG",
+ ]),
+ ...addLines(2),
+ mk(
+ "SUPPLY_VAL",
+ "총원가 (공급가액)",
+ "순공사원가 + 일반관리비 + 이윤",
+ r.supply308,
+ null,
+ r.supply308,
+ ),
+ ...post(["KONGSA", "BUSAN_B"]),
+ mk("VAT", "부가가치세", "공급가액 × 10%", r.supplyFinal, 10, r.vat),
+ ...post(["PURCHAS", "T_PEGI", "T_KOLJAE", "NBUGAM"]),
+ ...addLines(3),
+ mk(
+ "FINAL_SUPPLY",
+ "도급공사비",
+ "공급가액 + 부가가치세",
+ r.contractRaw,
+ null,
+ r.contractFinal,
+ ),
+ mk("GOV_MAT", "관급자재대", govStr, r.gD, null, r.govFinal),
+ ...(r.govK ? [mk("GOV_K", "관급자재대(관급자설치)", "", r.gK, null, r.govK)] : []),
+ ...(r.sagAfter ? [mk("SAGUP", "사급자재대", "", r.sagAfter, null, r.sagAfter)] : []),
+ ...addLines(4),
+ mk("GRAND_TOTAL", "총공사비", "도급 + 관급", r.grand, null, r.grand),
+ ];
+ const keysOut = Object.assign({}, r.keysOut, { I_ANJUN_B: r.anjunCase.cap ? 1 : 0 });
+ if (r.bugaUp != null) keysOut.B_BUGA_UP = r.bugaUp;
+ const out = { scheme: sch, dM: r.M, dL: r.L, dS: r.S, dT: r.T, kanno, totalLabor };
+ Object.assign(out, { indirectExpenseSum, totalExpense, pureConstructionCost: pCost });
+ Object.assign(out, { ilbanBase: r.ilbanBase, ilbanDesc: "순공사원가", ilbanAmt: r.ilban });
+ Object.assign(out, { iyunBase: r.A - (r.actl ? 0 : r.M), iyunDesc, iyunRaw: r.P });
+ Object.assign(out, {
+ pretaxProfitAdj,
+ iyunFinal: r.iyunFinal,
+ supplyBeforeAdj: r.supply308 + r.mAdj,
+ });
+ Object.assign(out, {
+ vatBeforeAdj: r.vat,
+ contractBeforeAdj: r.contractRaw,
+ diffTotal: r.diffTotal || 0,
+ });
+ Object.assign(out, {
+ supplyFinal: r.supply308,
+ vatFinal: r.vat,
+ contractFinal: r.contractFinal,
+ });
+ Object.assign(out, { govRawTotal: r.gD - r.jodal, govFeeTotal: r.jodal, adds: r.adds });
+ Object.assign(out, {
+ anjunBaseRaw: r.anjBasis,
+ anjun: r.anjunCase,
+ knSa: r.knSa,
+ kongsaJ: r.kongsaJ,
+ });
+ Object.assign(out, {
+ govFinal: r.govFinal,
+ govK: r.govK,
+ grandTotal: r.grand,
+ items,
+ itemMap,
+ lines,
+ });
+ Object.assign(out, { keysOut, hollab: r.hol, target: r.target, ctx: r.ctx, actl: r.actl });
+ Object.assign(out, { DIRECT_COST: r.T, KANNO: kanno, IND_EXP: kanno + indirectExpenseSum });
+ Object.assign(out, { TOTAL_COST: pCost, ILBAN: r.ilban, IYUN_INIT: r.P });
+ Object.assign(out, {
+ PROFIT_ADJ: -pretaxProfitAdj,
+ IYUN: r.iyunFinal,
+ SUPPLY_VAL: r.supply308,
+ });
+ return Object.assign(out, {
+ VAT: r.vat,
+ FINAL_SUPPLY: r.contractFinal,
+ GOV_MAT: r.govFinal,
+ GRAND_TOTAL: r.grand,
+ });
+ }
+
+ const KAN_ORD = { 1: KAN_ORD_1, 2: KAN_ORD_2 };
+ const out = { KEY, keyOf, codeOf, LABEL, ORD_CODE, KAN_ORD, S8, VAT_IDX, UPDN_IDX, DEF };
+ return Object.assign(out, { evalTpl, reader, addList, finish });
+};
+
+if (typeof window !== "undefined") window.STMATE_KANJUB_PARTS = StmateKanjubParts;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateKanjubParts;
diff --git a/tools/stmate-test/progress_calc.js b/tools/stmate-test/progress_calc.js
new file mode 100644
index 000000000..c0459bb12
--- /dev/null
+++ b/tools/stmate-test/progress_calc.js
@@ -0,0 +1,231 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 기성 계산 (progress_calc.js) — 회차별 전회·금회·누계 금액과 비율 · 공종별 기성수량·잔량 · 기성 제잡비 · 부가세 4택
+ * 근거: 지식DB STmate/old/20_분석 27 §3(wM_KanJub_K · 금회직접공사비 × 계약제잡비율 · 4단 누적 · 부가세 4택
+ * · 공급가액/총공사비 절사 · 이윤 직접입력 · 서식 0632~0638) · 35 §3(기성 단계) · 11 §10(계약 제잡비율 경로)
+ * 미확인(표본 0건, 27 §9): 누계 금액 산식 · 절사 순서 — 여기서는 누계 = trunc(계약단가 × 누계수량), 금회 = 누계 − 전회
+ * 시험: resources/tester/test_stmate_stage_progress.py (자기 일관성 — 전회+금회=누계 · 100% 누계=계약)
+ */
+const StmateProgress = (() => {
+ const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js");
+ const Change =
+ (typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js");
+ const { roundDown } = Calc;
+ const n0 = (v) => Number(v) || 0;
+ const r4 = (v) => Math.round(n0(v) * 1e4) / 1e4;
+ const KEYS = ["mAmt", "lAmt", "sAmt", "tAmt"];
+ // 부가세 4택(27 §3.3) — 0 직접입력 · 1 공급가액 10% · 2 재료비 10% · 3 재료비+산출경비 10%
+ const VAT_MODES = ["직접입력", "공급가액의 10%", "재료비의 10%", "재료비+산출경비의 10%"];
+ // 집계수준(rd_Filter) — 소계 이상 … 총합계 (머리 단 T2~T9)
+ const LEVELS = [
+ [0, "공종 전부"],
+ [2, "소계 이상"],
+ [3, "합계 이상"],
+ [4, "총계 이상"],
+ [5, "총합계"],
+ ];
+
+ // 계약 기준 — 계약 내역(contract_calc.convert)과 계약 원가계산서에서 공종·계약잡비율을 뽑음
+ function contractBase(conv, kj) {
+ const stage = conv.stage;
+ const leaves = conv.nodes
+ .filter((nd) => nd.kind === "leaf" && !nd.missing)
+ .map((nd) => {
+ const s = nd.st[stage];
+ const k = nd.pct ? 100 : 1; // % 단위 공종 = 밑수 ÷ 100 을 단가로
+ return {
+ id: nd.id,
+ code: nd.newCode || nd.code,
+ name: nd.name,
+ spec: nd.spec,
+ unit: nd.unit,
+ gov: nd.gov,
+ qty: n0(s.qty),
+ u: { m: n0(s.mUnit) / k, l: n0(s.lUnit) / k, s: n0(s.sUnit) / k },
+ amt: { mAmt: s.mAmt, lAmt: s.lAmt, sAmt: s.sAmt, tAmt: s.tAmt },
+ };
+ });
+ const direct = n0(kj.dT);
+ // 계약잡비율 = 계약 제잡비 항목 금액 ÷ 계약 직접공사비
+ const lines = kj.items
+ .filter((it) => n0(it.amount) !== 0)
+ .map((it) => ({ code: it.code, label: it.label, amount: n0(it.amount) }))
+ .concat([
+ { code: "ILBAN", label: "일반관리비", amount: n0(kj.ilbanAmt) },
+ { code: "IYUN", label: "이윤", amount: n0(kj.iyunFinal) },
+ ]);
+ const rates = Object.fromEntries(lines.map((l) => [l.code, direct ? l.amount / direct : 0]));
+ return { conv, kj, leaves, lines, rates, direct, nodes: conv.nodes, stage };
+ }
+
+ function amountsAt(lf, qty) {
+ const m = roundDown(lf.u.m * qty, 0),
+ l = roundDown(lf.u.l * qty, 0),
+ s = roundDown(lf.u.s * qty, 0);
+ return { mAmt: m, lAmt: l, sAmt: s, tAmt: m + l + s };
+ }
+ const sub = (a, b) => Object.fromEntries(KEYS.map((k) => [k, n0(a[k]) - n0(b[k])]));
+ const add = (a, b) => Object.fromEntries(KEYS.map((k) => [k, n0(a[k]) + n0(b[k])]));
+ const zero = () => Object.fromEntries(KEYS.map((k) => [k, 0]));
+ const pct = (a, b) => (b ? Math.round((a / b) * 1e5) / 1e3 : 0);
+
+ // 회차 하나의 제잡비·공급가액·부가세 — 금회직접 × 계약잡비율(항목별 버림)
+ function jabFor(base, cur, opt) {
+ const dT = cur.mAmt + cur.lAmt + cur.sAmt;
+ const items = base.lines.map((l) => ({
+ code: l.code,
+ label: l.label,
+ rate: base.rates[l.code],
+ amount:
+ l.code === "IYUN" && opt.iyunManual != null
+ ? n0(opt.iyunManual)
+ : roundDown(dT * base.rates[l.code], 0),
+ }));
+ let supply = dT + items.reduce((a, it) => a + it.amount, 0);
+ const sj = n0(opt.supplyJeol);
+ if (sj < 0) {
+ const cut = supply - roundDown(supply, sj);
+ const iy = items.find((it) => it.code === "IYUN");
+ if (iy) iy.amount -= cut;
+ supply -= cut;
+ }
+ const mode = opt.vatMode == null ? 1 : Number(opt.vatMode);
+ const vat =
+ mode === 0
+ ? n0(opt.vatManual)
+ : mode === 2
+ ? roundDown(cur.mAmt * 0.1, 0)
+ : mode === 3
+ ? roundDown((cur.mAmt + cur.sAmt) * 0.1, 0)
+ : roundDown(supply * 0.1, 0);
+ let total = supply + vat;
+ const tj = n0(opt.totalJeol);
+ let totalCut = 0;
+ if (tj < 0) {
+ totalCut = total - roundDown(total, tj);
+ const iy = items.find((it) => it.code === "IYUN");
+ if (iy) iy.amount -= totalCut;
+ supply -= totalCut;
+ total -= totalCut;
+ }
+ return { direct: dT, items, supply, vat, total, totalCut };
+ }
+
+ // 회차 실행 — rounds[k] = { qtyById: {id: 금회수량}, pct: 일괄 기성율(%), vatMode, vatManual, ... }
+ function run(base, rounds) {
+ const accQty = Object.fromEntries(base.leaves.map((lf) => [lf.id, 0]));
+ const accAmt = Object.fromEntries(base.leaves.map((lf) => [lf.id, zero()]));
+ let prevJab = null;
+ const out = [];
+ rounds.forEach((rd, k) => {
+ const rows = base.leaves.map((lf) => {
+ const q =
+ rd.qtyById && rd.qtyById[lf.id] != null
+ ? n0(rd.qtyById[lf.id])
+ : r4((lf.qty * n0(rd.pct)) / 100);
+ const prevQ = accQty[lf.id],
+ nowQ = r4(prevQ + q);
+ const prev = accAmt[lf.id],
+ acc = amountsAt(lf, nowQ),
+ cur = sub(acc, prev);
+ accQty[lf.id] = nowQ;
+ accAmt[lf.id] = acc;
+ return {
+ id: lf.id,
+ code: lf.code,
+ name: lf.name,
+ spec: lf.spec,
+ unit: lf.unit,
+ gov: lf.gov,
+ ctrQty: lf.qty,
+ prevQty: prevQ,
+ curQty: q,
+ accQty: nowQ,
+ remainQty: r4(lf.qty - nowQ),
+ qtyRate: pct(nowQ, lf.qty),
+ ctr: lf.amt,
+ prev,
+ cur,
+ acc,
+ prevRate: pct(prev.tAmt, lf.amt.tAmt),
+ curRate: pct(cur.tAmt, lf.amt.tAmt),
+ accRate: pct(acc.tAmt, lf.amt.tAmt),
+ };
+ });
+ const sumOf = (key) => rows.filter((r) => !r.gov).reduce((a, r) => add(a, r[key]), zero());
+ const totals = {
+ ctr: sumOf("ctr"),
+ prev: sumOf("prev"),
+ cur: sumOf("cur"),
+ acc: sumOf("acc"),
+ };
+ const jabCur = jabFor(base, totals.cur, rd);
+ const jabPrev = prevJab || {
+ direct: 0,
+ items: jabCur.items.map((it) => Object.assign({}, it, { amount: 0 })),
+ supply: 0,
+ vat: 0,
+ total: 0,
+ };
+ const jabAcc = {
+ direct: jabPrev.direct + jabCur.direct,
+ items: jabCur.items.map((it, i) =>
+ Object.assign({}, it, { amount: jabPrev.items[i].amount + it.amount }),
+ ),
+ supply: jabPrev.supply + jabCur.supply,
+ vat: jabPrev.vat + jabCur.vat,
+ total: jabPrev.total + jabCur.total,
+ };
+ prevJab = jabAcc;
+ const contractTotal = n0(base.kj.contractFinal);
+ out.push({
+ round: k + 1,
+ rows,
+ totals,
+ jab: { prev: jabPrev, cur: jabCur, acc: jabAcc },
+ contractTotal,
+ rate: {
+ prev: pct(jabPrev.total, contractTotal),
+ cur: pct(jabCur.total, contractTotal),
+ acc: pct(jabAcc.total, contractTotal),
+ },
+ });
+ });
+ return out;
+ }
+
+ // 집계수준 필터 — 머리 합계(전회·금회·누계·계약)를 얹고 고른 단 이상 머리만
+ function aggregate(base, round, minLevel = 0) {
+ const byId = new Map(round.rows.map((r) => [r.id, r]));
+ const nodes = base.nodes.map((nd) => {
+ const o = Object.assign({}, nd, { st: {} });
+ if (nd.kind === "leaf") {
+ const r = byId.get(nd.id);
+ for (const k of ["ctr", "prev", "cur", "acc"]) o.st[k] = r ? r[k] : zero();
+ o.row = r;
+ }
+ return o;
+ });
+ Change.sumHeads(nodes, ["ctr", "prev", "cur", "acc"]);
+ return nodes.filter((nd) =>
+ minLevel <= 0
+ ? nd.kind === "head" || nd.kind === "leaf"
+ : nd.kind === "head" && nd.level >= minLevel,
+ );
+ }
+
+ // 준공조서 — 계약금액 | 준공금액(최종 누계, 35 §3 미확인 규칙 = 마지막 회차 누계 이관)
+ function completion(base, runs) {
+ const last = runs[runs.length - 1];
+ return {
+ contract: n0(base.kj.contractFinal),
+ done: last ? last.jab.acc.total : 0,
+ direct: { contract: base.direct, done: last ? last.totals.acc.tAmt : 0 },
+ };
+ }
+
+ return { VAT_MODES, LEVELS, contractBase, run, aggregate, completion, jabFor };
+})();
+
+if (typeof window !== "undefined") window.STMATE_PROGRESS = StmateProgress;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateProgress;
diff --git a/tools/stmate-test/qty_qcode.py b/tools/stmate-test/qty_qcode.py
new file mode 100644
index 000000000..afecb53c6
--- /dev/null
+++ b/tools/stmate-test/qty_qcode.py
@@ -0,0 +1,93 @@
+"""STMATE 수량코드(QCODE.DBF · QCODE.SMT) 읽기·쓰기 — 수량산출서 기본 수량변수 원장.
+
+근거: 지식DB STmate/old/20_분석 02(QCODE 15필드 CODE·NAME·BQCODE·QTY0~9·IFORM·XCODE) ·
+28 §2(기본수량변수 ↔ 확장수량변수) · REA 30 quantity_variable_resolution(QTY0~9 직접 · XCODE 이름=값) ·
+SIxMemo_구조(메모 칸 10바이트
).
+"""
+
+from __future__ import annotations
+
+import struct
+
+from smt_parser import build_dbf_bytes, parse_dbf_bytes
+
+MEMO_FIELDS = ("IFORM", "XCODE")
+QTY_FIELDS = tuple(f"QTY{i}" for i in range(10))
+# 표본 STC 의 QCODE.DBF 필드 정의(빈 표 · 새 파일을 만들 때의 틀)
+DEFAULT_FIELDS = [
+ {"name": "CODE", "type": "C", "len": 6, "dec": 0},
+ {"name": "NAME", "type": "C", "len": 60, "dec": 0},
+ {"name": "BQCODE", "type": "C", "len": 16, "dec": 0},
+ *({"name": f, "type": "N", "len": 12, "dec": 4} for f in QTY_FIELDS),
+ {"name": "IFORM", "type": "M", "len": 10, "dec": 0},
+ {"name": "XCODE", "type": "M", "len": 10, "dec": 0},
+]
+
+
+def _memo_text(hex_ptr: str, smt: bytes) -> str:
+ raw = bytes.fromhex(hex_ptr or "")
+ if (
+ len(raw) < 10
+ or not smt
+ or smt[8:15] != b"SIxMemo"
+ or not any(b not in (0, 0x20) for b in raw)
+ ):
+ return ""
+ _tag, length, block = struct.unpack("= len(smt):
+ return ""
+ return smt[start : start + length].rstrip(b"\x00").decode("cp949", "replace")
+
+
+def read_qcode(files: dict[str, bytes]) -> dict:
+ """STC 파일 묶음 → {fields, rows} · 메모 칸(IFORM · XCODE)은 본문 글자로."""
+ dbf = files.get("QCODE.DBF", b"")
+ if len(dbf) < 32:
+ return {"fields": DEFAULT_FIELDS, "rows": [], "present": False}
+ fields, rows, _meta = parse_dbf_bytes(dbf)
+ smt = files.get("QCODE.SMT", b"")
+ out = []
+ for r in rows:
+ row = {f["name"]: r.get(f["name"], "") for f in fields}
+ for m in MEMO_FIELDS:
+ if m in row:
+ row[m] = _memo_text(r.get(f"{m}_HEX", ""), smt)
+ out.append(row)
+ return {
+ "fields": [{k: f[k] for k in ("name", "type", "len", "dec")} for f in fields],
+ "rows": out,
+ "present": True,
+ }
+
+
+def build_qcode(rows: list[dict], files: dict[str, bytes] | None = None) -> tuple[bytes, bytes]:
+ """행 목록 → (QCODE.DBF, QCODE.SMT) 바이트 · 원본 필드 정의가 있으면 그대로 씀."""
+ files = files or {}
+ fields, meta = DEFAULT_FIELDS, None
+ if len(files.get("QCODE.DBF", b"")) >= 32:
+ fields, _rows, meta = parse_dbf_bytes(files["QCODE.DBF"])
+ smt = bytearray(512)
+ smt[8:15] = b"SIxMemo"
+ recs = []
+ for r in rows:
+ rec = {}
+ for f in fields:
+ name, val = f["name"], r.get(f["name"], "")
+ if f.get("type") == "M" or name in MEMO_FIELDS:
+ text = str(val or "").replace("\r\n", "\n").replace("\n", "\r\n")
+ data = text.encode("cp949", "replace")
+ if not data:
+ rec[f"{name}_HEX"] = (b" " * int(f["len"])).hex()
+ continue
+ block = len(smt) // 64
+ smt.extend(data)
+ smt.extend(b"\x00" * ((-len(smt)) % 64))
+ rec[f"{name}_HEX"] = struct.pack(" {
+ const g = typeof globalThis !== "undefined" ? globalThis : {};
+ const C = g.STMATE_REPORT_CORE || require("./report_core.js");
+ const U = g.STMATE_REPORT_UNIT || require("./report_unit.js");
+ const B = g.STMATE_REPORT_BOQ || require("./report_boq.js");
+ const { num, str, code } = C;
+
+ /**
+ * 자재단가대비표(0105) — 재료비(M) 전수 · 가격지 5칸(단가·페이지) · 적용 단가와 적용 가격지 축약
+ * 가격지 이름 = Start8x.Ini JMOK1~6 · 축약 = JMK10~50 · 적용 슬롯 = COSTN PG_1
+ */
+ function priceCompareSheet(ctx) {
+ const { dbf, idx, ini, po } = ctx;
+ const sh = C.newSheet("자재단가대비표", "자재단가 대비표", C.projectName(dbf), "R");
+ const slots = ["E", "G", "I", "K", "M"];
+ const h3 = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격", D: "단위" };
+ const h4 = { _k: "head" };
+ slots.forEach((c, i) => {
+ h3[c] = str(ini[`JMOK${i + 1}`] || "").trim() || `가격${i + 1}`;
+ h4[c] = "단 가";
+ h4[String.fromCharCode(c.charCodeAt(0) + 1)] = "페이지";
+ });
+ Object.assign(h3, { O: str(ini.JMOK6 || "적용 단가").trim(), Q: "비 고", _k: "head" });
+ Object.assign(h4, { O: "단 가", P: "페이지" });
+ C.push(sh, h3);
+ C.push(sh, h4);
+ idx.ofPrefix("M").forEach((r, i) => {
+ const c = code(r);
+ const lbl = po.codeMode ? c : po.padNo ? String(i + 1).padStart(4) : String(i + 1);
+ const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
+ Object.assign(cells, { _k: "item", _code: c });
+ slots.forEach((col, j) => {
+ cells[col] = num(r[`AMT_${j + 1}1`]);
+ const pg = str(r[`PG_${j + 1}1`]).trim();
+ if (pg) cells[String.fromCharCode(col.charCodeAt(0) + 1)] = pg;
+ });
+ const p = idx.price(c, 1);
+ cells.O = p.M + p.L + p.S;
+ const s = num(r.PG_1);
+ if (s >= 1 && s <= 5) cells.P = str(ini[`JMK${s}0`] || "").trim();
+ const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
+ if (rm) cells.Q = rm;
+ C.push(sh, cells);
+ });
+ return sh;
+ }
+
+ // 시트 차례 — 원본 STMATE 엑셀 내보내기 순서(목차 번호)
+ const ORDER = [
+ "공사원가계산서",
+ "총괄내역서",
+ "설계내역서",
+ "일위대가목록표",
+ "일위대가표",
+ "단가산출근거목록표",
+ "환율및기초자료",
+ "중기목록표",
+ "중기사용료",
+ "재료비목록표",
+ "노무비목록표",
+ "경비목록표",
+ "자재단가대비표",
+ ];
+
+ // 목차 시트(〓 목 차 〓) — 시트 이름과 바로가기(엑셀에서 하이퍼링크)
+ function tocSheet(names, pname) {
+ const sh = { name: "〓 목 차 〓", title: "목 차", rows: [], cols: {} };
+ C.put(sh, 2, { B: "목 차", D: "※ 본 엑셀자료는 STMATE 재현 앱에서 만든 출력본입니다.", _k: "title" });
+ C.put(sh, 3, { D: ` 공사명 : ${pname}` });
+ ["※※안내※※"].concat(names).forEach((n, i) =>
+ C.put(sh, 4 + i, { B: `${i + 1}. ${n}`, D: `${n} →`, _link: n }),
+ );
+ return sh;
+ }
+
+ // 안내 시트(※※안내※※) — 원본 안내문 요지 + 금액 확인 줄
+ function guideSheet(k) {
+ const sh = { name: "※※안내※※", title: "안내", rows: [], cols: {} };
+ C.put(sh, 2, { A: "목차 →", B: "=== 엑셀파일에 작성에 대한 안내문(반드시 읽어보세요) ===", _link: "〓 목 차 〓" });
+ C.put(sh, 4, { B: "◈ 금액은 STMATE 재현 앱 계산 엔진(calc.js) 결과를 값으로 적었습니다." });
+ C.put(sh, 5, { B: "◈ 각 시트의 비목 열 순서는 설정(BMKNM)을 따릅니다." });
+ C.put(sh, 6, { B: "◈ [목차] 및 [안내] 시트의 이름을 바꾸지 마십시요." });
+ if (k) {
+ C.put(sh, 8, { B: "※※※ 아래의 내용(안내 및 오류)을 확인/검토 하세요 ※※※" });
+ C.put(sh, 9, { C: `【총괄내역서】 총공사비 = ${C.commas(k.grandTotal)}원 (이윤 보정액 ${C.commas(k.pretaxProfitAdj || 0)}원)` });
+ }
+ return sh;
+ }
+
+ /**
+ * 전체 묶음 — dbf(불러온 자료) · state(sim_state) · computed(calculateAll) · n 차수
+ * 돌려줌: { sheets:[시트 모형…], byName }
+ */
+ function buildBook(dbf, state, computed, n = 1, opts = {}) {
+ const unit = U.buildUnitSheets(dbf);
+ const boq = B.buildBoqSheets(dbf, state, computed, n, opts);
+ const pc = C.applyForm(priceCompareSheet(unit.ctx), dbf, "0105");
+ const all = Object.assign({}, unit, boq, { 자재단가대비표: pc });
+ const sheets = ORDER.filter((id) => all[id]).map((id) => Object.assign(all[id], { id }));
+ const names = sheets.map((s) => s.name);
+ const pname = C.projectName(dbf);
+ const book = [tocSheet(names, pname), guideSheet(boq.stage.kanjub)].concat(sheets);
+ for (const s of book) delete s._tree;
+ return { sheets: book, byName: Object.fromEntries(book.map((s) => [s.name, s])) };
+ }
+
+ // SET_REP 서식 → 시트 이름(구현된 것) — 없으면 null
+ const FORM_SHEET = {
+ "0101": "재료비목록표",
+ "0102": "노무비목록표",
+ "0103": "경비목록표",
+ "0105": "자재단가대비표",
+ "0201": "환율및기초자료",
+ "0202": "중기목록표",
+ "0203": "중기사용료",
+ "0301": "일위대가목록표",
+ "0302": "일위대가표",
+ "0401": "단가산출근거목록표",
+ "0601": "총괄내역서",
+ "0603": "설계내역서",
+ "0608": "공사원가계산서",
+ };
+
+ return { buildBook, priceCompareSheet, tocSheet, guideSheet, ORDER, FORM_SHEET };
+})();
+
+if (typeof window !== "undefined") window.STMATE_REPORT_BOOK = StmateReportBook;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateReportBook;
diff --git a/tools/stmate-test/report_boq.js b/tools/stmate-test/report_boq.js
new file mode 100644
index 000000000..95e41baf7
--- /dev/null
+++ b/tools/stmate-test/report_boq.js
@@ -0,0 +1,415 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 내역 보고서 (report_boq.js) — 설계내역서(0603) · 총괄내역서(0601) · 공사원가계산서(0608)
+ * 근거: 지식DB STmate/old/20_분석 20 §2.2(간접비 코드 순서 · 0원 항목 빠짐) · §2.6(DISP_EX 제외금액)
+ * 16 §4(BOQ 계층 T·E·C) · 06 §3(내역 절사) · 25 §2(간접비 식 설명)
+ * 정답 대조: resources/tester/test_stmate_report_boq.py (원본 STMATE 출력 엑셀)
+ * 금액 = 계산 엔진(calc.js calculateAll) 차수별 결과 · 구조(공종 계층·순서) = BOQ11.DBF
+ */
+const StmateReportBoq = (() => {
+ const C =
+ (typeof globalThis !== "undefined" && globalThis.STMATE_REPORT_CORE) ||
+ require("./report_core.js");
+ const { num, str, trunc, fix, commas } = C;
+ const PAIR = [
+ ["F", "G"],
+ ["H", "I"],
+ ["J", "K"],
+ ["L", "M"],
+ ];
+ const STRUCT = /^(T\d|E\d|C\d|:)$/;
+ const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" };
+ const rateText = (r) => String(Number(num(r).toFixed(5)));
+
+ /**
+ * 공종 나무 — SORTCODE 순 · T 행이 ENDCODE 의 E/C 행까지 묶음 · C 로 닫히면 제외금액 구역
+ * 묶음 금액 = 직속 품목 + 제외 아닌 하위 묶음 (원본 엑셀 SUMIF 와 같음)
+ * 비율 행(PR · 단위 %) = 같은 묶음의 표시(PRPT) 품목 금액 × 율 / 100 원 미만 절사
+ */
+ function boqTree(dbf, stageBoq, slot) {
+ const raw = ((dbf && dbf.boq_rows) || [])
+ .slice()
+ .sort((a, b) => num(a.SORTCODE) - num(b.SORTCODE));
+ const calcBy = {};
+ for (const r of (stageBoq && stageBoq.rows) || []) calcBy[r._recno] = r;
+ const root = { kind: "T", children: [], sum: { M: 0, L: 0, S: 0 }, end: null };
+ const stack = [root];
+ const out = [];
+ let finalRow = null;
+ for (const r of raw) {
+ const b = str(r.BDCODE1).trim();
+ const top = stack[stack.length - 1];
+ if (/^T\d$/.test(b)) {
+ const node = { kind: "T", row: r, level: Number(b[1]), children: [], parent: top };
+ Object.assign(node, { sum: { M: 0, L: 0, S: 0 }, end: num(r.ENDCODE), excluded: false });
+ top.children.push(node);
+ stack.push(node);
+ out.push(node);
+ continue;
+ }
+ if (/^[EC]\d$/.test(b)) {
+ const at = stack.findIndex((n) => n.end === num(r.SORTCODE));
+ if (at > 0) {
+ const node = stack[at];
+ if (b[0] === "C") node.excluded = true;
+ stack.length = at;
+ if (at === 1) finalRow = { kind: "final", node: root };
+ }
+ continue;
+ }
+ if (!b || b === ":") {
+ out.push({ kind: b === ":" ? "page" : "blank", row: r });
+ continue;
+ }
+ const item = { kind: "item", row: r, parent: top, code: b };
+ const unit = str(r.UNIT1).trim();
+ if (b === "PR" || /%$/.test(unit)) {
+ item.pct = true;
+ const inK = COMP[num(r.PRIN)] || "M",
+ outK = COMP[num(r.PROUT)] || (b[0] === "L" ? "L" : b[0] === "S" ? "S" : "M");
+ const sibs = top.children.filter((c) => c.kind === "item" && !c.pct);
+ const marked = sibs.filter((c) => str(c.row.PRPT).trim() === "1");
+ const base = (marked.length ? marked : sibs).reduce(
+ (s, c) => s + (inK === "T" ? c.amt.M + c.amt.L + c.amt.S : c.amt[inK]),
+ 0,
+ );
+ const q = num(r[`QTY${slot}`]) || num(r.QTY1);
+ item.qty = q;
+ item.unitP = { M: 0, L: 0, S: 0 };
+ item.amt = { M: 0, L: 0, S: 0 };
+ const k = outK === "T" ? "M" : outK;
+ item.unitP[k] = base;
+ item.amt[k] = trunc((base * q) / 100);
+ } else {
+ const c = calcBy[r._recno];
+ item.qty = c ? c.qty : num(r[`QTY${slot}`]);
+ item.unitP = c ? { M: c.mUnit, L: c.lUnit, S: c.sUnit } : { M: 0, L: 0, S: 0 };
+ item.amt = c ? { M: c.mAmt, L: c.lAmt, S: c.sAmt } : { M: 0, L: 0, S: 0 };
+ }
+ top.children.push(item);
+ out.push(item);
+ }
+ // 묶음 금액 — 아래에서 위로
+ const sumOf = (node) => {
+ const s = { M: 0, L: 0, S: 0 };
+ for (const ch of node.children) {
+ const v = ch.kind === "T" ? sumOf(ch) : ch.amt;
+ if (ch.kind === "T" && ch.excluded) continue;
+ for (const k of "MLS") s[k] += v[k];
+ }
+ node.sum = s;
+ return s;
+ };
+ sumOf(root);
+ return { out, root, finalRow };
+ }
+
+ // 품목 비고 — 코드 모드(SH_TYP1=0) 코드 · 호표 모드 = 대가·산근·중기는 「산근 n호표」 · 자원은 가격지 위치(BQCODE)
+ // (SH_TYP3=1 이면 자원도 「자재 n」) · 호표 번호 = 종류별 코드순 자리
+ function itemRemark(dbf, idx, b) {
+ const ini = (dbf && dbf.start_ini) || {};
+ if (b === "PR") return "";
+ if (String(ini.SH_TYP1 ?? "0") === "0") return b;
+ const r = idx.by[b];
+ if (!r) return "";
+ const kind = String(ini[`COD_FMT_${"LMSBXD".indexOf(b[0]) + 1}`] || "");
+ const no = idx.ofPrefix(b[0]).findIndex((x) => C.code(x) === b) + 1;
+ if ("BDX".includes(b[0])) return `${kind}${String(no).padStart(4)}호표`;
+ if (String(ini.SH_TYP3 ?? "2") === "1") return `${kind}${String(no).padStart(5)}`;
+ return str(r.BQCODE).trim();
+ }
+
+ // 설계내역서(0603) — 계(E)·총계(C) 줄은 묶음 머리 금액으로 올라가고 끝에 총합계 금액 줄
+ function designSheet(dbf, stage, opts = {}) {
+ const ini = (dbf && dbf.start_ini) || {};
+ const order = C.bimokOrder(ini);
+ const idx = C.costnIndex(dbf);
+ const name = opts.name || "설계내역서";
+ const sh = C.newSheet(name, "설 계 내 역 서", C.projectName(dbf), "O");
+ const h3 = { A: "공 종", B: "명 칭", C: "규 격", D: "수 량", E: "단위" };
+ Object.assign(h3, { N: "비 고", _k: "head" });
+ const h4 = { _k: "head" };
+ order.forEach((k, i) => {
+ h3[PAIR[i][0]] = C.bimokLabel(ini, k);
+ h4[PAIR[i][0]] = "단 가";
+ h4[PAIR[i][1]] = "금 액";
+ });
+ C.push(sh, h3);
+ C.push(sh, h4);
+ const tree = boqTree(dbf, stage.boq, stage.slot || 1);
+ const dispEx = `(${str(ini.DISP_EX || "제외금액").trim()})`;
+ const fill = (cells, unitP, amt, zeroUnit) => {
+ const u = { M: unitP.M, L: unitP.L, S: unitP.S, T: unitP.M + unitP.L + unitP.S };
+ const a = { M: amt.M, L: amt.L, S: amt.S, T: amt.M + amt.L + amt.S };
+ order.forEach((k, i) => {
+ cells[PAIR[i][0]] = zeroUnit ? 0 : fix(u[k]);
+ cells[PAIR[i][1]] = fix(a[k]);
+ });
+ };
+ // 쪽 넘김(:) — 빈 줄 하나에 쪽 나눔 표시(인쇄 미리보기에서 다음 쪽으로)
+ for (const n of tree.out) {
+ const r = n.row;
+ if (n.kind === "page") {
+ C.push(sh, { _k: "page" });
+ continue;
+ }
+ if (n.kind === "blank") {
+ C.push(sh, { F: 0, _k: "blank" });
+ continue;
+ }
+ const cells = { A: str(r.ITNUM).trim(), B: str(r.NAME1), C: str(r.SIZE1) };
+ if (n.kind === "T") {
+ Object.assign(cells, { _k: `T${n.level}` });
+ fill(cells, { M: 0, L: 0, S: 0 }, n.sum, true);
+ if (n.excluded) cells.N = dispEx;
+ } else {
+ Object.assign(cells, { D: fix(n.qty), E: C.unitText(r.UNIT1), _k: "item", _code: n.code });
+ fill(cells, n.unitP, n.amt, false);
+ const rm = itemRemark(dbf, idx, n.code);
+ if (rm) cells.N = rm;
+ }
+ C.push(sh, cells);
+ }
+ if (tree.finalRow) {
+ const cells = { _k: "final" };
+ const s = tree.finalRow.node.sum;
+ fill(cells, { M: 0, L: 0, S: 0 }, s, true);
+ for (const [u] of PAIR) delete cells[u];
+ C.push(sh, cells);
+ }
+ sh._tree = tree;
+ return sh;
+ }
+
+ // 간접비 항목 — 지식DB 20 §2.2 순서(KAN_ORD_1) · 이름 · 식 설명(25 §2)
+ const ITEMS = [
+ ["KANNO", "간접노무비", "간 접 노 무 비", "직접노무비"],
+ ["SANBO", "산재보험료", "산 재 보 험 료", "(직접노무비+간접노무비)"],
+ ["KOBO", "고용보험료", "고 용 보 험 료", "(직접노무비+간접노무비)"],
+ ["KUNBO", "건강보험료", "건 강 보 험 료", "직접노무비"],
+ ["SLVBO", "노인장기요양보험료", "노인장기요양보험료", "건강보험료"],
+ ["YUNBO", "연금보험료", "연 금 보 험 료", "직접노무비"],
+ ["TBUGUM", "퇴직공제부금비", "퇴직공제부금비", "직접노무비"],
+ ["ANJUN", "산업안전보건관리비", "산업안전보건관리비", ""],
+ ["KITA", "기타경비", "기 타 경 비", "(직접노무비+간접노무비+재료비)"],
+ ["HWAN", "환경보전비", "환 경 보 전 비", "(재료비 + 직접노무비 + 산출경비)"],
+ ["EQIP", "건설기계대여금지급보증 금액", "건설기계대여금지급보증 금액", "(재료비 + 직접노무비 + 산출경비)"],
+ ];
+
+ // 안전관리비 설명 — A) 관급재/1.1 포함 · B) 관급 빼고 1.2배 상한(지식DB 06)
+ function anjunNote(k) {
+ const it = k.itemMap.ANJUN;
+ if (!it) return "";
+ if (it.modeUsed === "fixed") return "";
+ const r = it.rate,
+ add = num(k._anjunAdd);
+ const noGov = k.dM + k.dL;
+ const a = trunc((k.anjunBaseRaw * r) / 100) + add;
+ const b = trunc((trunc((noGov * r) / 100) + add) * 1.2);
+ return (
+ `A)(직노+직재+간재+관급재/1.1)x${rateText(r)}% + ${commas(add)} = ${commas(a)}\n` +
+ `B)<(직노+직재+간재)x${rateText(r)}% + ${commas(add)}>의 1.2배 = ${commas(b)}`
+ );
+ }
+
+ /**
+ * 원가 줄 — 순공사비 → 간접비(0원 빠짐) → 소계 → 일반관리비 → 이윤 → 단계별 추가금액 → 부가세 → 도급 → 관급 → 총공사비
+ * fixedKeys = D_ 표시(저장액 고정) 항목 — 식 설명 없음
+ */
+ function costLines(k, cfg) {
+ const fixedKeys = new Set(Object.keys((cfg && cfg.fixed) || {}));
+ const govMat = k.govRawTotal + k.govFeeTotal;
+ const exp = [];
+ for (const [code, short, long, base] of ITEMS) {
+ const it = k.itemMap[code];
+ if (!it || !it.amount) continue;
+ let note = "";
+ if (!fixedKeys.has(code))
+ note = code === "ANJUN" ? anjunNote(k) : `${base} x ${rateText(it.rate)}%`;
+ exp.push({ code, short, long, amount: it.amount, rate: it.rate, note });
+ }
+ const adds = (st) => (k.adds || []).filter((a) => a.stage === st && num(a.amount));
+ return { exp, adds, govMat, fixedKeys };
+ }
+
+ // 총괄내역서(0601) — 공종 묶음 줄 + 원가 계산 줄
+ function summarySheet(dbf, stage, design, opts = {}) {
+ const ini = (dbf && dbf.start_ini) || {};
+ const order = C.bimokOrder(ini);
+ const cols = ["D", "E", "F", "G"];
+ const name = opts.name || "총괄내역서";
+ const sh = C.newSheet(name, "총 괄 내 역 서", C.projectName(dbf), "H");
+ const h3 = { A: "공 종", B: "명 칭", C: "규 격", _k: "head" };
+ order.forEach((k, i) => (h3[cols[i]] = C.bimokLabel(ini, k)));
+ C.push(sh, h3);
+ const minLevel = opts.minLevel ?? 2;
+ const put4 = (cells, s) => {
+ const v = { M: s.M, L: s.L, S: s.S, T: s.M + s.L + s.S };
+ order.forEach((k, i) => (cells[cols[i]] = v[k]));
+ };
+ for (const n of design._tree.out)
+ if (n.kind === "T" && n.level >= minLevel) {
+ const cells = { A: str(n.row.ITNUM).trim(), B: str(n.row.NAME1), C: str(n.row.SIZE1) };
+ put4(Object.assign(cells, { _k: `T${n.level}` }), n.sum);
+ C.push(sh, cells);
+ }
+ const k = stage.kanjub;
+ const L = costLines(k, stage.cfg);
+ const direct = design._tree.finalRow ? design._tree.finalRow.node.sum : { M: k.dM, L: k.dL, S: k.dS };
+ const ga = { A: " 가.", B: "순공사비계", _k: "sub" };
+ put4(ga, direct);
+ C.push(sh, ga);
+ let no = 0;
+ const line = (label, amount, note, extra) => {
+ no += 1;
+ const cells = { B: `${String(no).padStart(2)}. ${label}`, D: amount, _k: "cost" };
+ if (note) cells.E = note;
+ return C.push(sh, Object.assign(cells, extra || {}));
+ };
+ const sub = (a, label, amount) => C.push(sh, { A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" });
+ line("간접노무비", k.kanno, L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`);
+ for (const e of L.exp.filter((x) => x.code !== "KANNO"))
+ line(e.short, e.amount, e.note, e.code === "ANJUN" && L.govMat > 0 ? { C: "A)관급재/1.1포함 적용" } : null);
+ for (const a of L.adds(1)) line(a.name, num(a.amount));
+ const na = k.pureConstructionCost;
+ sub("나.", "소 계", na);
+ line("일반관리비", k.ilbanAmt, L.fixedKeys.has("ILBAN") ? "" : `(나.소 계) x ${rateText(num(stage.cfg?.rates?.ILBAN))}%`);
+ sub("다.", "소 계", na + k.ilbanAmt);
+ const iyunR = rateText(num(stage.cfg?.rates?.IYUN));
+ line("이 윤", k.iyunFinal, `(다.소계 - 재료비) x ${iyunR}% = ${commas(k.iyunRaw)}`);
+ for (const a of L.adds(2)) line(a.name, num(a.amount));
+ sub("라.", "공급가액", k.supplyFinal);
+ const vn = vatNote(stage.cfg);
+ line("부가가치세", k.vatFinal, vn === "총 원 가 x 10%" ? "공급가액 x 10%" : vn);
+ for (const a of L.adds(3)) line(a.name, num(a.amount));
+ sub("마.", "도급공사비", k.contractFinal);
+ if (k.govFinal)
+ line(govLabel(dbf, stage, false), k.govFinal, `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`);
+ for (const a of L.adds(4)) line(a.name, num(a.amount));
+ sub("바.", "총공사비", k.grandTotal);
+ return sh;
+ }
+
+ // 부가세 설명 — I_BUGA 갈래(공급가액 · 산림조합 · 면세품 · 재료비)
+ function vatNote(cfg) {
+ const m = (cfg && cfg.vatMode) || "supply_10";
+ if (m === "forest_exempt")
+ return `(재료비 - 면세품목:₩${commas(num(cfg.vatExempt))} + 산출경비) x 10%`;
+ if (m === "forest") return "(재료비 + 산출경비) x 10%";
+ if (m === "mat_10") return "재료비 x 10%";
+ return m === "zero" ? "" : "총 원 가 x 10%";
+ }
+
+ // 관급 이름 — 25 판부터 도급자 설치 관급(C_KWANGUP_D)은 「관급자재대(도급자설치)」(울진 2025 출력)
+ function govLabel(dbf, stage, wide) {
+ const ini = (dbf && dbf.start_ini) || {};
+ const kv = (dbf && dbf.prjlst_smt) || {};
+ const d = num(kv[`${stage.slot}_C_KWANGUP_D`]);
+ if (num(ini.PRG_V) >= 25 && d > 0) return "관급자재대(도급자설치)";
+ return wide ? "관 급 자 재 대" : "관급자재대";
+ }
+
+ // 원가계산서 줄 — 차수 하나의 { key, 칸, 금액, 설명 }
+ function wonRows(dbf, stage) {
+ const k = stage.kanjub;
+ const L = costLines(k, stage.cfg);
+ const rows = [];
+ const add = (key, cells, amount, note) => rows.push({ key, cells, amount, note: note || "" });
+ add("dM", { A: "순 공 사 원 가", B: "재 료 비", C: "직 접 재 료 비" }, k.dM);
+ add("iM", { C: "간 접 재 료 비" }, 0);
+ add("by", { C: "작업설,부산물등(△)" }, 0);
+ add("sM", { C: "소 계" }, k.dM);
+ add("dL", { B: "노 무 비", C: "직 접 노 무 비" }, k.dL);
+ const kn = L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`;
+ add("KANNO", { C: "간 접 노 무 비" }, k.kanno, kn);
+ add("sL", { C: "소 계" }, k.totalLabor);
+ add("dS", { B: "경 비", C: "산 출 경 비" }, k.dS);
+ for (const e of L.exp.filter((x) => x.code !== "KANNO")) {
+ const note = e.code === "ANJUN" && stage.cfg.anjunAuto === 2 && stage.slot > 1 ? "당초금액 + 안전관리비 증감액" : e.note;
+ add(e.code, { C: e.long }, e.amount, note);
+ }
+ for (const a of L.adds(1)) add(`A1${a.name}`, { C: a.name }, num(a.amount));
+ add("sS", { C: "소 계" }, k.totalExpense);
+ const ilR = rateText(num(stage.cfg?.rates?.ILBAN));
+ add("ILBAN", { A: "일 반 관 리 비" }, k.ilbanAmt, L.fixedKeys.has("ILBAN") ? "" : `순공사원가 x ${ilR}%`);
+ const iyR = rateText(num(stage.cfg?.rates?.IYUN));
+ add("IYUN", { A: "이 윤" }, k.iyunFinal, `(노무비+경비+일반관리비) x ${iyR}%`);
+ for (const a of L.adds(2)) add(`A2${a.name}`, { A: a.name }, num(a.amount));
+ add("SUP", { A: "총 원 가" }, k.supplyFinal);
+ add("VAT", { A: "부 가 가 치 세" }, k.vatFinal, vatNote(stage.cfg));
+ for (const a of L.adds(3)) add(`A3${a.name}`, { A: a.name }, num(a.amount));
+ add("CON", { A: "도 급 공 사 비" }, k.contractFinal);
+ if (k.govFinal) {
+ const gn = `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`;
+ add("GOV", { A: govLabel(dbf, stage, true) }, k.govFinal, gn);
+ }
+ for (const a of L.adds(4)) add(`A4${a.name}`, { A: a.name }, num(a.amount));
+ add("GRAND", { A: "총 공 사 비" }, k.grandTotal);
+ return rows;
+ }
+
+ /**
+ * 공사원가계산서(0608) — 비목 · 금액 · 구성비(총공사비 대비 %) · 비고(식 설명)
+ * base(당초 차수)를 주면 변경 양식 — 당초 · 변경 · 증감 · 구성비(변경) · 비고(변경)
+ */
+ function wonSheet(dbf, stage, base) {
+ const sh = C.newSheet("공사원가계산서", "공 사 원 가 계 산 서", C.projectName(dbf), base ? "I" : "G");
+ const now = wonRows(dbf, stage);
+ const grand = stage.kanjub.grandTotal || 1;
+ if (!base) {
+ C.put(sh, 3, { A: "구 분 ", D: "금 액", E: "구 성 비", F: "비 고", _k: "head" });
+ C.put(sh, 4, { A: " 비 목", _k: "head" });
+ now.forEach((r, i) => {
+ const c = Object.assign({ D: r.amount, E: (r.amount / grand) * 100, _k: "cost" }, r.cells);
+ if (r.note) c.F = r.note;
+ C.put(sh, 5 + i, c);
+ });
+ } else {
+ C.put(sh, 3, { A: "구 분 ", D: "금 액", G: "구 성 비", H: "비 고", _k: "head" });
+ C.put(sh, 4, { A: " 비 목", D: "당 초", E: "변 경", F: "증 감", _k: "head" });
+ const old = Object.fromEntries(wonRows(dbf, base).map((r) => [r.key, r]));
+ now.forEach((r, i) => {
+ const o = old[r.key] ? old[r.key].amount : 0;
+ const c = Object.assign({ D: o, E: r.amount, F: r.amount - o, _k: "cost" }, r.cells);
+ c.G = (r.amount / grand) * 100;
+ if (r.note) c.H = r.note;
+ C.put(sh, 5 + i, c);
+ });
+ }
+ const last = sh.rows[sh.rows.length - 1];
+ last[base ? "G" : "E"] = 100;
+ return sh;
+ }
+
+ /**
+ * 차수 맥락 — calc.js 결과(stages)에서 n 차수 내역·원가 · 설정(kanjubStages)
+ * 안전관리비 기초액(C_ANJUN_G)은 식 설명용
+ */
+ function stageOf(state, computed, n = 1) {
+ const st = (computed && computed.stages && computed.stages[n]) || {
+ boq: computed.boqOrig,
+ kanjub: computed.kanjubOrig,
+ };
+ const cfg = (state.kanjubStages && state.kanjubStages[n]) || state.kanjubConfig || {};
+ const kanjub = Object.assign({}, st.kanjub, { _anjunAdd: num(cfg.anjunBaseAdd) });
+ if (cfg.fixed && cfg.fixed.ANJUN != null && kanjub.itemMap.ANJUN)
+ kanjub.itemMap = Object.assign({}, kanjub.itemMap, {
+ ANJUN: Object.assign({}, kanjub.itemMap.ANJUN, { modeUsed: "fixed" }),
+ });
+ return { boq: st.boq, kanjub, cfg, slot: n };
+ }
+
+ function buildBoqSheets(dbf, state, computed, n = 1, opts = {}) {
+ const stage = stageOf(state, computed, n);
+ const design = C.applyForm(designSheet(dbf, stage, opts), dbf, "0603");
+ const summary = C.applyForm(summarySheet(dbf, stage, design, opts), dbf, "0601");
+ const base = n > 1 && opts.changeForm !== false ? stageOf(state, computed, 1) : null;
+ const won = C.applyForm(wonSheet(dbf, stage, base), dbf, "0608");
+ return { stage, 공사원가계산서: won, 총괄내역서: summary, 설계내역서: design };
+ }
+
+ return { buildBoqSheets, boqTree, designSheet, summarySheet, wonSheet, stageOf, ITEMS };
+})();
+
+if (typeof window !== "undefined") window.STMATE_REPORT_BOQ = StmateReportBoq;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateReportBoq;
diff --git a/tools/stmate-test/report_core.js b/tools/stmate-test/report_core.js
new file mode 100644
index 000000000..4e2cf309f
--- /dev/null
+++ b/tools/stmate-test/report_core.js
@@ -0,0 +1,256 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 보고서 공통 (report_core.js) — 시트 모형 · 열 순서(BMKNM) · 단가 조회 · 구성행 줄별 계산
+ * 근거: 지식DB STmate/old/20_분석 20(설정파일·보고서 카탈로그 §2 · §3) · 17(단가조립) · 06 §3(출력별 반올림)
+ * 정답 대조: resources/tester/test_stmate_report_sheets.py (원본 STMATE 출력 엑셀)
+ * 시트 모형 = { name, title, cols:{A:폭}, rows:[{A:값,…, _k:종류}] } — rows[i] = 엑셀 i+1 행
+ */
+const StmateReportCore = (() => {
+ const num = (v, d = 0) => {
+ if (v === null || v === undefined || v === "") return d;
+ const n = Number(String(v).replace(/,/g, "").trim());
+ return Number.isFinite(n) ? n : d;
+ };
+ // 절사 = 소수 d 자리 아래 버림(이진 오차 보정)
+ const trunc = (v, d = 0) => {
+ const f = 10 ** d,
+ s = v * f;
+ return Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)) / f;
+ };
+ // 사사오입
+ const half = (v, d = 0) => {
+ const f = 10 ** d,
+ r = Math.floor(Math.abs(v) * f + 0.5 + 1e-9) / f;
+ return v < 0 ? -r : r;
+ };
+ const roundUp = (v, d = 0) => {
+ const f = 10 ** d,
+ s = v * f;
+ return (v > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9)) / f;
+ };
+ const fix = (v, d = 6) => half(v, d); // 표시용 이진 오차 걷기
+ const str = (v) => String(v ?? "").trimEnd();
+ // 단위 표시 — 중기 단위 X = 시간 · 비율 단위 M%·L%·S%·T% = %
+ const unitText = (u) => {
+ const t = String(u ?? "").trim();
+ return t === "X" ? "시간" : /^[MLST]%$/.test(t) ? "%" : t;
+ };
+ const code = (r) => String((r && (r.CODE || r.BDCODE1)) || "").trim();
+ const commas = (v, d = 0) =>
+ new Intl.NumberFormat("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }).format(
+ v,
+ );
+
+ // 비목 열 순서 — Start8x.Ini BMKNM(기본 TLMS = 합계·노무·재료·경비) · 머리글 BMK_STR1~4
+ function bimokOrder(ini) {
+ const s = String((ini && ini.BMKNM) || "TLMS").toUpperCase();
+ const order = s.split("").filter((c) => "TLMS".includes(c));
+ return order.length === 4 ? order : ["T", "L", "M", "S"];
+ }
+ function bimokLabel(ini, k) {
+ const i = { M: 1, L: 2, S: 3, T: 4 }[k];
+ const dflt = { M: "재 료 비", L: "노 무 비", S: "경 비", T: "합 계" }[k];
+ return String((ini && ini[`BMK_STR${i}`]) || dflt);
+ }
+
+ // 시트 머리 — 1행 제목 · 2행 공사명 · 오른쪽 끝 시트 이름표
+ function newSheet(name, title, pname, tagCol) {
+ const rows = [{ A: title, _k: "title" }, { A: `공사명 : ${pname}`, _k: "pname" }];
+ if (tagCol) rows[1][tagCol] = name;
+ return { name, title, rows, cols: {} };
+ }
+ const put = (sh, r, cells) => {
+ while (sh.rows.length < r) sh.rows.push({});
+ Object.assign(sh.rows[r - 1], cells);
+ return sh.rows[r - 1];
+ };
+ const push = (sh, cells) => (sh.rows.push(cells), sh.rows.length);
+
+ /**
+ * 원가 자료 묶음 — 불러온 자료(dbfData) 기준 단가 사전
+ * 낙찰률 단가(NAGCD>0) = 1칸 · 그 밖 = 1칸(설계 단가) — 계산 엔진이 갱신한 COSTN 값을 그대로 읽음
+ */
+ function costnIndex(dbf) {
+ const by = {};
+ for (const r of (dbf && dbf.costn_rows) || []) by[code(r)] = r;
+ const price = (c, slot = 1) => {
+ const r = by[c];
+ if (!r) return null;
+ return {
+ M: num(r[`MAMT${slot}`]),
+ L: num(r[`LAMT${slot}`]),
+ S: num(r[`SAMT${slot}`]),
+ unit: str(r.UNIT).trim(),
+ unitDisp: unitText(r.UNIT),
+ name: str(r.NAME),
+ spec: str(r.SIZE),
+ row: r,
+ };
+ };
+ const ofPrefix = (p) =>
+ Object.keys(by)
+ .filter((c) => c[0] === p)
+ .sort()
+ .map((c) => by[c]);
+ return { by, price, ofPrefix };
+ }
+
+ /**
+ * 구성행 줄별 계산 — 일위대가표(0302)·중기사용료(0203) 출력 칸 그대로
+ * 줄 금액 = 단가 × 수량 0.1원 절사 · 총액우선(CHBNM=0)이면 줄마다 0.1원 끝수를 성분에 나눔
+ * 끝수 배분 = 소수 셋째 자리까지 남은 끝수가 큰 성분부터 · 같으면 OPTNM 순서 앞(S·M·L)
+ * 합계 = 줄 합계의 원 미만 절사 · 총액우선이면 원 끝수도 같은 규칙
+ */
+ const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" };
+ function spread(parts, total, step, order, fracDigits) {
+ const d = step === 0.1 ? 1 : 0;
+ const base = {},
+ frac = {};
+ for (const k of "MLS") {
+ base[k] = trunc(parts[k], d);
+ frac[k] = half(trunc(parts[k], fracDigits) - base[k], fracDigits);
+ }
+ const diff = trunc(half(trunc(total, d) - (base.M + base.L + base.S), fracDigits), d);
+ const n = Math.round(Math.abs(diff) / step);
+ const sign = diff < 0 ? -1 : 1;
+ const adj = { M: 0, L: 0, S: 0 };
+ if (!n) return adj;
+ const ord = order.filter((k) => "MLS".includes(k));
+ for (const k of ord) {
+ const ahead = ord.filter((o) =>
+ sign > 0 ? frac[o] > frac[k] : frac[o] < frac[k],
+ ).length;
+ const tieAhead = ord.slice(0, ord.indexOf(k)).filter((o) => frac[o] === frac[k]).length;
+ if (ahead + tieAhead < n) adj[k] = sign * step;
+ }
+ return adj;
+ }
+
+ function compositionLines(rows, lookup, opts = {}) {
+ const order = String(opts.order || "SML").toUpperCase().split("");
+ const lines = [];
+ const marked = { M: 0, L: 0, S: 0 };
+ const sum = { M: 0, L: 0, S: 0 };
+ let closed = false;
+ const hourly = (daily) => {
+ let acc = daily;
+ for (const [, op, n] of String(opts.rx || "")
+ .replace(/\s+/g, "")
+ .matchAll(/([*/])?(\d+(?:\.\d+)?)/g))
+ acc = op === "/" ? acc / Number(n) : acc * Number(n);
+ return trunc(acc);
+ };
+ for (const row of rows) {
+ const c = String(row.CODE || "").trim(),
+ q = num(row.QTY1);
+ if (c === "PR") {
+ // 비율 줄 — 표시(PRPT) 줄의 밑수 비목(PRIN) 합 × 율 → 대상 비목(PROUT)
+ const bk = COMP[row.PRIN],
+ ok = COMP[row.PROUT] === "T" ? "M" : COMP[row.PROUT];
+ if (!bk || !ok) continue;
+ const bv = bk === "T" ? marked.M + marked.L + marked.S : marked[bk];
+ const u = { M: 0, L: 0, S: 0 },
+ a = { M: 0, L: 0, S: 0 };
+ u[ok] = fix(bv);
+ a[ok] = q ? trunc((bv * q) / 100, 1) : 0;
+ sum[ok] += a[ok];
+ lines.push({ code: "PR", pct: true, qty: q, unit: "%", u, a, marked: false });
+ continue;
+ }
+ if (/^C\d$/.test(c)) closed = true;
+ // 글 줄 — 코드 없는 설명 줄(EXIST1=1)
+ if (!c && String(row.EXIST1 || "").trim() === "1") {
+ lines.push({ code: "", text: true, qty: 0, u: { M: 0, L: 0, S: 0 }, a: { M: 0, L: 0, S: 0 } });
+ continue;
+ }
+ const r = lookup(c);
+ if (!r) continue;
+ const unit = String(r.unit || "").trim();
+ const own = c[0] === "L" ? "L" : c[0] === "S" ? "S" : "M";
+ const pct = unit.match(/^([MLST])%$/);
+ const u = { M: 0, L: 0, S: 0 },
+ a = { M: 0, L: 0, S: 0 };
+ if (pct) {
+ if (closed) continue; // 닫힌 뒤 % = 앞 금액 전체 비율 — 줄 없음
+ const bv = pct[1] === "T" ? sum.M + sum.L + sum.S : sum[pct[1]];
+ u[own] = fix(bv);
+ a[own] = q ? trunc((bv * q) / 100, 1) : 0;
+ sum[own] += a[own];
+ lines.push({ code: c, ref: r, pct: true, qty: q, unit, u, a, marked: false });
+ continue;
+ }
+ let p = { M: r.M, L: r.L, S: r.S };
+ if (opts.labor && opts.labor.has(c)) p = { M: 0, L: hourly(r.L), S: 0 };
+ if (unit === "$") for (const k of "MLS") p[k] = (p[k] * num(opts.usdRate)) / 1000;
+ const raw = { M: q * p.M, L: q * p.L, S: q * p.S };
+ for (const k of "MLS") a[k] = q ? trunc(raw[k], 1) : 0;
+ if (opts.totalFirst && q) {
+ const adj = spread(raw, q * (p.M + p.L + p.S), 0.1, order, 3);
+ for (const k of "MLS") a[k] = fix(a[k] + adj[k], 1);
+ }
+ const isMarked =
+ String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== "";
+ for (const k of "MLS") {
+ sum[k] += a[k];
+ if (isMarked) marked[k] += a[k];
+ }
+ lines.push({ code: c, ref: r, qty: q, unit, u: p, a, marked: isMarked, labor: !!opts.labor?.has(c) });
+ }
+ const raw = { M: fix(sum.M, 4), L: fix(sum.L, 4), S: fix(sum.S, 4) };
+ const tot = { M: trunc(raw.M), L: trunc(raw.L), S: trunc(raw.S) };
+ if (opts.totalFirst) {
+ const adj = spread(raw, raw.M + raw.L + raw.S, 1, order, 2);
+ for (const k of "MLS") tot[k] += adj[k];
+ }
+ let T = tot.M + tot.L + tot.S;
+ const sbmokS = String(opts.sbmok || "").trim() === "S";
+ if (sbmokS) Object.assign(tot, { M: 0, L: 0, S: T });
+ return { lines, raw, tot, T, sbmokS, totalFirst: !!opts.totalFirst };
+ }
+
+ /**
+ * SET_REP.DBF 서식 제목 적용(지식DB 20 §3) — 제목 = HEADTITLE · 시트 이름 = 제목에서 빈칸 뺌
+ * 서식 번호(INDX)는 old/30_원자료/print_form_map.txt
+ */
+ function applyForm(sh, dbf, indx) {
+ const r = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx);
+ const title = r ? String(r.HEADTITLE || "").trim() : "";
+ if (!sh || !title) return sh;
+ const name = title.replace(/\s+/g, "");
+ sh.title = title;
+ sh.indx = indx;
+ if (sh.rows[0] && sh.rows[0]._k === "title") sh.rows[0].A = title;
+ const tag = sh.rows[1] && Object.keys(sh.rows[1]).find((k) => sh.rows[1][k] === sh.name);
+ if (tag) sh.rows[1][tag] = name;
+ sh.name = name;
+ return sh;
+ }
+
+ // 공사명 — PRJLST.DBF PNAME
+ const projectName = (dbf) => str((dbf && dbf.prjlst && dbf.prjlst.PNAME) || "");
+
+ return {
+ num,
+ trunc,
+ half,
+ roundUp,
+ fix,
+ str,
+ code,
+ unitText,
+ commas,
+ bimokOrder,
+ bimokLabel,
+ newSheet,
+ put,
+ push,
+ costnIndex,
+ compositionLines,
+ spread,
+ projectName,
+ applyForm,
+ };
+})();
+
+if (typeof window !== "undefined") window.STMATE_REPORT_CORE = StmateReportCore;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateReportCore;
diff --git a/tools/stmate-test/report_unit.js b/tools/stmate-test/report_unit.js
new file mode 100644
index 000000000..2ddc25421
--- /dev/null
+++ b/tools/stmate-test/report_unit.js
@@ -0,0 +1,293 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 단가 보고서 (report_unit.js) — 목록표(0101~0103 · 0202 · 0301 · 0401) · 일위대가표(0302) · 중기사용료(0203) · 환율및기초자료(0201)
+ * 근거: 지식DB STmate/old/20_분석 20 §2.4(코드 종류) · §3(SET_REP 서식) · 17(단가조립) · old/30_원자료/print_form_map.txt
+ * 정답 대조: resources/tester/test_stmate_report_sheets.py (원본 STMATE 출력 엑셀 셀 값)
+ */
+const StmateReportUnit = (() => {
+ const C =
+ (typeof globalThis !== "undefined" && globalThis.STMATE_REPORT_CORE) ||
+ require("./report_core.js");
+ const { num, str, code, fix } = C;
+
+ // 비목 열 — 목록표 E~H · 일위대가표 (E,F)~(K,L) 쌍
+ const LIST_COLS = ["E", "F", "G", "H"];
+ const PAIR_COLS = [
+ ["E", "F"],
+ ["G", "H"],
+ ["I", "J"],
+ ["K", "L"],
+ ];
+
+ function iniOf(dbf) {
+ return (dbf && dbf.start_ini) || {};
+ }
+ const slotOf = (r) => (num(r && r.NAGCD) > 0 ? 2 : 1);
+
+ // 구성 조립 설정 — Start8x.Ini(CHBNM · OPTNM · CONLD · RXXNM · EXLNM1)
+ function compOptions(dbf, parent) {
+ const ini = iniOf(dbf);
+ const p = code(parent);
+ return {
+ totalFirst: p[0] === "B" && String(ini.CHBNM ?? "1") === "0",
+ order: ini.OPTNM || "SML",
+ sbmok: str(parent.SBMOK).trim(),
+ labor: p[0] === "X" ? new Set(String(ini.CONLD || "").match(/.{1,6}/g) || []) : null,
+ rx: ini.RXXNM || "",
+ usdRate: num(ini.EXLNM1),
+ };
+ }
+
+ function groupsOf(dbf) {
+ const g = {};
+ for (const r of (dbf && dbf.bdqty_rows) || []) (g[r.BDCODE] = g[r.BDCODE] || []).push(r);
+ return g;
+ }
+
+ // 코드 단가 조회 — 낙찰률 품목은 원단가(2칸)로 조립(지식DB 17 · smt_engine 과 같음)
+ function lookupOf(idx) {
+ return (c) => {
+ const r = idx.by[c];
+ return r ? idx.price(c, slotOf(r)) : null;
+ };
+ }
+
+ /**
+ * 출력 설정 — Start8x.Ini
+ * SH_TYP1 = 0 이면 첫 칸 코드 · 그 밖 호표 번호 / SH_TYP3 = 1 이면 비고에 호표 · 그 밖 가격지 위치(BQCODE)
+ * CH_PRN_COD = 1 이면 번호를 자리 맞춤(중기 「제 n호표」 · 자원 「 n」)
+ * 줄 비고 종류 이름 = COD_FMT_1~6(노무·자재·경비·대가·중기·산근 · 지식DB 20 §2.4)
+ */
+ function printOptions(ini) {
+ const kind = {};
+ "LMSBXD".split("").forEach((p, i) => (kind[p] = String(ini[`COD_FMT_${i + 1}`] || "")));
+ return {
+ codeMode: String(ini.SH_TYP1 ?? "0") === "0",
+ remarkHopyo: String(ini.SH_TYP3 ?? "2") === "1",
+ padNo: String(ini.CH_PRN_COD ?? "0") === "1",
+ kind,
+ };
+ }
+ const pad = (n, w) => String(n).padStart(w);
+ // 호표 비고 글 — 대가·산근 「대가 n호표」 · 그 밖 「노무 n」
+ const kindNo = (po, p, n) =>
+ p === "B" || p === "D" ? `${po.kind[p]}${pad(n, 4)}호표` : `${po.kind[p] || ""}${pad(n, 5)}`;
+ // 목록 첫 칸 호표 글 — 일위대가·산근 「제 n호표」 · 중기·자원 번호
+ function hopyoLabel(po, prefix, n) {
+ if (prefix === "B" || prefix === "D" || (prefix === "X" && po.padNo)) return `제${pad(n, 4)}호표`;
+ return po.padNo ? pad(n, 4) : String(n);
+ }
+
+ // 목록표 — 코드 종류(M·L·S·B·D·X)의 COSTN 전수 · 코드순
+ function listSheet(ctx, name, title, prefix) {
+ const { dbf, idx, ini, order, po } = ctx;
+ const sh = C.newSheet(name, title, C.projectName(dbf), "J");
+ const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" };
+ Object.assign(head, { D: "단위", I: "비 고", _k: "head" });
+ order.forEach((k, i) => (head[LIST_COLS[i]] = C.bimokLabel(ini, k)));
+ C.push(sh, head);
+ idx.ofPrefix(prefix).forEach((r, i) => {
+ const c = code(r);
+ const tot = ctx.totals[c] || idx.price(c, 1);
+ const lbl = po.codeMode ? c : hopyoLabel(po, prefix, i + 1);
+ const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
+ Object.assign(cells, { _k: "item", _code: c });
+ const v = { M: tot.M, L: tot.L, S: tot.S, T: tot.M + tot.L + tot.S };
+ order.forEach((k, j) => (cells[LIST_COLS[j]] = v[k]));
+ const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
+ if (rm) cells.I = rm;
+ C.push(sh, cells);
+ });
+ return sh;
+ }
+
+ // 자원 목록표(0101~0103) — 단가 한 칸
+ function resourceSheet(ctx, name, title, prefix) {
+ const { dbf, idx, po } = ctx;
+ const sh = C.newSheet(name, title, C.projectName(dbf), "G");
+ const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" };
+ C.push(sh, Object.assign(head, { D: "단위", E: "단 가", F: "비 고", _k: "head" }));
+ idx.ofPrefix(prefix).forEach((r, i) => {
+ const c = code(r);
+ const p = idx.price(c, 1);
+ const lbl = po.codeMode ? c : hopyoLabel(po, prefix, i + 1);
+ const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
+ Object.assign(cells, { E: fix(p.M + p.L + p.S), _k: "item", _code: c });
+ const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
+ if (rm) cells.F = rm;
+ C.push(sh, cells);
+ });
+ return sh;
+ }
+
+ // 줄 비고 — 코드(뒤 한 칸) · 호표(종류 이름 + 번호 5자리) · 가격지 위치 · 단가 0 이면 「별산」(SET_REP 0302 FLD_N3)
+ function lineRemark(ctx, ref, c, pct) {
+ const { po, hopyoNo } = ctx;
+ const zero = !pct && ref && ref.M + ref.L + ref.S === 0 ? "별산" : "";
+ if (po.codeMode) return `${zero}${c} `;
+ if (po.remarkHopyo) return `${zero}${kindNo(po, c[0], hopyoNo[c] || "")} `;
+ return ref && ref.row ? str(ref.row.BQCODE).trim() : "";
+ }
+
+ /**
+ * 구성 호표 시트 — 일위대가표(B · 0302) · 중기사용료(X · 0203)
+ * 호표 머리 → 구성 줄(단가·금액 쌍) → 합계(합계 줄 원 미만 절사 · 경비 지정이면 경비 한 칸)
+ * 총액우선이면 합계 줄에 끝수 전 합(단가 칸)도 적음 · 글 줄(코드 없음)은 수량 0 빈 줄(명칭은 BDQTY 암호 칸)
+ */
+ function compositionSheet(ctx, name, title, prefix) {
+ const { dbf, idx, ini, order, po, hopyoNo } = ctx;
+ const isB = prefix === "B";
+ const sh = C.newSheet(name, title, C.projectName(dbf), "N");
+ const h3 = { A: "명 칭", B: "규 격", C: "수 량", D: "단위", M: "비 고", _k: "head" };
+ const h4 = { _k: "head" };
+ order.forEach((k, i) => {
+ h3[PAIR_COLS[i][0]] = C.bimokLabel(ini, k);
+ h4[PAIR_COLS[i][0]] = "단 가";
+ h4[PAIR_COLS[i][1]] = "금 액";
+ });
+ C.push(sh, h3);
+ C.push(sh, h4);
+ let no = 0;
+ for (const r of idx.ofPrefix(prefix)) {
+ const c = code(r);
+ no += 1;
+ const wide = isB || po.padNo;
+ C.push(sh, { A: wide ? ` 제${pad(no, 5)} 호표` : `제 ${no} 호표`, _k: "hopyo" });
+ const hd = { A: str(r.NAME), B: str(r.SIZE), D: C.unitText(r.UNIT), _k: "parent", _code: c };
+ const rm = po.codeMode
+ ? c
+ : po.remarkHopyo
+ ? `${po.kind[prefix]}${pad(no, 5)}`
+ : str(r.BQCODE).trim();
+ if (rm) hd.M = rm;
+ C.push(sh, hd);
+ const ev = ctx.evals[c];
+ if (!ev) continue;
+ for (const ln of ev.lines) {
+ if (ln.text) {
+ C.push(sh, { C: 0, _k: "text", M: po.remarkHopyo && !po.codeMode ? " " : undefined });
+ continue;
+ }
+ const ref = ln.ref;
+ const cells = { C: ln.qty, D: ln.unit, _k: "line", _code: ln.code };
+ if (ref) Object.assign(cells, { A: ref.name, B: ref.spec, D: ref.unitDisp });
+ if (ln.pct) cells.D = "%";
+ const u = { M: ln.u.M, L: ln.u.L, S: ln.u.S };
+ u.T = fix(u.M + u.L + u.S);
+ const a = { M: ln.a.M, L: ln.a.L, S: ln.a.S };
+ a.T = fix(a.M + a.L + a.S, 1);
+ order.forEach((k, i) => {
+ cells[PAIR_COLS[i][0]] = fix(u[k]);
+ cells[PAIR_COLS[i][1]] = fix(a[k], 1);
+ });
+ if (ln.code !== "PR") {
+ const rm2 = lineRemark(ctx, ref, ln.code, ln.pct);
+ if (rm2) cells.M = rm2;
+ }
+ C.push(sh, cells);
+ }
+ const tot = { A: ev.sbmokS ? "합 계(경비 로적용)" : "합 계", _k: "total", _code: c };
+ const raw = Object.assign({}, ev.raw, { T: fix(ev.raw.M + ev.raw.L + ev.raw.S, 4) });
+ const fin = Object.assign({}, ev.tot, { T: ev.T });
+ order.forEach((k, i) => {
+ if (ev.totalFirst) tot[PAIR_COLS[i][0]] = fix(raw[k], 4);
+ tot[PAIR_COLS[i][1]] = fin[k];
+ });
+ C.push(sh, tot);
+ }
+ return sh;
+ }
+
+ // 환율및기초자료(0201) — 환율 · 노임 시간 환산(CONLD · RXXNM) · 중기 구성에 쓰인 재료
+ function basicSheet(ctx) {
+ const { dbf, idx, ini, groups, po, hopyoNo } = ctx;
+ const rmk = (c) => (po.remarkHopyo && !po.codeMode ? kindNo(po, c[0], hopyoNo[c]) : c);
+ const sh = { name: "환율및기초자료", title: "1. 환 율", rows: [], cols: {} };
+ C.put(sh, 1, { A: "1. 환 율", _k: "title" });
+ C.put(sh, 2, { C: "100,000$ 미만", D: "100,000$ 이상", E: "유로화(€)", G: "엔화(100¥)" });
+ sh.rows[1].I = "환율및기초자료";
+ const e3 = { C: num(ini.EXLNM1), D: num(ini.EXHNM1), E: num(ini.EXEUR1), G: num(ini.EXYEN1) };
+ C.put(sh, 3, e3);
+ C.put(sh, 5, { A: "2. 인 건 비" });
+ const labor = String(ini.CONLD || "").match(/.{1,6}/g) || [];
+ const rx = String(ini.RXXNM || "");
+ let r = 6;
+ const lab = labor.filter((c) => idx.by[c]);
+ for (const c of lab) {
+ const p = idx.price(c, 1);
+ const daily = p.M + p.L + p.S;
+ let acc = daily;
+ for (const [, op, n] of rx.replace(/\s+/g, "").matchAll(/([*/])?(\d+(?:\.\d+)?)/g))
+ acc = op === "/" ? acc / Number(n) : acc * Number(n);
+ C.put(sh, r++, { C: p.name, D: daily, E: `* ${rx} =`, G: C.trunc(acc), H: rmk(c), _code: c });
+ }
+ r += 1;
+ C.put(sh, r++, { A: "3. 단가 및 재료비" });
+ C.put(sh, r++, { B: "NO", C: "단 가 명", D: "규 격", E: "단위", F: "가 격", H: "비 고" });
+ // 재료 = 중기(X) 구성에 쓰인 재료비(M) 코드 · 코드순
+ const mats = new Set();
+ for (const [p, rows] of Object.entries(groups))
+ if (p[0] === "X")
+ for (const g of rows) {
+ const c = String(g.CODE).trim();
+ if (/^M/.test(c) && idx.by[c] && !/%$/.test(str(idx.by[c].UNIT).trim())) mats.add(c);
+ }
+ const items = lab.concat([...mats].filter((c) => idx.by[c]).sort());
+ items.forEach((c, i) => {
+ const p = idx.price(c, 1);
+ const cells = { B: String(i + 1), C: p.name, E: p.unitDisp, F: p.M + p.L + p.S, H: rmk(c), _code: c };
+ if (p.spec.trim()) cells.D = p.spec;
+ C.put(sh, r++, cells);
+ });
+ return sh;
+ }
+
+ /**
+ * 단가 맥락 — 구성 호표(B·X) 줄별 계산 결과와 목록표 합계
+ * 구성 없는 호표 = COSTN 단가 그대로
+ */
+ function unitContext(dbf) {
+ const idx = C.costnIndex(dbf);
+ const ini = iniOf(dbf);
+ const order = C.bimokOrder(ini);
+ const groups = groupsOf(dbf);
+ const lookup = lookupOf(idx);
+ const evals = {},
+ totals = {};
+ for (const p of ["X", "B"])
+ for (const r of idx.ofPrefix(p)) {
+ const c = code(r);
+ if (!groups[c]) continue;
+ const ev = C.compositionLines(groups[c], lookup, compOptions(dbf, r));
+ evals[c] = ev;
+ totals[c] = ev.tot;
+ }
+ // 호표 번호 = 종류별 코드순 자리(1부터)
+ const hopyoNo = {};
+ for (const p of "LMSBXD") idx.ofPrefix(p).forEach((r, i) => (hopyoNo[code(r)] = i + 1));
+ const po = printOptions(ini);
+ return { dbf, idx, ini, order, groups, evals, totals, hopyoNo, po };
+ }
+
+ function buildUnitSheets(dbf) {
+ const ctx = unitContext(dbf);
+ const F = (sh, indx) => C.applyForm(sh, dbf, indx);
+ return {
+ ctx,
+ 일위대가목록표: F(listSheet(ctx, "일위대가목록표", "일위대가 목록표", "B"), "0301"),
+ 일위대가표: F(compositionSheet(ctx, "일위대가표", "일 위 대 가 표", "B"), "0302"),
+ 단가산출근거목록표: F(listSheet(ctx, "단가산출근거목록표", "단가산출근거 목록표", "D"), "0401"),
+ 환율및기초자료: basicSheet(ctx),
+ 중기목록표: F(listSheet(ctx, "중기목록표", "중기 목록표", "X"), "0202"),
+ 중기사용료: F(compositionSheet(ctx, "중기사용료", "중 기 사 용 료", "X"), "0203"),
+ 재료비목록표: F(resourceSheet(ctx, "재료비목록표", "재료비 목록표", "M"), "0101"),
+ 노무비목록표: F(resourceSheet(ctx, "노무비목록표", "노무비 목록표", "L"), "0102"),
+ 경비목록표: F(resourceSheet(ctx, "경비목록표", "경비 목록표", "S"), "0103"),
+ };
+ }
+
+ return { buildUnitSheets, unitContext, compOptions, LIST_COLS, PAIR_COLS };
+})();
+
+if (typeof window !== "undefined") window.STMATE_REPORT_UNIT = StmateReportUnit;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateReportUnit;
diff --git a/tools/stmate-test/scr_edit_comp.js b/tools/stmate-test/scr_edit_comp.js
new file mode 100644
index 000000000..98d0f2d27
--- /dev/null
+++ b/tools/stmate-test/scr_edit_comp.js
@@ -0,0 +1,249 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 일위대가(B)·중기(X) 구성행 편집 (scr_edit_comp.js) — BDQTY 구성행 보기·고치기·행 추가/삭제/이동 ·
+ * 나누기수량 → 일반수량 · 노임산출계수(RXX) · 손료 갈래(E0~E3) · 결과적용 비목 · 계산 반영 · 당초내역 재생
+ * 근거: REA 30 TWM_EDIT_ILWI(wcBx_RXX 7택 · cBx_SBMOK 4택 · Act_Devd2Qty · Act_Up/Dn · Act_Undo 당초내역 재생 ·
+ * Act_CalcBD 재계산) · divisor_to_ordinary_quantity_rules · 지식DB 17(성분 절사) · 30 §4(E0~E3 · H0~H5)
+ * 계산 = smt_eval.js evaluateComposition · 설정 = smt_engine.js compositionOptions
+ */
+(function (global) {
+ "use strict";
+ const num = (v) => Number(v) || 0;
+ const esc = (s) =>
+ String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
+ const RXX = ["초기값설정 개별적용", "기 본 노임산출계수", "기 타 노임산출계수", "기타-1노임산출계수", "기타-2노임산출계수", "기타-3노임산출계수", "기타-4노임산출계수"];
+ const SHIFT = [["E0", "주간"], ["E1", "야간"], ["E2", "주야간 2교대"], ["E3", "주야간 3교대"]];
+ const SB = [["", "0 각 해당비목 적용"], ["M", "1 재료비로 적용"], ["L", "2 노무비로 적용"], ["S", "3 경 비로 적용"]];
+ const COMP = { 1: "재", 2: "노", 3: "경", 4: "계" };
+
+ const E = () => global.STMATE_SMT_EVAL;
+ const ENG = () => global.STMATE_ENGINE;
+ const byCode = (ctx) => ENG().costnLookup(ctx).byCode;
+
+ // 편집 사본 — 고르면 BDQTY 구성행을 복사해 둠(반영 전까지 원본 그대로)
+ function draft(ctx, code) {
+ const u = ctx.uiState;
+ u.compEdit = u.compEdit || {};
+ if (!u.compEdit[code])
+ u.compEdit[code] = ((ctx.dbfData && ctx.dbfData.bdqty_rows) || [])
+ .filter((r) => r.BDCODE === code)
+ .map((r) => ({ ...r }));
+ return u.compEdit[code];
+ }
+
+ // 행 금액 미리보기 — 단가 × 수량(소수 1자리 버림) · 시간 환산 직종은 시간당 노임
+ function rowView(ctx, code, row, opts) {
+ const c = String(row.CODE || "").trim();
+ const r = byCode(ctx)[c];
+ const q = ["T", "t", "1", true].includes(row.DEVD1) ? (num(row.QTY1) ? 1 / num(row.QTY1) : 0) : num(row.QTY1);
+ if (!r) return { name: c === "PR" || c === "RR" ? "비율 줄" : "", p: null, q };
+ let p = { M: num(r.MAMT1), L: num(r.LAMT1), S: num(r.SAMT1) };
+ if (num(r.NAGCD) > 0) p = { M: num(r.MAMT2), L: num(r.LAMT2), S: num(r.SAMT2) };
+ if (opts.labor && opts.labor.has(c)) {
+ const rx = opts.labor instanceof Map ? opts.labor.get(c) : opts.rx;
+ p = { M: 0, L: E().hourlyWage(p.L, rx), S: 0 };
+ }
+ const amt = {};
+ for (const k of "MLS") amt[k] = Math.trunc(q * p[k] * 10 + 1e-9) / 10;
+ return { name: r.NAME, size: r.SIZE, unit: r.UNIT, p, q, amt };
+ }
+
+ function evalDraft(ctx, code) {
+ const row = byCode(ctx)[code] || {};
+ const opts = ENG().compositionOptions(ctx, code, row);
+ return { opts, ev: E().evaluateComposition(draft(ctx, code), ENG().costnLookup(ctx).lookup, opts) };
+ }
+
+ function render(ctx, code, app) {
+ const fmt = app.fmt;
+ const rows = draft(ctx, code);
+ const row = byCode(ctx)[code] || {};
+ const { opts, ev } = evalDraft(ctx, code);
+ const sel = new Set(ctx.uiState.compSel || []);
+ const sys = (ctx.state && ctx.state.sysConfig) || {};
+ const opt = (list, cur) => list.map(([v, l]) => ``).join("");
+ const isX = code[0] === "X";
+ const errs = checkDraft(ctx, code);
+ return `
+
+
+
+
+
+
+
+
+ ${isX ? `` : ""}
+ ${isX ? `` : ""}
+ ${isX ? `` : ""}
+
+
+ ${errs.length ? `${errs.map(esc).join("
")}
` : ""}
+
+ 재계산: 재 ${fmt(ev.M)} · 노 ${fmt(ev.L)} · 경 ${fmt(ev.S)} = ${fmt(ev.T)}원
+ 저장 단가: 재 ${fmt(num(row.MAMT1))} · 노 ${fmt(num(row.LAMT1))} · 경 ${fmt(num(row.SAMT1))} = ${fmt(num(row.TAMT1))}원 ${opts.totalFirst ? "· 총액우선" : "· 비목우선"}
`;
+ }
+
+ // 구성 검사 — 없는 코드 · 자기참조 · 순환(하위가 다시 이 호표를 부름)
+ function checkDraft(ctx, code) {
+ const by = byCode(ctx);
+ const errs = [];
+ const deps = depsOf(ctx, code);
+ draft(ctx, code).forEach((r, i) => {
+ const c = String(r.CODE || "").trim();
+ if (!c || /^(PR|RR|T\d|C\d|E\d)$/.test(c)) return;
+ if (!by[c]) errs.push(`${i + 1}행 ${c} 는 등록된 단가코드가 아님`);
+ if (c === code) errs.push(`${i + 1}행 ${c} 자기 자신을 참조함(순환참조)`);
+ });
+ const cyc = ENG().findCycles(deps, new Set([code, ...reach(deps, code)]));
+ cyc.filter((p) => p.includes(code)).forEach((p) => errs.push(`순환참조: ${p.join(" → ")}`));
+ return [...new Set(errs)];
+ }
+ // 참조 그래프 — 편집 중 사본 · BDQTY 구성 · 산근 글
+ function depsOf(ctx, code) {
+ const groups = {};
+ for (const r of (ctx.dbfData && ctx.dbfData.bdqty_rows) || [])
+ if (/^[BX]/.test(r.BDCODE || "")) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(String(r.CODE || "").trim());
+ const u = ctx.uiState.compEdit || {};
+ for (const k of Object.keys(u)) groups[k] = u[k].map((r) => String(r.CODE || "").trim());
+ const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
+ const edited = ctx.uiState.editedSmtMap || {};
+ return (c) =>
+ groups[c] ||
+ [...String(edited[c] ?? (memos[c] && memos[c].text) ?? "").matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g)].map((m) => m[1]);
+ }
+ function reach(deps, start) {
+ const seen = new Set(),
+ st = [start];
+ while (st.length) for (const d of deps(st.pop())) if (!seen.has(d)) seen.add(d), st.push(d);
+ return seen;
+ }
+
+ function bind(ctx, code, app) {
+ const rows = draft(ctx, code);
+ const $ = (id) => document.getElementById(id);
+ const redraw = () => app.renderWorkspace();
+ const sel = () => (ctx.uiState.compSel || []).slice().sort((a, b) => a - b);
+ const row = byCode(ctx)[code] || {};
+ document.querySelectorAll(".comp-sel").forEach(
+ (el) =>
+ (el.onchange = () => {
+ const i = Number(el.dataset.i);
+ const s = new Set(ctx.uiState.compSel || []);
+ el.checked ? s.add(i) : s.delete(i);
+ ctx.uiState.compSel = [...s];
+ }),
+ );
+ document.querySelectorAll(".comp-code").forEach(
+ (el) =>
+ (el.onchange = () => {
+ rows[Number(el.dataset.i)].CODE = el.value.trim().toUpperCase();
+ redraw();
+ }),
+ );
+ document.querySelectorAll(".comp-qty").forEach(
+ (el) =>
+ (el.onchange = () => {
+ rows[Number(el.dataset.i)].QTY1 = String(num(el.value));
+ redraw();
+ }),
+ );
+ document.querySelectorAll(".comp-devd").forEach(
+ (el) =>
+ (el.onchange = () => {
+ rows[Number(el.dataset.i)].DEVD1 = el.checked ? "T" : "F";
+ redraw();
+ }),
+ );
+ const on = (id, fn) => $(id) && ($(id).onclick = fn);
+ on("comp-add", () => {
+ const at = sel().length ? sel().pop() + 1 : rows.length;
+ rows.splice(at, 0, { BDCODE: code, CODE: "", QTY1: "0", DEVD1: "F", PRPT: "0", PRIN: "0", PROUT: "0" });
+ ctx.uiState.compSel = [at];
+ redraw();
+ });
+ on("comp-del", () => {
+ const s = new Set(sel());
+ if (!s.size) return alert("지울 행을 고르세요");
+ rows.splice(0, rows.length, ...rows.filter((_r, i) => !s.has(i)));
+ ctx.uiState.compSel = [];
+ redraw();
+ });
+ const move = (d) => {
+ const s = sel();
+ if (!s.length || s[0] + d < 0 || s[s.length - 1] + d >= rows.length) return;
+ const order = d < 0 ? s : s.slice().reverse();
+ for (const i of order) [rows[i], rows[i + d]] = [rows[i + d], rows[i]];
+ ctx.uiState.compSel = s.map((i) => i + d);
+ redraw();
+ };
+ on("comp-up", () => move(-1));
+ on("comp-dn", () => move(1));
+ on("comp-devd", () => {
+ const by = byCode(ctx);
+ for (const i of sel()) {
+ const r = rows[i];
+ if (!["T", true].includes(r.DEVD1)) continue;
+ const unit = (by[String(r.CODE).trim()] || {}).UNIT;
+ Object.assign(r, { QTY1: String(E().devdToOrdinary(r.QTY1, unit)), DEVD1: "F" });
+ }
+ redraw();
+ });
+ on("comp-undo", () => {
+ delete ctx.uiState.compEdit[code];
+ ctx.uiState.compSel = [];
+ redraw();
+ });
+ const setRow = (k, v) => Object.assign(row, { [k]: v });
+ const onc = (id, fn) => $(id) && ($(id).onchange = (e) => (fn(e.target.value), redraw()));
+ onc("comp-rxx", (v) => setRow("RXX", v));
+ onc("comp-sbmok", (v) => setRow("SBMOK", v));
+ onc("comp-shift", (v) => setRow("SHIFT", v));
+ onc("comp-shift-f", (v) => {
+ const sys = ctx.state.sysConfig;
+ sys.shiftFactors = Object.assign({}, sys.shiftFactors, { [row.SHIFT || sys.shiftMode || "E0"]: num(v) || "" });
+ });
+ on("comp-apply", () => {
+ const errs = checkDraft(ctx, code);
+ if (errs.length && !confirm(`검사 오류 ${errs.length}건 — 그래도 반영할까요?\n${errs.slice(0, 5).join("\n")}`)) return;
+ const all = ctx.dbfData.bdqty_rows || (ctx.dbfData.bdqty_rows = []);
+ const at = all.findIndex((r) => r.BDCODE === code);
+ const rest = all.filter((r) => r.BDCODE !== code);
+ const fresh = rows.map((r) => ({ ...r, BDCODE: code }));
+ rest.splice(at < 0 ? rest.length : at, 0, ...fresh);
+ all.splice(0, all.length, ...rest);
+ delete ctx.uiState.compEdit[code];
+ app.cascadeAndRender();
+ const cyc = ctx.uiState.cycleErrors || [];
+ if (cyc.length) alert(`순환참조 ${cyc.length}건 — 저장 단가로 이어 셈\n${cyc.slice(0, 5).join("\n")}`);
+ });
+ }
+
+ global.STMATE_EDIT_COMP = { render, bind, draft, checkDraft, depsOf, reach, rowView };
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/tools/stmate-test/scr_edit_expr.js b/tools/stmate-test/scr_edit_expr.js
new file mode 100644
index 000000000..e3caf04eb
--- /dev/null
+++ b/tools/stmate-test/scr_edit_expr.js
@@ -0,0 +1,268 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 산출근거 식 평가기 (scr_edit_expr.js) — 사칙·괄호·함수·조건·대괄호 표시식 · 순수 계산
+ * 근거: REA 30 ST_EdSan TWM_EDIT_SAN cBoxFunc(∏ · INT · SIN · COS · TAN · SQRT · √ · ROUND ·
+ * ROUNDDOWN · CUT · LOG · LN · ABS · EXP · MOD · POWER · FACT · IIF · [2/3]) · Memo_Use 문법
+ * (3) round(A/B,4) · [?:4R] · [?:2U] · [?:0C] · (9) [Q=0.208] · 지식DB 11 §4.2(ROUND = RoundTo 짝수
+ * 반올림 · ROUNDDOWN = 0 방향) · 24 §2.3(IIF · IFI · IFO) · REA 18 IIF 인자 순서(참 = 두 번째)
+ * 연산 차례 — 곱·나눗셈은 왼쪽부터 차례로(18 §2.1) · 덧뺄셈보다 먼저 · 거듭제곱(^)은 오른쪽부터
+ */
+const StmateSmtExpr = (() => {
+ const EPS = 1e-9;
+ const trunc = (v, d = 0) => {
+ const f = 10 ** d,
+ s = v * f;
+ return Math.trunc(s + (s >= 0 ? EPS : -EPS)) / f;
+ };
+ // RoundTo — 정확히 절반이면 짝수 쪽 · 0 대칭
+ const roundEven = (v, d = 0) => {
+ const unit = d > 0 ? 1 / 10 ** d : 10 ** -d,
+ s = Math.abs(v) / unit,
+ fl = Math.floor(s),
+ diff = s - fl;
+ const r = diff > 0.5 || (diff === 0.5 && fl % 2 === 1) ? fl + 1 : fl;
+ const out = d > 0 ? r / 10 ** d : r * unit;
+ return v < 0 ? -out : out;
+ };
+ // 올림 — 절사 뒤 남은 끝이 있으면 한 단위 더(0에서 멀어짐)
+ const roundUp = (v, d = 0) => {
+ const t = trunc(v, d);
+ const f = 10 ** d;
+ return Math.abs(v - t) * f > EPS ? (Math.round(t * f) + Math.sign(v)) / f : t;
+ };
+ const fact = (n) => {
+ let r = 1;
+ for (let i = 2; i <= Math.trunc(n); i++) r *= i;
+ return r;
+ };
+ const pick = (c, a, b) => (c ? a : b);
+ const FUNCS = {
+ INT: [1, (x) => Math.trunc(x)],
+ ROUND: [[1, 2], (x, d = 0) => roundEven(x, d)],
+ ROUNDDOWN: [[1, 2], (x, d = 0) => trunc(x, d)],
+ CUT: [[1, 2], (x, d = 0) => trunc(x, d)],
+ ROUNDUP: [[1, 2], (x, d = 0) => roundUp(x, d)],
+ SQRT: [1, Math.sqrt],
+ SIN: [1, Math.sin],
+ COS: [1, Math.cos],
+ TAN: [1, Math.tan],
+ LOG: [2, (b, x) => (b === 10 ? Math.log10(x) : Math.log(x) / Math.log(b))],
+ LN: [1, Math.log],
+ ABS: [1, Math.abs],
+ EXP: [1, Math.exp],
+ MOD: [2, (a, b) => a - b * Math.trunc(a / b)],
+ POWER: [2, Math.pow],
+ FACT: [1, fact],
+ IIF: [3, pick],
+ IF: [3, pick],
+ IFI: [3, pick],
+ IFO: [3, pick],
+ };
+ const RND = { R: roundEven, U: roundUp, C: trunc };
+
+ function tokenize(src) {
+ const out = [];
+ const s = String(src);
+ let i = 0;
+ while (i < s.length) {
+ const c = s[i];
+ if (/\s/.test(c)) {
+ i++;
+ continue;
+ }
+ const num = s.slice(i).match(/^(\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/);
+ if (num) {
+ out.push({ t: "num", v: parseFloat(num[0]) });
+ i += num[0].length;
+ continue;
+ }
+ const id = s.slice(i).match(/^[A-Za-z_][A-Za-z_0-9]*/);
+ if (id) {
+ out.push({ t: "id", v: id[0] });
+ i += id[0].length;
+ continue;
+ }
+ const rnd = s.slice(i).match(/^:\s*(\d)\s*([RUCruc])(?=\s*\])/);
+ if (rnd) {
+ out.push({ t: "rnd", v: rnd[0], d: Number(rnd[1]), k: rnd[2].toUpperCase() });
+ i += rnd[0].length;
+ continue;
+ }
+ const two = s.slice(i, i + 2);
+ if ([">=", "<=", "<>", "!="].includes(two)) {
+ out.push({ t: "op", v: two === "!=" ? "<>" : two });
+ i += 2;
+ continue;
+ }
+ if ("+-*/^()<>=,√∏÷×[]".includes(c)) {
+ out.push({ t: "op", v: c === "÷" ? "/" : c === "×" ? "*" : c });
+ i++;
+ continue;
+ }
+ throw new Error(`알 수 없는 글자 「${c}」`);
+ }
+ return out;
+ }
+
+ /**
+ * 식 하나 평가 — vars: 대문자 이름 → 값 · 대괄호 [이름=식] 은 vars 에 씀
+ * 돌려줌: { value, error, missing: [정의 안 된 변수] }
+ */
+ function evaluate(src, vars = {}) {
+ const missing = [];
+ let toks, p = 0;
+ const peek = () => toks[p];
+ const isOp = (v) => toks[p] && toks[p].t === "op" && toks[p].v === v;
+ const expect = (v) => {
+ if (!isOp(v)) throw new Error(`「${v}」 빠짐`);
+ p++;
+ };
+ function cmp() {
+ let a = add();
+ while (toks[p] && toks[p].t === "op" && [">", "<", ">=", "<=", "=", "<>"].includes(toks[p].v)) {
+ const op = toks[p++].v,
+ b = add();
+ a =
+ op === ">" ? a > b : op === "<" ? a < b : op === ">=" ? a >= b : op === "<=" ? a <= b : op === "=" ? Math.abs(a - b) < EPS : Math.abs(a - b) >= EPS;
+ a = a ? 1 : 0;
+ }
+ return a;
+ }
+ function add() {
+ let a = mul();
+ while (isOp("+") || isOp("-")) {
+ const op = toks[p++].v,
+ b = mul();
+ a = op === "+" ? a + b : a - b;
+ }
+ return a;
+ }
+ function mul() {
+ let a = unary();
+ while (isOp("*") || isOp("/")) {
+ const op = toks[p++].v,
+ b = unary();
+ a = op === "*" ? a * b : a / b;
+ }
+ return a;
+ }
+ function unary() {
+ if (isOp("-")) return p++, -unary();
+ if (isOp("+")) return p++, unary();
+ return power();
+ }
+ function power() {
+ const a = primary();
+ if (isOp("^")) {
+ p++;
+ return Math.pow(a, unary());
+ }
+ return a;
+ }
+ function args() {
+ const out = [];
+ expect("(");
+ if (!isOp(")")) {
+ out.push(cmp());
+ while (isOp(",")) p++, out.push(cmp());
+ }
+ expect(")");
+ return out;
+ }
+ function primary() {
+ const t = peek();
+ if (!t) throw new Error("식이 끝남");
+ if (t.t === "num") return p++, t.v;
+ if (t.t === "op" && t.v === "(") {
+ p++;
+ const v = cmp();
+ expect(")");
+ return v;
+ }
+ if (t.t === "op" && t.v === "√") return p++, Math.sqrt(isOp("(") ? (p++, ((v) => (expect(")"), v))(cmp())) : primary());
+ if (t.t === "op" && t.v === "∏") return p++, Math.PI;
+ if (t.t === "op" && t.v === "[") return p++, bracket();
+ if (t.t === "id") {
+ p++;
+ const name = t.v.toUpperCase();
+ if (isOp("(")) {
+ const f = FUNCS[name];
+ if (!f) throw new Error(`지원하지 않는 함수 「${t.v}」`);
+ const a = args();
+ const n = f[0];
+ const ok = Array.isArray(n) ? a.length >= n[0] && a.length <= n[1] : a.length === n;
+ if (!ok) throw new Error(`「${t.v}」 인자 수 ${a.length}개 틀림`);
+ return f[1](...a);
+ }
+ if (name === "PI") return Math.PI;
+ if (vars[name] === undefined) {
+ missing.push(t.v);
+ return 0;
+ }
+ return Number(vars[name]) || 0;
+ }
+ throw new Error(`「${t.v}」 자리가 틀림`);
+ }
+ // [이름=식] 대입 · [식:자리R|U|C] 자리 처리 · [식] 값
+ function bracket() {
+ let name = null;
+ if (toks[p] && toks[p].t === "id" && toks[p + 1] && toks[p + 1].t === "op" && toks[p + 1].v === "=") {
+ name = toks[p].v.toUpperCase();
+ p += 2;
+ }
+ let v = cmp();
+ if (toks[p] && toks[p].t === "rnd") v = RND[toks[p].k](v, toks[p++].d);
+ expect("]");
+ if (name) vars[name] = v;
+ return v;
+ }
+ try {
+ toks = tokenize(src);
+ if (!toks.length) return { value: null, error: null, missing };
+ const value = cmp();
+ if (p < toks.length) throw new Error(`「${toks[p].v}」 뒤가 남음`);
+ if (!Number.isFinite(value)) return { value: 0, error: "계산 불가(0 나눔 등)", missing };
+ return { value, error: null, missing };
+ } catch (e) {
+ return { value: null, error: e.message, missing };
+ }
+ }
+
+ // 괄호·대괄호 밖의 구분자로 나눔
+ function splitTop(s, sep = ",") {
+ const out = [];
+ let depth = 0,
+ cur = "";
+ for (const c of String(s)) {
+ if (c === "(" || c === "[") depth++;
+ if (c === ")" || c === "]") depth--;
+ if (c === sep && depth === 0) {
+ out.push(cur);
+ cur = "";
+ } else cur += c;
+ }
+ out.push(cur);
+ return out;
+ }
+ // 괄호·대괄호 밖의 '=' 자리들(:= · <= · >= · <> 제외)
+ function topEqs(s) {
+ const at = [];
+ let depth = 0;
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i];
+ if (c === "(" || c === "[") depth++;
+ else if (c === ")" || c === "]") depth--;
+ else if (c === "=" && depth === 0 && !":<>".includes(s[i - 1] || " ")) at.push(i);
+ }
+ return at;
+ }
+ const lastTopEq = (s) => (topEqs(s).pop() ?? -1);
+ const firstTopEq = (s) => (topEqs(s)[0] ?? -1);
+
+ const FUNC_LIST = ["∏", "INT(0)", "SIN(0)", "COS(0)", "TAN(0)", "SQRT(1)", "√(1)", "ROUND(1,0)", "ROUNDDOWN(1,0)", "CUT(1,0)", "LOG(10,1)", "LN(1)", "ABS(1)", "EXP(1)", "MOD(1,1)", "POWER(1,1)", "FACT(1)", "IIF(1>0, 2, 3)", ";;", "[2/3]"];
+
+ return { evaluate, tokenize, splitTop, lastTopEq, firstTopEq, trunc, roundEven, roundUp, RND, FUNCS, FUNC_LIST };
+})();
+
+if (typeof window !== "undefined") window.STMATE_SMT_EXPR = StmateSmtExpr;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateSmtExpr;
diff --git a/tools/stmate-test/scr_edit_lang.js b/tools/stmate-test/scr_edit_lang.js
new file mode 100644
index 000000000..6d58d5330
--- /dev/null
+++ b/tools/stmate-test/scr_edit_lang.js
@@ -0,0 +1,146 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 단가산출 편집기 글 도우미 (scr_edit_lang.js) — 코드 줄 만들기(F6/F7 · 중기비 Q·A·T·N) · 계표시 ·
+ * 금액결과 삭제 · 문법 강조 · 문법설명 · 순수 계산
+ * 근거: REA 30 TWM_EDIT_SAN(cBox_Trans 5택 · ComOp / * · ComQty Q A T N · Chk0Not 「단가가 0원인 비목은
+ * 입력안함」 · Act_InsSum1~5 · Act_InsCon1~5 · Memo_Use 문법 (1)~(10) · CBoxSystax · CBoxEnable)
+ * · result_text_restoration_rules(소계~총합계 · 제외금액 10종 · 참조줄 '=' 까지 남김) · 지식DB 26 §3
+ */
+const StmateSmtLang = (() => {
+ const esc = (s) =>
+ String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
+ const num = (v) => Number(v) || 0;
+
+ // 계표시 — 레벨 1~5 · 제외금액
+ const MARK_NAMES = ["소계", "계", "합계", "총계", "총합계"];
+ const marker = (level, excluded) => `(${excluded ? "-" : ""}${"=".repeat(Math.max(1, Math.min(5, level)))})`;
+
+ // 일반코드 입력 꼴(cBox_Trans) 0 코드 · 1 명칭 코드 · 2 명칭 규격 코드 · 3 명칭 규격 코드 단위 · 4 코드번호만
+ const TRANS = ["코드 * 수량(0) =", "명칭 코드 * 수량(0) =", "명칭 규격 코드 * 수량(0) =", "명칭 규격 코드 * 수량(0) 단위 =", "코드번호(만 입력)"];
+ const COMP_LABEL = { L: "노 무 비", M: "재 료 비", S: "경 비" };
+
+ /**
+ * 코드 줄 만들기 — row = COSTN 행 · opts { trans, op('/'|'*'), qtyVar('Q'|'A'|'T'|'N'), skipZero, qty }
+ * 중기(X)·일위대가(B)·산근(D)는 성분마다 한 줄(「'노 무 비' &X00006L& / Q =」) · 0원 성분은 skipZero 면 뺌
+ */
+ function codeLines(code, row = {}, opts = {}) {
+ const c = String(code || "").trim();
+ const q = opts.qty === undefined || opts.qty === "" ? "0" : String(opts.qty);
+ if (/^[XBD]/.test(c)) {
+ const op = opts.op === "*" ? "*" : "/";
+ const v = /^[QATN]$/.test(opts.qtyVar || "") ? opts.qtyVar : "Q";
+ return ["L", "M", "S"]
+ .filter((k) => !opts.skipZero || num(row[`${k}AMT1`]) !== 0)
+ .map((k) => `'${COMP_LABEL[k]}' &${c}${k}& ${op} ${v} =`);
+ }
+ const nm = String(row.NAME || "").trim(),
+ sz = String(row.SIZE || "").trim(),
+ un = String(row.UNIT || "").trim();
+ const t = Number(opts.trans) || 0;
+ if (t === 4) return [`&${c}&`];
+ const label = t === 1 ? nm : t >= 2 ? `${nm} ${sz}`.trim() : "";
+ const unit = t === 3 && un ? `'${un}'` : "";
+ return [`${label ? `'${label}' ` : ""}&${c}& * ${q}${unit} =`];
+ }
+
+ /**
+ * 금액결과 삭제(Act_Del_ResultAmt) — 「소계」…「총합계(제외금액)」 글을 계표시로 · 참조 줄은 마지막 '=' 까지
+ * 남기고 뒤는 따옴표 주석 · '->' · 금액변수(A..)만 · 그 밖의 '=' 없는 글 줄은 따옴표로 감쌈
+ */
+ function stripResults(text) {
+ const marks = {};
+ MARK_NAMES.forEach((n, i) => {
+ marks[n] = marker(i + 1, false);
+ marks[`${n}(제외금액)`] = marker(i + 1, true);
+ });
+ return String(text || "")
+ .split(/\r?\n/)
+ .map((ln) => {
+ const t = ln.trim();
+ if (marks[t]) return ln.replace(t, marks[t]);
+ const amps = (ln.match(/&/g) || []).length;
+ const eq = ln.lastIndexOf("=");
+ if (amps === 2 && eq > ln.lastIndexOf("&")) {
+ const tail = ln.slice(eq + 1);
+ const q = tail.indexOf("'");
+ let keep = "";
+ if (tail.includes("->")) keep = tail.slice(0, tail.indexOf("->") + 2);
+ else if (q >= 0) keep = tail.slice(q);
+ else if (/^\s*A/.test(tail)) keep = tail.trim().slice(0, 3);
+ return ln.slice(0, eq + 1) + keep;
+ }
+ if (t && !t.includes("=") && !t.includes("'") && !/^\(-?=+\)/.test(t) && !t.startsWith(";;") && !t.startsWith("//"))
+ return ln.replace(t, `'${t}'`);
+ return ln;
+ })
+ .join("\r\n");
+ }
+
+ // 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 변수·단가코드 · 계표시
+ function highlight(line) {
+ const s = String(line);
+ const out = [];
+ let i = 0;
+ while (i < s.length) {
+ const rest = s.slice(i);
+ let m;
+ if (rest.startsWith("//")) {
+ out.push(`${esc(rest)}`);
+ break;
+ }
+ if (rest[0] === "'") {
+ const j = s.indexOf("'", i + 1);
+ const seg = j < 0 ? rest : s.slice(i, j + 1);
+ out.push(`${esc(seg)}`);
+ i += seg.length;
+ continue;
+ }
+ if ((m = rest.match(/^&[^&\s]*&/)) || (m = rest.match(/^~[^~]*~/))) {
+ out.push(`${esc(m[0])}`);
+ i += m[0].length;
+ continue;
+ }
+ if ((m = rest.match(/^\(-?=+\)(?::\s*T\d{2})?/))) {
+ out.push(`${esc(m[0])}`);
+ i += m[0].length;
+ continue;
+ }
+ if ((m = rest.match(/^\d+\.?\d*/))) {
+ out.push(`${esc(m[0])}`);
+ i += m[0].length;
+ continue;
+ }
+ if ((m = rest.match(/^[A-Za-z_][A-Za-z_0-9]*/))) {
+ out.push(`${esc(m[0])}`);
+ i += m[0].length;
+ continue;
+ }
+ out.push(esc(rest[0]));
+ i += 1;
+ }
+ return out.join("");
+ }
+
+ // 문법설명(CBoxSystax) — TWM_EDIT_SAN Memo_Use 요지
+ const GRAMMAR_HELP = [
+ "(1) Text — 계산과 무관한 글은 양쪽을 ' 로 막음",
+ "(2) 변수 — 「K = 1.0」 · 첫 글자 영문 · 대소문자 구분 없음 · 영문 뒤 숫자 가능(Q1, Q2)",
+ "(3) 계산식 — 「Q = qo*K*E/Cm」 소수 그대로 · 끝에 '=' 를 붙이면 단가산출서 소수 자리(ZCD)로 반올림",
+ " 자리 함수 round(A/B,4) · int(B/A) · A/B=[?:4R] 반올림 · [?:2U] 올림 · [?:0C] 절사",
+ "(4) 단가코드 — 「&L00084&」 앞뒤에 & · 짧게 &L84&",
+ "(5) 그림코드 — 「IMG(I00001)」 · 한 줄에 하나",
+ "(6) 단가코드+단가코드 불가 — 「0.208 * &L84& =」 「0.208 * &L87& =」 처럼 줄을 나눔",
+ "(7) 계표시 — (=)소계 (==)계 (===)합계 (====)총계 (=====)총합계 · (-=) 제외금액(셈은 하되 계에서 뺌)",
+ "(8) 주석문 — 「//」 뒤는 계산·인쇄에서 빠짐(한 줄 안)",
+ "(9) 손료계산 — [Q=0.208] * &L84& :=A01 금액변수 누적 · :=A01 -> 누적하되 제외 · (=):T01 누계번지",
+ " &PA01L:S& * 10'%' = · &RA01L:S& * 0.1 = · &PC_S:M& 마지막 계표시 이후 · &PS_:M& 처음부터",
+ "(10) 결과금액 조정 — 합계 × M·L·S 배율 · 결과적용 비목(각 해당 · 재료 · 노무 · 경비)",
+ "수량변수 — ~Q00001.QTY1~ 기본 수량변수 · ~Q00001.별명~ 확장 수량변수(수량산출서)",
+ "함수 — ∏ INT SIN COS TAN SQRT √ ROUND ROUNDDOWN ROUNDUP CUT LOG LN ABS EXP MOD POWER FACT IIF(조건, 참, 거짓)",
+ ];
+
+ return { MARK_NAMES, TRANS, marker, codeLines, stripResults, highlight, GRAMMAR_HELP, esc };
+})();
+
+if (typeof window !== "undefined") window.STMATE_SMT_LANG = StmateSmtLang;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateSmtLang;
diff --git a/tools/stmate-test/scr_edit_price.js b/tools/stmate-test/scr_edit_price.js
new file mode 100644
index 000000000..9bf8e466c
--- /dev/null
+++ b/tools/stmate-test/scr_edit_price.js
@@ -0,0 +1,88 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 자재 후보단가 채택 (scr_edit_price.js) — 가격지 1~5단가(AMT_1n~AMT_5n) 중 최소단가 자동채택 · 지정 단가 ·
+ * 보호표시(UC=T) 수동 고정 우선 · 0원 후보 제외
+ * 근거: 지식DB STmate/old/20_분석 17 §5.5(최저가 채택) · 22 §3.1(보호표시·0원 수정 안 됨) ·
+ * ui_form_catalog wM_Boxa RG_MComp(변동없음 · 1~5 단가 · 최소단가) · 13 T10(수동값 우선)
+ */
+(function (global) {
+ "use strict";
+ const num = (v) => Number(v) || 0;
+
+ /**
+ * rows = COSTN 행 · opts { slot: 차수 1~4, mode: "min" | 1~5 | "keep", kinds: "M" }
+ * 돌려줌 { changed: [{code, from, to, src}], kept: [보호표시 코드] } · rows 는 그대로(적용은 부르는 쪽)
+ */
+ function adoptCandidates(rows, opts = {}) {
+ const slot = Number(opts.slot) || 1,
+ mode = opts.mode ?? "min",
+ kinds = opts.kinds || "M";
+ const changed = [],
+ kept = [];
+ if (mode === "keep") return { changed, kept };
+ for (const r of rows || []) {
+ const code = String(r.CODE || "").trim();
+ if (!code || !kinds.includes(code[0])) continue;
+ const cand = [1, 2, 3, 4, 5].map((i) => num(r[`AMT_${i}${slot}`]));
+ let to = null,
+ src = 0;
+ if (mode === "min") {
+ cand.forEach((v, i) => v > 0 && (to === null || v < to) && ((to = v), (src = i + 1)));
+ } else if (cand[Number(mode) - 1] > 0) [to, src] = [cand[Number(mode) - 1], Number(mode)];
+ if (to === null) continue;
+ if (String(r.UC || "").trim() === "T") {
+ kept.push(code);
+ continue;
+ }
+ const from = num(r[`MAMT${slot}`]);
+ if (from !== to) changed.push({ code, from, to, src });
+ }
+ return { changed, kept };
+ }
+
+ const api = { adoptCandidates };
+ global.STMATE_PRICE_ADOPT = api;
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
+
+ // 화면 — 단가표 메뉴에 「자재 후보단가 채택」 창
+ if (typeof document === "undefined") return;
+ const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] });
+ P.actions.Act_Qty_AdoptPrice = (ctx, app) => {
+ if (!ctx.dbfData) return alert("먼저 프로젝트를 여세요.");
+ const opts = ["min:최소단가(0원 제외)", "1:1 단가", "2:2 단가", "3:3 단가", "4:4 단가", "5:5 단가", "keep:변동없음"];
+ app.openModal(
+ "자재 후보단가 채택 (wM_Boxa RG_MComp)",
+ `
+
가격지 1~5단가 중 고른 단가를 재료비 적용단가로 · 보호표시(UC=T) 자재는 수동값 그대로 · 0원 후보는 제외
+
+
+
+
+
`,
+ );
+ const $ = (id) => document.getElementById(id);
+ const run = () => adoptCandidates(ctx.dbfData.costn_rows, { mode: $("adopt-mode").value, slot: $("adopt-slot").value });
+ $("adopt-run-preview").onclick = () => {
+ const res = run();
+ $("adopt-preview").innerHTML =
+ `바뀔 자재 ${res.changed.length}건 · 보호표시로 그대로 ${res.kept.length}건
` +
+ res.changed.map((c) => `${c.code}: ${app.fmt(c.from)} → ${app.fmt(c.to)} (${c.src}단가)
`).join("");
+ };
+ $("adopt-run-apply").onclick = () => {
+ const res = run();
+ const slot = Number($("adopt-slot").value) || 1;
+ const by = Object.fromEntries(ctx.dbfData.costn_rows.map((r) => [String(r.CODE).trim(), r]));
+ const sync = global.STMATE_VIEW_PRJ_BOQ && global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice;
+ for (const c of res.changed) {
+ const r = by[c.code];
+ if (slot === 1 && sync) sync(ctx, c.code, c.to, num(r.LAMT1), num(r.SAMT1));
+ else Object.assign(r, { [`MAMT${slot}`]: String(c.to), [`TAMT${slot}`]: String(c.to + num(r[`LAMT${slot}`]) + num(r[`SAMT${slot}`])) });
+ }
+ app.closeModal();
+ app.cascadeAndRender();
+ alert(`자재 ${res.changed.length}건 채택 · 보호표시 ${res.kept.length}건 그대로`);
+ };
+ };
+ if (!P.menus.some((m) => m.act === "Act_Qty_AdoptPrice"))
+ P.menus.push({ menu: "m2", label: "자재 후보단가 채택(최소단가)", act: "Act_Qty_AdoptPrice" });
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/tools/stmate-test/scr_edit_san.js b/tools/stmate-test/scr_edit_san.js
new file mode 100644
index 000000000..b1575419a
--- /dev/null
+++ b/tools/stmate-test/scr_edit_san.js
@@ -0,0 +1,289 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 대가표·산근 편집 화면 (scr_edit_san.js) — wM_Edit_iLWi 자리 바꿔 끼움
+ * 왼쪽 B·D·X 목록 · 오른쪽 산출근거 편집기(D · 산식 있는 코드) 또는 구성행 편집(B · X, scr_edit_comp.js)
+ * 산출근거 편집기: 계산(F9) · 문법 오류 줄 표시 · 함수 입력 · 계표시 · 코드 입력(꼴 5택 · 연산 / * · 변수 Q A T N ·
+ * 0원 비목 입력안함) · 수량변수(~Q00001.QTY1~) · 중기공식 불러오기 · 금액결과 삭제 · 결과적용 비목 · 결과금액조정 ·
+ * 문법 강조 · 문법설명
+ * 근거: REA 30 TWM_EDIT_SAN · TWM_EDIT_EQ · 지식DB 26 §3 · 28 §1·§4 · 23
+ */
+(function (global) {
+ "use strict";
+ const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] });
+ const num = (v) => Number(v) || 0;
+ const L = () => global.STMATE_SMT_LANG;
+ const X = () => global.STMATE_SMT_EXPR;
+ const ENG = () => global.STMATE_ENGINE;
+ const COMP = () => global.STMATE_EDIT_COMP;
+ const esc = (s) => L().esc(s);
+ const SB = [["", "0 각 해당비목 적용"], ["M", "1 재료비로 적용"], ["L", "2 노무비로 적용"], ["S", "3 경 비로 적용"]];
+ const FILTERS = [["ALL_BDX", "전체 대가·산근·중기"], ["SMT_ONLY", "산식 보유"], ["D", "단가산출 D"], ["B", "일위대가 B"], ["X", "중기사용료 X"]];
+
+ const memoText = (ctx, code) => {
+ const ed = ctx.uiState.editedSmtMap || {};
+ if (ed[code] !== undefined) return ed[code];
+ const m = ((ctx.dbfData && ctx.dbfData.costn_memos) || {})[code];
+ return m ? m.text : "";
+ };
+
+ // 수량코드·중기공식 — 처음 한 번 서버에서 받아 둠
+ function preload(ctx, app) {
+ const d = ctx.dbfData;
+ if (d && !d.qcode_rows && !ctx.uiState.qcodeLoading && ctx.currentSourcePath) {
+ ctx.uiState.qcodeLoading = true;
+ app
+ .postJson("/api/qty/qcode", { source_path: ctx.currentSourcePath })
+ .then((r) => ((d.qcode_rows = r.rows || []), app.renderWorkspace()))
+ .catch(() => (d.qcode_rows = []));
+ }
+ if (!ctx.uiState.eqItems && !ctx.uiState.eqLoading) {
+ ctx.uiState.eqLoading = true;
+ fetch("/api/qty/eq")
+ .then((r) => r.json())
+ .then((r) => ((ctx.uiState.eqItems = r.items || []), app.renderWorkspace()))
+ .catch(() => (ctx.uiState.eqItems = []));
+ }
+ }
+
+ function listRows(ctx) {
+ const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
+ const f = ctx.uiState.ilwiSubFilter || "ALL_BDX";
+ return ((ctx.dbfData && ctx.dbfData.costn_rows) || []).filter((r) => {
+ const c = String(r.CODE || "").trim();
+ if (!c) return false;
+ if (f === "ALL_BDX") return /^[BDX]/.test(c) || Boolean(memos[c]);
+ if (f === "SMT_ONLY") return Boolean(memos[c]);
+ return c[0] === f;
+ });
+ }
+
+ function render(ctx, app) {
+ preload(ctx, app);
+ const fmt = app.fmt;
+ const rows = listRows(ctx);
+ const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
+ let code = ctx.uiState.selectedIlwiCode;
+ if (!rows.some((r) => String(r.CODE).trim() === code)) code = rows.length ? String(rows[0].CODE).trim() : "";
+ ctx.uiState.selectedIlwiCode = code;
+ const row = rows.find((r) => String(r.CODE).trim() === code) || {};
+ const hasComp = ((ctx.dbfData && ctx.dbfData.bdqty_rows) || []).some((r) => r.BDCODE === code);
+ const isText = code[0] === "D" || Boolean(memos[code]) || !/^[BX]/.test(code) || !hasComp;
+ const f = ctx.uiState.ilwiSubFilter || "ALL_BDX";
+ const cyc = ctx.uiState.cycleErrors || [];
+ return `
+ `;
+ }
+
+ // 산출근거 편집기
+ function renderText(ctx, app, code, row) {
+ const fmt = app.fmt;
+ const text = memoText(ctx, code);
+ const ev = ENG().evaluateSmtFormulaText(text, ctx, code);
+ const cyc = cycleErrors(ctx, code);
+ const errs = ev.errors.concat(cyc.map((msg) => ({ lineNo: 0, msg })));
+ const u = ctx.uiState;
+ const sel = (id, opts, cur) =>
+ ``;
+ const marks = [["", "계표시(F5)"]].concat(
+ L().MARK_NAMES.map((n, i) => [L().marker(i + 1, false), `${n} ${L().marker(i + 1, false)}`]),
+ L().MARK_NAMES.map((n, i) => [L().marker(i + 1, true), `${n}(제외금액) ${L().marker(i + 1, true)}`]),
+ );
+ const qrows = (ctx.dbfData && ctx.dbfData.qcode_rows) || [];
+ const qopts = [["", qrows.length ? "수량코드" : "수량코드 없음"]];
+ for (const q of qrows) {
+ const c = String(q.CODE).trim();
+ for (let k = 0; k < 10; k++) if (String(q[`QTY${k}`] ?? "").trim()) qopts.push([`~${c}.QTY${k}~`, `${c}.QTY${k} = ${q[`QTY${k}`]} ${q.NAME || ""}`]);
+ for (const ln of String(q.XCODE || "").split(/\r?\n/)) if (ln.includes("=")) qopts.push([`~${c}.${ln.split("=")[0].trim()}~`, `${c}.${ln.trim()}`]);
+ }
+ const eq = u.eqItems || [];
+ const adjOn = String(row.S_ADJ || "").trim() === "T";
+ const lines = ev.linesOut;
+ return `
+
+
+
+
+
+ ${sel("san-func", [["", "각종 지원함수"]].concat(X().FUNC_LIST.map((f) => [f, f])), "")}
+ ${sel("san-mark", marks, "")}
+ ${sel("san-qty", qopts, "")}
+ ${sel("san-eq", [["", eq.length ? "중기공식 불러오기" : "등록된 중기공식 없음"]].concat(eq.map((e, i) => [String(i), `${e.name} (${e.ins_x ? "공식+코드" : "공식"})`])), "")}
+
+
+
+
+ ${sel("san-trans", L().TRANS.map((t, i) => [String(i), t]), u.sanTrans ?? "1")}
+ 중기비 코드${sel("san-op", [["/", "/"], ["*", "*"]], u.sanOp || "/")}${sel("san-qv", ["Q", "A", "T", "N"].map((v) => [v, v]), u.sanQv || "Q")}(으)로 입력
+
+
+ 결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())}
+
+ ${adjOn ? ["M", "L", "S"].map((k) => `${k}:x `).join(" ") : ""}
+
+ ${u.sanHelp ? `${L().GRAMMAR_HELP.map(esc).join("\n")}
` : ""}
+
+
+
+
+
문법 오류 ${errs.length}건
+ ${errs.map((e) => `
${e.lineNo ? `${e.lineNo}번째줄: ` : ""}${esc(e.msg)}
`).join("") || "
오류 없음
"}
+
+
+
계산결과(Alt+F9) — 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")}
+ 재 ${fmt(ev.sumM)} + 노 ${fmt(ev.sumL)} + 경 ${fmt(ev.sumS)} = ${fmt(ev.sumT)}원
+
+
`;
+ }
+
+ // 순환 — 이 산근이 부르는 코드가 다시 이 산근에 닿는지(REA 30 mode1 순환참조 검사)
+ function cycleErrors(ctx, code) {
+ const C = COMP();
+ const deps = C.depsOf(ctx, code);
+ const cyc = ENG().findCycles(deps, new Set([code, ...C.reach(deps, code)]));
+ return cyc.filter((p) => p.includes(code)).map((p) => `순환참조: ${p.join(" → ")}`);
+ }
+
+ function insertAt(area, text) {
+ const s = area.selectionStart ?? area.value.length,
+ e = area.selectionEnd ?? s;
+ area.value = area.value.slice(0, s) + text + area.value.slice(e);
+ area.selectionStart = area.selectionEnd = s + text.length;
+ area.focus();
+ }
+ // 줄 머리에 끼움 — 커서가 있는 줄 다음에 새 줄로
+ function insertLines(area, lines) {
+ const v = area.value,
+ at = v.indexOf("\n", area.selectionStart ?? v.length);
+ const pos = at < 0 ? v.length : at + 1;
+ const add = (at < 0 && v && !v.endsWith("\n") ? "\r\n" : "") + lines.join("\r\n") + "\r\n";
+ area.value = v.slice(0, pos) + add + v.slice(pos);
+ area.selectionStart = area.selectionEnd = pos + add.length;
+ area.focus();
+ }
+
+ function bind(ctx, app) {
+ const $ = (id) => document.getElementById(id);
+ const code = ctx.uiState.selectedIlwiCode;
+ const u = ctx.uiState;
+ u.editedSmtMap = u.editedSmtMap || {};
+ document.querySelectorAll(".ilwi-sub-btn").forEach(
+ (b) => (b.onclick = () => ((u.ilwiSubFilter = b.dataset.sub), app.renderWorkspace())),
+ );
+ document.querySelectorAll(".ilwi-master-row").forEach(
+ (tr) => (tr.onclick = () => ((u.selectedIlwiCode = tr.dataset.code), (u.compSel = []), app.renderWorkspace())),
+ );
+ if ($("san-btn-eq")) $("san-btn-eq").onclick = () => ((u.activeScreen = "wM_Edit_EQ"), app.renderWorkspace());
+ const area = $("smt-formula-textarea");
+ if (!area) return COMP().bind(ctx, code, app);
+ const keep = () => (u.editedSmtMap[code] = area.value);
+ area.oninput = keep;
+ const redo = () => (keep(), app.renderWorkspace());
+ area.onkeydown = (e) => {
+ if (e.key === "F9") e.preventDefault(), $("san-calc").click();
+ };
+ const pick = (id, fn) =>
+ $(id) &&
+ ($(id).onchange = (e) => {
+ if (e.target.value === "") return;
+ fn(e.target.value);
+ e.target.value = "";
+ redo();
+ });
+ pick("san-func", (v) => insertAt(area, v));
+ pick("san-mark", (v) => insertLines(area, [v]));
+ pick("san-qty", (v) => insertAt(area, v));
+ pick("san-eq", (v) => {
+ const it = (u.eqItems || [])[Number(v)];
+ if (!it) return;
+ // 「공식」 = 계산식 줄만 · 「공식+코드」 = 단가코드 줄까지
+ const ls = String(it.text || "").split(/\r?\n/);
+ insertLines(area, it.ins_x ? ls : ls.filter((l) => !/&[^&]+&/.test(l)));
+ });
+ const keepOpt = (id, key) => $(id) && ($(id).onchange = (e) => (u[key] = e.target.type === "checkbox" ? e.target.checked : e.target.value));
+ [["san-trans", "sanTrans"], ["san-op", "sanOp"], ["san-qv", "sanQv"], ["san-skip0", "sanSkip0"], ["san-code", "sanCode"]].forEach(([i, k]) => keepOpt(i, k));
+ $("san-ins").onclick = () => {
+ const c = $("san-code").value.trim().toUpperCase();
+ const r = ENG().costnLookup(ctx).byCode[c];
+ if (!r) return alert(`${c || "(빈 코드)"} 는 등록된 단가코드가 아님`);
+ if (c === code) return alert("자기 자신은 넣을 수 없음(순환참조)");
+ insertLines(area, L().codeLines(c, r, { trans: $("san-trans").value, op: $("san-op").value, qtyVar: $("san-qv").value, skipZero: $("san-skip0").checked }));
+ redo();
+ };
+ $("san-strip").onclick = () => ((area.value = L().stripResults(area.value)), redo());
+ $("san-help").onclick = () => ((u.sanHelp = !u.sanHelp), app.renderWorkspace());
+ $("san-hilite").onclick = () => ((u.sanHilite = u.sanHilite === false), app.renderWorkspace());
+ document.querySelectorAll(".san-err").forEach(
+ (el) =>
+ (el.onclick = () => {
+ const n = Number(el.dataset.line);
+ if (!n) return;
+ const ls = area.value.split("\n");
+ const s = ls.slice(0, n - 1).join("\n").length + (n > 1 ? 1 : 0);
+ area.focus();
+ area.setSelectionRange(s, s + (ls[n - 1] || "").length);
+ }),
+ );
+ const row = ENG().costnLookup(ctx).byCode[code] || {};
+ $("san-sbmok").onchange = (e) => ((row.SBMOK = e.target.value), redo());
+ $("san-adj").onchange = (e) => {
+ row.S_ADJ = e.target.checked ? "T" : "F";
+ for (const k of "MLS") if (!num(row[`ADJ_${k}`])) row[`ADJ_${k}`] = "1";
+ redo();
+ };
+ document.querySelectorAll(".san-adjv").forEach((el) => (el.onchange = () => ((row[`ADJ_${el.dataset.k}`] = String(num(el.value))), redo())));
+ $("san-calc").onclick = () => {
+ keep();
+ const txt = area.value;
+ const ev = ENG().evaluateSmtFormulaText(txt, ctx, code);
+ const cyc = cycleErrors(ctx, code);
+ const n = ev.errors.length + cyc.length;
+ if (n && !confirm(`문법 오류 ${n}건 — 그래도 계산 결과를 단가에 반영할까요?\n${ev.errors.slice(0, 5).map((e) => `${e.lineNo}번째줄: ${e.msg}`).concat(cyc).join("\n")}`)) return app.renderWorkspace();
+ const memos = ctx.dbfData.costn_memos || (ctx.dbfData.costn_memos = {});
+ memos[code] = Object.assign({ code, blockNo: 0 }, memos[code], { text: txt, byteLen: txt.length });
+ global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice(ctx, code, ev.sumM, ev.sumL, ev.sumS);
+ app.cascadeAndRender();
+ };
+ }
+
+ P.screens.wM_Edit_iLWi = { render, bind };
+ global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines };
+})(window);
diff --git a/tools/stmate-test/scr_qty_sheet.js b/tools/stmate-test/scr_qty_sheet.js
new file mode 100644
index 000000000..e1aeaad29
--- /dev/null
+++ b/tools/stmate-test/scr_qty_sheet.js
@@ -0,0 +1,289 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 수량산출서 계산 (scr_qty_sheet.js) — 시트 셈 · 셀별명(확장 수량변수) · 수량코드(기본 수량변수) ·
+ * 내역 가져가기 · 코드 종류별 수량 · 내역↔BDQTY 수량 연결(자원 역집계) · 순수 계산
+ * 근거: 지식DB STmate/old/20_분석 28 §2(기본수량변수 ↔ 확장수량변수(셀별명) · 전송 · 수량산출서 파일) ·
+ * 02(QCODE CODE·NAME·BQCODE·QTY0~9·IFORM·XCODE) · 10(ST_U_ConBD CON_QTY · BOQ↔BDQTY) ·
+ * REA 28 TWQTYLIST(내역명칭·규격·단위·수량·시작줄·끝줄·단가·공종번호·내역코드 필드 · 명칭/코드 기준 가져가기)
+ * · 수량모듈 중간메모(시작~끝 작은 행부터 양 끝 포함 · Tamt1 = Mamt1+Lamt1+Samt1 · 수량 곱셈 없음)
+ * · REA 30 quantity_variable_resolution(QTY0~9 직접 · XCODE 「이름=값」 · 빈 값 0 · 없는 이름 오류)
+ */
+const StmateQtySheet = (() => {
+ const X = () =>
+ (typeof window !== "undefined" && window.STMATE_SMT_EXPR) ||
+ (typeof require !== "undefined" ? require("./scr_edit_expr.js") : null);
+ const num = (v) => {
+ const n = parseFloat(String(v == null ? "" : v).replace(/,/g, ""));
+ return Number.isFinite(n) ? n : 0;
+ };
+ const SHEET_SIG = "STMATE-QTY-SHEET/1";
+
+ // 열 이름 ↔ 번호(A=0)
+ const colName = (i) => (i < 26 ? String.fromCharCode(65 + i) : colName(Math.floor(i / 26) - 1) + colName(i % 26));
+ const colIndex = (s) => [...String(s).toUpperCase()].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
+ const addr = (r, c) => `${colName(c)}${r + 1}`;
+ const parseAddr = (a) => {
+ const m = String(a).toUpperCase().match(/^([A-Z]{1,2})(\d+)$/);
+ return m ? { r: Number(m[2]) - 1, c: colIndex(m[1]) } : null;
+ };
+
+ function newSheet(rows = 30, cols = 8) {
+ return { sig: SHEET_SIG, rows, cols, cells: {}, aliases: {}, roles: {}, start: 0, end: 0 };
+ }
+
+ /**
+ * 시트 셈 — 「=식」 칸은 셀 주소(A1)·셀별명을 변수로 평가 · 순환·오류 칸은 errors
+ * 돌려줌: { values: {A1: 수 또는 글자}, errors: {A1: 글}, aliasValues: {별명: 수} }
+ */
+ function evalSheet(sheet) {
+ const values = {},
+ errors = {},
+ state = {};
+ const aliasOf = {};
+ for (const [nm, a] of Object.entries(sheet.aliases || {})) aliasOf[nm.toUpperCase()] = String(a).toUpperCase();
+ const cellOf = (name) => aliasOf[name] || (parseAddr(name) ? name : null);
+ function calc(a) {
+ if (state[a] === 2) return values[a];
+ if (state[a] === 1) {
+ errors[a] = "셀참조 순환";
+ return 0;
+ }
+ state[a] = 1;
+ const raw = String((sheet.cells || {})[a] ?? "").trim();
+ let v;
+ if (raw.startsWith("=")) {
+ const vars = {};
+ const ids = (raw.slice(1).match(/[A-Za-z_][A-Za-z_0-9]*/g) || []).map((s) => s.toUpperCase());
+ for (const id of ids) {
+ const at = cellOf(id);
+ if (at && !(id in vars)) {
+ const sub = calc(at);
+ if (errors[at]) errors[a] = errors[a] || `셀참조 에러(${id})`;
+ vars[id] = typeof sub === "number" ? sub : num(sub);
+ }
+ }
+ const r = X().evaluate(raw.slice(1), vars);
+ if (r.error) errors[a] = r.error;
+ else if (r.missing.length) errors[a] = `없는 셀별명 「${r.missing[0]}」`;
+ v = r.value === null ? 0 : r.value;
+ } else if (raw !== "" && /^[-+]?[\d,]*\.?\d+$/.test(raw)) v = num(raw);
+ else v = raw;
+ values[a] = v;
+ state[a] = 2;
+ return v;
+ }
+ for (const a of Object.keys(sheet.cells || {})) calc(a.toUpperCase());
+ const aliasValues = {};
+ for (const [nm, a] of Object.entries(aliasOf)) {
+ const v = calc(a);
+ aliasValues[nm] = typeof v === "number" ? v : num(v);
+ if (errors[a]) errors[`@${nm}`] = errors[a];
+ }
+ return { values, errors, aliasValues };
+ }
+
+ /**
+ * 기본 수량변수 ← 확장 수량변수 — 수량코드 행 IFORM 의 「QTYn=식」 줄을 셀별명으로 셈해 QTYn 에 넣고
+ * XCODE 에 셀별명 값 전부를 「이름=값」 줄로 남김(방금 저장된 수량산출서의 확장수량변수 값)
+ */
+ function assignFromSheet(qrows, sheet) {
+ const ev = evalSheet(sheet);
+ const errors = [];
+ const xcode = Object.entries(ev.aliasValues)
+ .map(([k, v]) => `${k}=${v}`)
+ .join("\r\n");
+ const out = qrows.map((r) => {
+ const row = { ...r };
+ for (const ln of String(r.IFORM || "").split(/\r?\n/)) {
+ const m = ln.match(/^\s*(QTY\d)\s*=\s*(.+)$/i);
+ if (!m) continue;
+ const res = X().evaluate(m[2], { ...ev.aliasValues });
+ if (res.error || res.missing.length) {
+ errors.push({ code: r.CODE, msg: `${m[1]} — ${res.error || `없는 셀별명 「${res.missing[0]}」`}` });
+ continue;
+ }
+ row[m[1].toUpperCase()] = String(res.value);
+ }
+ row.XCODE = xcode;
+ return row;
+ });
+ return { rows: out, errors, sheetErrors: ev.errors };
+ }
+
+ // 수량코드 검사(F3-29) — 형식 · 중복 · 내역코드 · 수치 · XCODE 줄 · IFORM 줄
+ function checkQcodes(qrows, costnCodes) {
+ const errs = [];
+ const seen = new Set();
+ const codes = costnCodes instanceof Set ? costnCodes : new Set(costnCodes || []);
+ qrows.forEach((r, i) => {
+ const c = String(r.CODE || "").trim().toUpperCase();
+ const at = `${i + 1}행 ${c || "(빈 코드)"}`;
+ if (!/^Q\d{5}$/.test(c)) errs.push(`${at}: 수량코드는 Q + 숫자 다섯 자리`);
+ if (seen.has(c)) errs.push(`${at}: 같은 수량코드가 이미 있음`);
+ seen.add(c);
+ const bq = String(r.BQCODE || "").trim();
+ if (bq && codes.size && !codes.has(bq)) errs.push(`${at}: 내역코드 ${bq} 는 단가목록에 없음`);
+ for (let k = 0; k < 10; k++) {
+ const v = String(r[`QTY${k}`] ?? "").trim();
+ if (v && !/^[-+]?[\d,]*\.?\d+$/.test(v)) errs.push(`${at}: QTY${k} 값 「${v}」 은 숫자가 아님`);
+ }
+ String(r.XCODE || "")
+ .split(/\r?\n/)
+ .filter((ln) => ln.trim())
+ .forEach((ln) => {
+ if (!/^\s*[A-Za-z가-힣_][^=]*=/.test(ln)) errs.push(`${at}: 확장수량변수 줄 「${ln}」 은 이름=값 꼴이 아님`);
+ });
+ String(r.IFORM || "")
+ .split(/\r?\n/)
+ .filter((ln) => ln.trim())
+ .forEach((ln) => {
+ if (!/^\s*QTY\d\s*=/.test(ln)) errs.push(`${at}: 연결식 줄 「${ln}」 은 QTYn=식 꼴이 아님`);
+ });
+ });
+ return errs;
+ }
+
+ // 다음 수량코드 번호
+ function nextQcode(qrows) {
+ const max = qrows.reduce((m, r) => Math.max(m, num(String(r.CODE || "").slice(1))), 0);
+ return "Q" + String(max + 1).padStart(5, "0");
+ }
+
+ /**
+ * 내역서로 가져가기 — 시작~끝 줄(작은 쪽부터 양 끝 포함) · mode code = 내역코드 칸 · name = 명칭 칸
+ * 돌려줌 [{ row, code, name, size, unit, qty, M, L, S, T, itnum }] · 단가 T = M+L+S(수량 곱 없음)
+ */
+ function sheetToBoq(sheet, mode = "code") {
+ const ev = evalSheet(sheet);
+ const roles = sheet.roles || {};
+ const cell = (role, r) => (roles[role] === undefined || roles[role] === "" ? "" : ev.values[addr(r, colIndex(roles[role]))]);
+ const need = mode === "code" ? ["code", "qty"] : ["name", "size", "unit", "qty"];
+ const missing = need.filter((k) => roles[k] === undefined || roles[k] === "");
+ if (missing.length) return { items: [], error: `셀별명 설정 안 됨: ${missing.join(", ")}` };
+ const a = Math.min(sheet.start, sheet.end),
+ b = Math.max(sheet.start, sheet.end);
+ const items = [];
+ for (let r = a; r <= b; r++) {
+ const code = String(cell("code", r) ?? "").trim();
+ const name = String(cell("name", r) ?? "").trim();
+ if (mode === "code" ? !code : !name) continue;
+ const M = num(cell("mamt", r)),
+ L = num(cell("lamt", r)),
+ S = num(cell("samt", r));
+ items.push({
+ row: r + 1,
+ code,
+ name,
+ size: String(cell("size", r) ?? ""),
+ unit: String(cell("unit", r) ?? ""),
+ qty: num(cell("qty", r)),
+ M,
+ L,
+ S,
+ T: M + L + S,
+ itnum: String(cell("no", r) ?? ""),
+ });
+ }
+ return { items, error: null };
+ }
+
+ // 코드 종류(wQtyList · wM_JabPum cBoxCode) — 모든단가 · 노무 · 재료 · 경비 · 일위대가 · 중기 · 단가산출 · 견적 · 대가산근
+ const KINDS = [
+ ["ALL", "모든단가", () => true],
+ ["L", "노무비", (c) => c[0] === "L"],
+ ["M", "재료비", (c) => c[0] === "M"],
+ ["S", "경비", (c) => c[0] === "S"],
+ ["B", "일위대가", (c) => c[0] === "B"],
+ ["X", "중기비", (c) => c[0] === "X"],
+ ["D", "단가산출", (c) => c[0] === "D"],
+ ["W", "견적단가", (c) => c[0] === "W"],
+ ["BD", "대가,산근", (c) => c[0] === "B" || c[0] === "D"],
+ ];
+ const STRUCT = /^[TCE]\d*$/;
+
+ // 코드 종류별 내역 수량(F3-24) — 내역 단가코드 행의 수량을 코드별로 모음
+ function qtyByCode(boqRows, kind = "ALL", field = "QTY1") {
+ const test = (KINDS.find((k) => k[0] === kind) || KINDS[0])[2];
+ const by = {};
+ for (const r of boqRows || []) {
+ const c = String(r.BDCODE1 || "").trim();
+ if (!c || STRUCT.test(c) || !test(c)) continue;
+ const o = (by[c] = by[c] || { code: c, name: r.NAME1 || "", size: r.SIZE1 || "", unit: r.UNIT1 || "", qty: 0, rows: 0 });
+ o.qty += num(r[field]);
+ o.rows += 1;
+ }
+ return Object.values(by).sort((a, b) => a.code.localeCompare(b.code));
+ }
+
+ /**
+ * 내역↔BDQTY 수량 연결(F3-28 · ST_U_ConBD CON_QTY) — 내역 수량 × 구성 수량을 하위로 펼쳐
+ * 기초 자원(L·M·S)과 중간 호표(B·D·X)의 소요 수량을 모음 · 비율(PR·RR)·구분(T·C) 줄은 건너뜀
+ * 돌려줌 { totals: {코드: 수량}, cycles: [경로] }
+ */
+ function rollup(boqRows, bdqtyRows, field = "QTY1") {
+ const groups = {};
+ for (const r of bdqtyRows || []) (groups[String(r.BDCODE || "").trim()] = groups[String(r.BDCODE || "").trim()] || []).push(r);
+ const totals = {},
+ cycles = [];
+ const qOf = (row) => {
+ const q = num(row.QTY1);
+ return ["T", "t", "1", true].includes(row.DEVD1) ? (q ? 1 / q : 0) : q;
+ };
+ const walk = (code, qty, path) => {
+ totals[code] = (totals[code] || 0) + qty;
+ const g = groups[code];
+ if (!g) return;
+ if (path.includes(code)) return cycles.push([...path, code].join(" → "));
+ for (const row of g) {
+ const c = String(row.CODE || "").trim();
+ if (!c || /^(PR|RR)$/.test(c) || STRUCT.test(c)) continue;
+ walk(c, qty * qOf(row), [...path, code]);
+ }
+ };
+ for (const r of boqRows || []) {
+ const c = String(r.BDCODE1 || "").trim();
+ if (!c || STRUCT.test(c)) continue;
+ const q = num(r[field]);
+ if (q) walk(c, q, []);
+ }
+ return { totals, cycles };
+ }
+
+ // 수량산출서 파일 — 서명 검사(호환되지 않는 파일형식 알림)
+ function serialize(sheet, qrows) {
+ return JSON.stringify({ sig: SHEET_SIG, sheet, qcode: qrows || [] });
+ }
+ function deserialize(text) {
+ let d;
+ try {
+ d = JSON.parse(text);
+ } catch (_e) {
+ return { error: "수량산출서로 저장하려는 파일이 손상되었거나 호환되지 않는 파일형식" };
+ }
+ if (!d || d.sig !== SHEET_SIG || !d.sheet || typeof d.sheet.cells !== "object")
+ return { error: "호환되지 않는 파일형식 — STMATE 수량산출서가 아님" };
+ return { sheet: Object.assign(newSheet(), d.sheet), qcode: Array.isArray(d.qcode) ? d.qcode : [] };
+ }
+
+ return {
+ SHEET_SIG,
+ KINDS,
+ colName,
+ colIndex,
+ addr,
+ parseAddr,
+ newSheet,
+ evalSheet,
+ assignFromSheet,
+ checkQcodes,
+ nextQcode,
+ sheetToBoq,
+ qtyByCode,
+ rollup,
+ serialize,
+ deserialize,
+ };
+})();
+
+if (typeof window !== "undefined") window.STMATE_QTY_SHEET = StmateQtySheet;
+if (typeof module !== "undefined" && module.exports) module.exports = StmateQtySheet;
diff --git a/tools/stmate-test/scr_stage.js b/tools/stmate-test/scr_stage.js
new file mode 100644
index 000000000..68ae9ff53
--- /dev/null
+++ b/tools/stmate-test/scr_stage.js
@@ -0,0 +1,527 @@
+/* -*- coding: utf-8 -*- */
+/**
+ * 설계 후속 단계 화면 틀 (scr_stage.js) — wStage 대체 · 탭(설계변경·대비표·계약·실행·기성) · 보고서 인쇄·엑셀 공용
+ * 근거: 지식DB STmate/old/20_분석 27(계약·실행·기성·준공) · 08 §2(변경설계) · 35 §3 · 20 §3(SET_REP 0623~0674)
+ * 계산: change_calc.js · contract_calc.js · progress_calc.js · exec_calc.js (여기서 불러옴)
+ * 탭 = STMATE_STAGE.addTab({ key, label, order, render(ctx, api, S), bind(ctx, api, S) })
+ */
+(function (global) {
+ "use strict";
+ // 계산 모듈 — 화면 파일보다 먼저 실행되도록 문서 파싱 중 끼워 넣음
+ if (global.document && global.document.readyState === "loading")
+ [
+ ["STMATE_CHANGE", "change_calc.js"],
+ ["STMATE_CONTRACT", "contract_calc.js"],
+ ["STMATE_PROGRESS", "progress_calc.js"],
+ ["STMATE_EXEC", "exec_calc.js"],
+ ].forEach(([g, f]) => !global[g] && global.document.write(`