From f00612e7afb75a798e16be52ed9c337c9d13463e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 18:42:10 +0900 Subject: [PATCH] auto: 2026-11-13 18:42 (EOMSANGDON-HOME) --- resources/tester/helper_stmate_report.cjs | 22 + resources/tester/helper_stmate_stage.cjs | 102 +++ resources/tester/test_stmate_report_sheets.py | 173 ++++ resources/tester/test_stmate_stage_api.py | 65 ++ resources/tester/test_stmate_stage_change.py | 188 +++++ .../tester/test_stmate_stage_contract.py | 169 ++++ .../tester/test_stmate_stage_progress.py | 132 +++ tools/stmate-test/api_qty.py | 125 +++ tools/stmate-test/api_stage.py | 167 ++++ tools/stmate-test/app.js | 172 +--- tools/stmate-test/app_modals.js | 163 +--- tools/stmate-test/calc.js | 68 +- tools/stmate-test/change_calc.js | 561 +++++++++++++ tools/stmate-test/contract_calc.js | 269 +++++++ tools/stmate-test/exec_calc.js | 149 ++++ tools/stmate-test/index.html | 138 +--- tools/stmate-test/kanjub_calc.js | 753 ++++++++++++------ tools/stmate-test/kanjub_cfg.py | 180 +++-- tools/stmate-test/kanjub_parts.js | 362 +++++++++ tools/stmate-test/progress_calc.js | 231 ++++++ tools/stmate-test/qty_qcode.py | 93 +++ tools/stmate-test/report_book.js | 135 ++++ tools/stmate-test/report_boq.js | 415 ++++++++++ tools/stmate-test/report_core.js | 256 ++++++ tools/stmate-test/report_unit.js | 293 +++++++ tools/stmate-test/scr_edit_comp.js | 249 ++++++ tools/stmate-test/scr_edit_expr.js | 268 +++++++ tools/stmate-test/scr_edit_lang.js | 146 ++++ tools/stmate-test/scr_edit_price.js | 88 ++ tools/stmate-test/scr_edit_san.js | 289 +++++++ tools/stmate-test/scr_qty_sheet.js | 289 +++++++ tools/stmate-test/scr_stage.js | 527 ++++++++++++ tools/stmate-test/scr_stage_change.js | 225 ++++++ tools/stmate-test/scr_stage_contract.js | 367 +++++++++ tools/stmate-test/scr_stage_exec.js | 364 +++++++++ tools/stmate-test/scr_stage_progress.js | 349 ++++++++ tools/stmate-test/scr_ui_boq.js | 285 +++++++ tools/stmate-test/scr_ui_boq_dlg.js | 248 ++++++ tools/stmate-test/scr_ui_dan.js | 202 +++++ tools/stmate-test/scr_ui_prj.js | 212 +++++ tools/stmate-test/scr_ui_sys.js | 136 ++++ tools/stmate-test/smt_engine.js | 116 ++- tools/stmate-test/smt_eval.js | 339 ++++++-- tools/stmate-test/ui_app_menu.js | 206 +++++ tools/stmate-test/ui_boq.js | 373 +++++++++ tools/stmate-test/ui_boq_tools.js | 171 ++++ tools/stmate-test/ui_dan.js | 201 +++++ tools/stmate-test/ui_menu.js | 365 +++++++++ tools/stmate-test/ui_style.css | 199 +++++ tools/stmate-test/ui_sys.js | 177 ++++ 50 files changed, 10918 insertions(+), 854 deletions(-) create mode 100644 resources/tester/helper_stmate_report.cjs create mode 100644 resources/tester/helper_stmate_stage.cjs create mode 100644 resources/tester/test_stmate_report_sheets.py create mode 100644 resources/tester/test_stmate_stage_api.py create mode 100644 resources/tester/test_stmate_stage_change.py create mode 100644 resources/tester/test_stmate_stage_contract.py create mode 100644 resources/tester/test_stmate_stage_progress.py create mode 100644 tools/stmate-test/api_qty.py create mode 100644 tools/stmate-test/api_stage.py create mode 100644 tools/stmate-test/change_calc.js create mode 100644 tools/stmate-test/contract_calc.js create mode 100644 tools/stmate-test/exec_calc.js create mode 100644 tools/stmate-test/kanjub_parts.js create mode 100644 tools/stmate-test/progress_calc.js create mode 100644 tools/stmate-test/qty_qcode.py create mode 100644 tools/stmate-test/report_book.js create mode 100644 tools/stmate-test/report_boq.js create mode 100644 tools/stmate-test/report_core.js create mode 100644 tools/stmate-test/report_unit.js create mode 100644 tools/stmate-test/scr_edit_comp.js create mode 100644 tools/stmate-test/scr_edit_expr.js create mode 100644 tools/stmate-test/scr_edit_lang.js create mode 100644 tools/stmate-test/scr_edit_price.js create mode 100644 tools/stmate-test/scr_edit_san.js create mode 100644 tools/stmate-test/scr_qty_sheet.js create mode 100644 tools/stmate-test/scr_stage.js create mode 100644 tools/stmate-test/scr_stage_change.js create mode 100644 tools/stmate-test/scr_stage_contract.js create mode 100644 tools/stmate-test/scr_stage_exec.js create mode 100644 tools/stmate-test/scr_stage_progress.js create mode 100644 tools/stmate-test/scr_ui_boq.js create mode 100644 tools/stmate-test/scr_ui_boq_dlg.js create mode 100644 tools/stmate-test/scr_ui_dan.js create mode 100644 tools/stmate-test/scr_ui_prj.js create mode 100644 tools/stmate-test/scr_ui_sys.js create mode 100644 tools/stmate-test/ui_app_menu.js create mode 100644 tools/stmate-test/ui_boq.js create mode 100644 tools/stmate-test/ui_boq_tools.js create mode 100644 tools/stmate-test/ui_dan.js create mode 100644 tools/stmate-test/ui_menu.js create mode 100644 tools/stmate-test/ui_style.css create mode 100644 tools/stmate-test/ui_sys.js 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 — 경영정보(주) 원가·내역 통합 시스템 (독립 재현판) +