diff --git a/resources/tester/helper_stmate_kanjub_loop.cjs b/resources/tester/helper_stmate_kanjub_loop.cjs new file mode 100644 index 000000000..b74928234 --- /dev/null +++ b/resources/tester/helper_stmate_kanjub_loop.cjs @@ -0,0 +1,30 @@ +// STMATE 재현 앱 요율 순환 점검·수동 확정(scr_kanjub_rate.js) — stdin {matrix, scales} · 규모를 차례로 돌려 순환을 만듦 +const R = require("../../tools/stmate-test/scr_kanjub_rate.js"); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const { matrix, scales } = JSON.parse(buf); + const m = R.matrixFromRows(matrix); + const cfg = { keys: {}, rates: {} }; + let i = 0; + const calc = () => ({ dT: scales[i++ % scales.length], anjunBaseRaw: 0 }); + const first = R.applyWithLoopCheck(cfg, m, calc); + const picked = first.choices && first.choices[first.choices.length - 1]; + if (picked) R.confirmManual(cfg, picked); + const ratesAfterPick = Object.assign({}, cfg.rates); + const again = R.applyWithLoopCheck(cfg, m, calc); + const keep = JSON.stringify(cfg.rates) === JSON.stringify(ratesAfterPick); + R.clearManual(cfg); + const freed = R.applyWithLoopCheck(cfg, m, calc); + process.stdout.write( + JSON.stringify({ + loop: first.loop, + choices: (first.choices || []).map((c) => c.band.name), + pickedBand: picked && picked.band.name, + againManual: !!again.manual, + keep, + freedLoop: freed.loop, + }), + ); +}); diff --git a/resources/tester/helper_stmate_report.cjs b/resources/tester/helper_stmate_report.cjs index f0ba10d13..aa3b7de53 100644 --- a/resources/tester/helper_stmate_report.cjs +++ b/resources/tester/helper_stmate_report.cjs @@ -1,4 +1,4 @@ -// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage, mode(pps = 조달청 XML) } · 연쇄 재계산(계산완료) 뒤 시트 모형 +// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage, mode(pps = 조달청 XML · forms = 양식 변형), opts } · 연쇄 재계산(계산완료) 뒤 시트 모형 globalThis.window = globalThis; globalThis.document = globalThis.document || { readyState: "complete" }; // 노드 — 화면 전용 분기 막음 require("../../tools/stmate-test/calc.js"); @@ -14,7 +14,7 @@ let buf = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { - const { load, stage, mode } = JSON.parse(buf); + const { load, stage, mode, opts } = 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); @@ -23,7 +23,21 @@ process.stdin.on("end", () => { process.stdout.write(JSON.stringify({ xml })); return; } - const book = Book.buildBook(load, ctx.state, computed, stage || 1); + if (mode === "forms") { + const U = require("../../tools/stmate-test/report_unit.js"); + const Fm = require("../../tools/stmate-test/report_forms.js"); + const u = U.unitContext(load); + const forms = Fm.buildFormSheets(u, load, ctx.state, computed, stage || 1, opts || {}); + const out = {}; + for (const [id, sh] of Object.entries(forms)) out[id] = { name: sh.name, rows: sh.rows }; + out._grand = + computed.stages && computed.stages[stage || 1] + ? computed.stages[stage || 1].kanjub.grandTotal + : null; + process.stdout.write(JSON.stringify(out)); + return; + } + const book = Book.buildBook(load, ctx.state, computed, stage || 1, opts || {}); 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_smt_opts.cjs b/resources/tester/helper_stmate_smt_opts.cjs index 390c2721b..a7b3e30ab 100644 --- a/resources/tester/helper_stmate_smt_opts.cjs +++ b/resources/tester/helper_stmate_smt_opts.cjs @@ -9,7 +9,10 @@ process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const { cases } = JSON.parse(buf); const out = cases.map((c) => { - const ctx = { dbfData: { start_ini: c.ini || {}, costn_rows: [] }, state: { sysConfig: c.sys || {} } }; + const ctx = { + dbfData: { start_ini: c.ini || {}, costn_rows: [] }, + state: { sysConfig: c.sys || {} }, + }; const opts = window.STMATE_ENGINE.compositionOptions(ctx, c.code || "X00001", c.row || {}); const lookup = (code) => c.table[code] || null; const ev = window.STMATE_SMT_EVAL.evaluateComposition(c.rows, lookup, opts); diff --git a/resources/tester/test_stmate_kanjub_boq.py b/resources/tester/test_stmate_kanjub_boq.py index a5c43df76..03399c6ea 100644 --- a/resources/tester/test_stmate_kanjub_boq.py +++ b/resources/tester/test_stmate_kanjub_boq.py @@ -50,11 +50,11 @@ def test_boq_rows_match_stc_bimok(name): out = _run(_with_pr(loaded)) bad = [] for n, items in out.items(): - for rid, code, m, l, s in items: + for rid, code, m, lab, s in items: r = rows[int(rid.split("_")[1]) - 1] exp = [float(r.get(f"{c}_AMT{n}") or 0) for c in "MLS"] - if [m, l, s] != exp: - bad.append((n, code, [m, l, s], exp)) + if [m, lab, s] != exp: + bad.append((n, code, [m, lab, s], exp)) assert not bad, f"{name} 줄 금액 불일치 {bad[:5]}" diff --git a/resources/tester/test_stmate_kanjub_rate.py b/resources/tester/test_stmate_kanjub_rate.py index dd5634a8a..5e59a916a 100644 --- a/resources/tester/test_stmate_kanjub_rate.py +++ b/resources/tester/test_stmate_kanjub_rate.py @@ -106,3 +106,21 @@ def test_rates_come_from_table(name): best = scored[0] # 불일치 = 정확히 수동 목록(더 많으면 규칙 오류 · 적으면 MANUAL 이 버그를 숨김) assert best[2] == MANUAL.get(name, set()), f"{name} {n}차수 표 {best[1]} 불일치 {best[2]}" + + +LOOP_HELPER = Path(__file__).with_name("helper_stmate_kanjub_loop.cjs") + + +def test_rate_loop_stops_and_manual_confirm_holds(): + """06 §2 계약 — 규모 구간이 왕복하면 멈추고 오간 구간을 돌려줌 · 수동 확정 뒤에는 다시 고르지 않음.""" + loaded = server.api_load(server.LoadReq(source_path=str(DOCS / STCS["봉화2024"]))) + req = {"matrix": loaded["rate_tables"]["RATE_R.DBF"], "scales": [4.9e9, 5.1e9]} + out = json.loads( + subprocess.check_output( + ["node", str(LOOP_HELPER)], input=json.dumps(req), text=True, encoding="utf-8" + ) + ) + assert out["loop"] is True + assert sorted(out["choices"]) == ["50억 미만", "50억~300억 미만"] + assert out["againManual"] is True and out["keep"] is True + assert out["freedLoop"] is True diff --git a/resources/tester/test_stmate_report_coll.py b/resources/tester/test_stmate_report_coll.py new file mode 100644 index 000000000..ebb4cfa94 --- /dev/null +++ b/resources/tester/test_stmate_report_coll.py @@ -0,0 +1,174 @@ +"""STMATE 재현 앱 보고서 2차(G5) — 집계표 · 단가산출근거 펼침 · INITIAL 시트를 원본 STMATE 엑셀과 셀 대조. + +정답 = 실무 STC 옆 원본 출력 엑셀(계산된 값). 코드에 정답값을 박지 않음. +앱 쪽 = STC 불러오기 → 연쇄 재계산 → 보고서 묶음(report_book · report_coll · report_san · report_init). +울진 = 「변경 1차분」 STC 의 당초 차수 ↔ 착공 때 내보낸 엑셀 — 코드 번호가 뒤에 바뀌어 코드 비고 칸은 대조 밖. +빼는 칸: 원본 부속 칸(STmate 표지·바로가기·숨은 메타) · 파일 자리·작성자·날짜(INITIAL) · 앞뒤 빈칸. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_stmate_report_sheets import FULL, book, cell, norm, original # noqa: E402 + +COLL = { + "재료비수량금액집계표": "ABCDEFGH", + "노무비수량금액집계표": "ABCDEFGH", + "경비수량금액집계표": "ABCDEFGH", + "중기시간금액집계표": "ABCDEFGHIJ", +} +# 울진 착공 엑셀에만 있는 집계 — 코드 비고 칸(H·I·J) 빼고 +ULJIN = { + "일식견적목록표": "ABCDEFGH", + "재료비수량금액집계표": "ABCDEFG", + "노무비수량금액집계표": "ABCDEFG", + "경비수량금액집계표": "ABCDEFG", + "일식견적수량금액집계표": "ABCDEFG", + "중기시간금액집계표": "ABCDEFGHI", + "일위대가수량금액집계표": "ABCDEFGHI", + "단가산출근거수량금액집계표": "ABCDEFGHI", +} + + +def diffs_of(name, sid, cols, start=0): + mine = book(name)[sid] + orig = original(name, mine["name"]) + out = [] + for i in range(start, 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: + ov, mv = norm(cell(o, c)), norm(m.get(c)) + if isinstance(ov, str) and ov.endswith("→"): + continue + if ov != mv: + out.append((f"{c}{i + 1}", ov, mv)) + return out, len(orig) + + +@pytest.mark.parametrize("name", FULL) +@pytest.mark.parametrize("sid", list(COLL)) +def test_collection_sheet_matches_original(name, sid): + """수량금액집계표(0501~0503 · 0505) — 투입 수량 전개(대가 구성 × 산근 식 계수 · 제외 구간 뺌) · 금액 사사오입.""" + diffs, n = diffs_of(name, sid, COLL[sid], start=2) + assert n > 4, f"{name} {sid} 원본 시트 비어 있음" + assert not diffs, f"{name} {sid} 불일치 {len(diffs)}건 {diffs[:5]}" + + +@pytest.mark.parametrize("sid", list(ULJIN)) +def test_uljin_collection_and_w_list_match_original(sid): + """울진 착공 엑셀 — 일식·견적(0104 · 0504) · 일위대가(0506) · 산근(0507) 집계 · 단가는 낙찰률 적용 1칸.""" + diffs, n = diffs_of("울진착공", sid, ULJIN[sid], start=2) + assert n > 3, f"울진 {sid} 원본 시트 비어 있음" + assert not diffs, f"울진 {sid} 불일치 {len(diffs)}건 {diffs[:5]}" + + +@pytest.mark.parametrize("name", FULL) +def test_san_expansion_matches_original(name): + """단가산출근거(0402) — 식 줄 글(계산값·참조 단가 넣음) · 줄 금액 · 계표시 합 · 쪽 끝 총계 행 자리까지.""" + diffs, n = diffs_of(name, "단가산출근거", "ABCDEF", start=2) + assert n > 1000 + assert not diffs, f"{name} 단가산출근거 불일치 {len(diffs)}건 {diffs[:5]}" + + +@pytest.mark.parametrize("name", FULL) +def test_initial_sheet_matches_original(name): + """〓 INITIAL 〓 — 시트 정보 18칸 열 자리 · 공사정보 · 초기값 · 인쇄정보 · 절 머리 행 번호.""" + mine = book(name)["〓 INITIAL 〓"]["rows"] + orig = original(name, "〓 INITIAL 〓") + # 시트 마지막 행(K) — 설계·총괄내역서는 쪽 넘김·출력 단계 선택에 달려 대조 밖 + skip = {"D", "C25", "C77", "C78", "K16", "K17"} + bad = [] + for i, o in enumerate(orig): + m = mine[i] if i < len(mine) else {} + for c in "BCDEFGHIJKLMNOPQRSTUVWX": + if c in skip and 6 <= i + 1 <= 23 or f"{c}{i + 1}" in skip: + continue + ov, mv = cell(o, c), m.get(c) + if ov in ("", None) and mv in ("", None): + continue + if ov != mv: + bad.append((f"{c}{i + 1}", ov, mv)) + assert len(orig) > 70 and not bad, f"{name} INITIAL 불일치 {bad[:5]}" + + +def test_amount_to_hangul_follows_doc_example(): + """AMTTOHAN — 지식DB 24 §2.4 예 「금 일억삼천사백이십만원정」.""" + import json + import subprocess + + js = ( + "const I=require('./tools/stmate-test/report_init.js');" + "process.stdout.write(JSON.stringify([I.amtToHan(134200000),I.amtToHan(0),I.amtToHan(1010001)]))" + ) + root = Path(__file__).resolve().parents[2] + got = json.loads(subprocess.check_output(["node", "-e", js], cwd=root, encoding="utf-8")) + assert got == ["금 일억삼천사백이십만원정", "금 영원정", "금 일백일만일원정"] + + +def _forms(name, opts): + import json + import subprocess + + import server + from test_stmate_report_sheets import DOCS, HELPER, PAIRS + + 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, "mode": "forms", "opts": opts}, ensure_ascii=False + ), + text=True, + encoding="utf-8", + ) + return json.loads(out) + + +def test_forms_without_original_sample_agree_with_engine_totals(): + """견본 없는 양식(부분) — 분할 100% = 설계 합계 · 총괄집계표·간이명세서 끝 = 총공사비 · 구.시도 시기금+구기금 = 합계.""" + f = _forms("봉화2024", {"splitRate": 100, "fundRate": 60}) + sp = [r for r in f["분할내역서"]["rows"] if r.get("_k") == "final"][0] + assert sp["G"] == sp["I"] and sp["J"] == 100 + total = [r for r in f["총괄집계표"]["rows"] if r.get("_k") == "cost"] + assert total[-1]["C"] == f["_grand"] and abs(total[-1]["D"] - 100) < 1e-9 + simple = [r for r in f["간이명세서"]["rows"] if r.get("_k") == "cost"] + assert simple[-1]["B"] == f["_grand"] + fund = [r for r in f["설계내역서구시도"]["rows"] if r.get("_k") == "item"] + assert fund and all(r["E"] + r["F"] == r["G"] for r in fund) + # 공종별 재료비 집계(0675) 금액 합 = 재료비 수량금액집계표(0501) 수량 기반 — 같은 전개에서 나옴 + mat = [r for r in f["재료비집계표"]["rows"] if r.get("_k") == "item"] + assert mat and all(r["C"] for r in mat) + + +def test_xlsx_formulas_and_print_options(): + """엑셀 수식 넣기(Prn_FMLA_INFO) — 집계 ROUND · 내역 ROUNDDOWN 수식 · 인쇄 옵션(글꼴·방향).""" + from io import BytesIO + + import openpyxl + import server + from fastapi.testclient import TestClient + + client = TestClient(server.app) + bk = book("봉화2024") + sheets = [bk["재료비수량금액집계표"], bk["설계내역서"]] + body = {"sheets": sheets, "filename": "f.xlsx", "formulas": True} + body["print"] = {"font": "돋움체", "orient": "portrait"} + res = client.post("/api/report/xlsx", json=body) + assert res.status_code == 200 + wb = openpyxl.load_workbook(BytesIO(res.content)) + ws = wb[sheets[0]["name"]] + assert ws["G4"].value == "=ROUND(D4*F4,0)" and ws["B4"].font.name == "돋움체" + assert ws.page_setup.orientation == "portrait" + d = wb[sheets[1]["name"]] + fml = [ + c.value + for row in d.iter_rows() + for c in row + if isinstance(c.value, str) and "ROUNDDOWN" in c.value + ] + assert fml and fml[0].startswith("=ROUNDDOWN(") diff --git a/resources/tester/test_stmate_smt_opts.py b/resources/tester/test_stmate_smt_opts.py index 8df0bd972..490a3e0ec 100644 --- a/resources/tester/test_stmate_smt_opts.py +++ b/resources/tester/test_stmate_smt_opts.py @@ -47,7 +47,9 @@ def test_line_digits_by_kind(): def test_line_digits_change_sum(): rows = [{"CODE": "M00001", "QTY1": 0.33}] * 3 # 109.89 × 3 one, zero, two = run( - comp(rows, M1, {"KCDNM": "1"}), comp(rows, M1, {"KCDNM": "0"}), comp(rows, M1, {"KCDNM": "2"}) + comp(rows, M1, {"KCDNM": "1"}), + comp(rows, M1, {"KCDNM": "0"}), + comp(rows, M1, {"KCDNM": "2"}), ) assert (one["M"], zero["M"], two["M"]) == (329, 327, 329) # 329.4 · 327 · 329.67 @@ -90,5 +92,7 @@ def test_foreign_units_and_usd_band(): def test_usd_real_case_ulljin(): # 울진 X00127 트럭트랙터 — S00346 49594$ × 1470 ÷ 1000 × 0.2529 = 18437(저장 SAMT1) table = {"S00346": {"M": 0, "L": 0, "S": 49594, "unit": "$"}} - (r,) = run(comp([{"CODE": "S00346", "QTY1": 0.2529}], table, {"EXLNM1": "1470", "EXHNM1": "1470"})) + (r,) = run( + comp([{"CODE": "S00346", "QTY1": 0.2529}], table, {"EXLNM1": "1470", "EXHNM1": "1470"}) + ) assert r["S"] == 18437 diff --git a/resources/tester/test_stmate_smt_tools.py b/resources/tester/test_stmate_smt_tools.py index 432a282d2..aade8fa56 100644 --- a/resources/tester/test_stmate_smt_tools.py +++ b/resources/tester/test_stmate_smt_tools.py @@ -21,8 +21,14 @@ def call(*calls): def test_xrate_daily_and_applied_quantity(): lab, eq, zero = call( - ("compute", {"qty1": 120, "cond": "0.9*1.1", "hrat": 80, "lqty": 3, "labor": True, "point": 4}), - ("compute", {"qty1": 120, "cond": "", "hrat": 100, "lqty": 1, "labor": False, "auto": True}), + ( + "compute", + {"qty1": 120, "cond": "0.9*1.1", "hrat": 80, "lqty": 3, "labor": True, "point": 4}, + ), + ( + "compute", + {"qty1": 120, "cond": "", "hrat": 100, "lqty": 1, "labor": False, "auto": True}, + ), ("compute", {"qty1": 0, "cond": "", "hrat": 100, "lqty": 2, "labor": True, "point": 3}), ) # 120 × 0.99 × 80% = 95.04 · 노무 3 × 1 ÷ 95.04 = 0.031565… → 4 자리 0.0316 diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md index 869c98f41..a3a22c042 100644 --- a/tools/stmate-test/PLAN.md +++ b/tools/stmate-test/PLAN.md @@ -27,9 +27,9 @@ ## 진행률 -- 합계: 전체 246 · 구현 131 · 부분 66 · 없음 49 → 진행률 66.7%((131 + 66×0.5) / 246) · 구현만 세면 53.3% +- 합계: 전체 246 · 구현 142 · 부분 78 · 없음 26 → 진행률 73.6%((142 + 78×0.5) / 246) · 구현만 세면 57.7% -### G1 화면·메뉴 (구현 16 · 부분 18 · 없음 20) — 46.3% +### G1 화면·메뉴 (구현 16 · 부분 23 · 없음 15) — 50.9% - [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui)) - [ ] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음) @@ -66,9 +66,9 @@ - [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~) - [ ] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 부분(일위대가·중기 구성행 편집 화면 · BDQTY 저장 없음) - [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음) -- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 없음 +- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료 계산기 규칙(REA 30) · 반올림 방향 미확정) - [ ] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 없음 -- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 없음 +- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 비목율 정리(REA 27) · 금액 비목 나눔 근거 없음) - [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 없음 - [x] F1-40 사용처 찾기 — ST_FindUse wM_FindUse · 현재: 구현(단가 찾기·사용처 찾기) - [ ] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 없음 @@ -81,18 +81,18 @@ - [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152) - [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510) - [ ] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 없음 -- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 없음(고정 문구 view_kanjub_prt 454) -- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 없음 -- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 없음 +- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 부분(scr_report_opts 인쇄 메뉴 옵션 화면) +- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 부분(인쇄순서·집계수준 화면) +- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 부분(인쇄영역(당초/변경/분할·레벨·차수) 화면) - [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음 -### G2 계산 엔진 (구현 49 · 부분 11 · 없음 1) — 89.3% +### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0% - [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84) - [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 부분(노임 환산 갈래 연결 · 실무 표본 기본 갈래만) - [x] F2-03 환산 적용 직종(COND_) — 20번 §2.5 · 현재: 구현(중기 구성 운전원 = Start8x.Ini CONLD 직종 · RXXNM 좌→우 환산) -- [ ] F2-04 환율 적용(USD 구간·EUR·YEN·적용 범위) — wM_Set_Sys cmBX_EXR · 현재: 부분(중기 구성 '$' 단위 = EXLNM1÷1000 · 구간·EUR·YEN 없음) -- [ ] F2-05 소수 자리수 설정(KCD·ACD·ZCD·JCD·ECD·BPN) — 22번 §3.4 · 현재: 부분(ZCD 계산 반영 · 나머지 자리수 쓰임 근거 없음) +- [ ] F2-04 환율 적용(USD 구간·EUR·YEN·적용 범위) — wM_Set_Sys cmBX_EXR · 현재: 부분(calc_xrate 고액 환율·€·100¥·당초/변경 시점 · 울진 $장비 18,437원 · 적용 범위 EXRNM 없음) +- [ ] F2-05 소수 자리수 설정(KCD·ACD·ZCD·JCD·ECD·BPN) — 22번 §3.4 · 현재: 부분(KCD·BPN·ACD·JCD·ECD 각 계산 연결(REA 14 안내문) · JCD 가르는 표본 없음) - [x] F2-06 자재 후보 최저가 자동채택 — 17번 §5.5 · 현재: 구현(최저가 채택 · 실무 7건 변동 0(test_material_candidate_minimum_matches_stored)) - [x] F2-07 수동 고정·보호표시 우선 — 22번 §3.1 · 13번 T10 · 현재: 구현(보호표시(UC=T) 우선) - [x] F2-08 중기 시간당 사용료 조립(손료·운전원·연료·잡품 성분 절사) — 17번 §3 · 현재: 구현(BDQTY 구성 · 성분 절사 · 7건 대조) @@ -105,7 +105,7 @@ - [ ] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 부분(calc.js 215 일위 할증만, 박힌 기본 상태 전용) - [ ] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 부분(합산만, L/M/S 종류별 검사 없음) - [x] F2-17 내역 행 ROUNDDOWN(수량×단가) — 16번 · 06번 §3 · 현재: 구현(calc.js 249) -- [ ] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 부분(산근 = CHGNM·OPTNM 총액우선 구현 · 내역서 CHNNM 원본 미대조) +- [x] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 구현(CHNNM 비목우선 = 실무 7건 차수별 내역 줄 금액 1원 · 총액우선 = OPTIMIZ 끝전 배분(손셈 대조) · 비율 줄(test_stmate_kanjub_boq)) - [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66) - [x] F2-20 제외금액 C* 구역 — 16번 §4.2 · 현재: 구현 - [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts)) @@ -116,7 +116,7 @@ - [x] F2-26 산재·건강·연금보험료 — 12번 §3 · 현재: 구현(calc.js 387~391) - [x] F2-27 고용보험료(등급 7) — 12번 §8 rGrd_Rate_Kobo · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts)) - [x] F2-28 노인장기요양보험료(건강 확정액 밑수) — 25번 §2.3 · 현재: 구현(calc.js 390) -- [ ] F2-29 퇴직공제부금비(토목·준설·건축·기타) — 12번 §10.1 · 현재: 부분(퇴직공제 공종 · 표 값 자동 적용 근거 부족(체크 시 적용)) +- [ ] F2-29 퇴직공제부금비(토목·준설·건축·기타) — 12번 §10.1 · 현재: 부분(퇴직공제 표 값은 선택만으로 자동 적용 안 함(실무 4건 저장 요율 0과 일치)) - [x] F2-30 산업안전보건관리비(규모식·1.2배·상한 3모드) — 11번 §7 · 16번 §4.3 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts)) - [x] F2-31 안전·품질관리비 별도내역 — 12번 §3 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts)) - [x] F2-32 기타경비(일반형식 묶음) — 12번 §3 · 현재: 구현(calc.js 431) @@ -146,7 +146,7 @@ - [x] F2-56 형식별 설정 스냅샷 4벌(_T/_S/_TA/_SA) — 12번 §10.1-2 · 현재: 구현(형식 탭 = 형식별 설정 한 벌 갈아 끼움) - [x] F2-57 간접비 식 템플릿 26 평가(IF·선택토큰) — 25번 §2 · 현재: 구현(식 템플릿 재계산 = 항목 금액) - [x] F2-58 항목별 밑수(D_)·결과(S_)·직접입력(C_) 키 왕복 — 25번 §1.1 · 현재: 구현(저장 설정 → 계산 설정 → 다시 저장 원본 그대로(test_kanjub_smt_round_trip)) -- [ ] F2-59 요율 순환(무한루프) 경고·수동 확정 — 06번 §2 · 현재: 부분(순환 감지·알림 · 요율 순환 수동 확정 없음) +- [x] F2-59 요율 순환(무한루프) 경고·수동 확정 — 06번 §2 · 현재: 구현(요율 순환 시 멈춤·구간 단추·수동 확정·확정 풀기(06번 §2 · test_stmate_kanjub_rate)) - [x] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 구현(기초단가부터 원설계 5건 전 단가·총공사비 1원 · 변경설계 일부 미대조) - [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음 @@ -215,43 +215,43 @@ - [ ] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 없음 - [x] F4-29 자동백업·자료 복원 — wM_D_Restore · wM_ST_Restore · 현재: 구현(api_file 자동 백업·복원) -### G5 보고서·내보내기 (구현 10 · 부분 8 · 없음 18) — 38.9% +### G5 보고서·내보내기 (구현 19 · 부분 17 · 없음 0) — 76.4% - [x] F5-01 재료비·노무비·경비 목록표(0101~0103) — Prn_SanMok · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) -- [ ] F5-02 일식·견적(W코드) 목록표(0104) — Prn_SanMok · 현재: 없음 +- [x] F5-02 일식·견적(W코드) 목록표(0104) — Prn_SanMok · 현재: 구현(일식견적 목록 0104 · 울진 변경1차 당초 차수 셀 일치) - [x] F5-03 자재단가대비표(0105) — Prn_Col_Dan · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) -- [ ] F5-04 표준시장·실적단가 목록표(0106·0206) — Prn_SanMok · 현재: 없음 +- [ ] F5-04 표준시장·실적단가 목록표(0106·0206) — Prn_SanMok · 현재: 부분(report_forms SET_REP 칸 이름 양식 · 원본 견본 없음) - [x] F5-05 환율및기초자료(0201) — 18번 §2.3 · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) - [x] F5-06 중기목록표·시간당중기사용료(0202·0203) — Prn_Joong · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) - [x] F5-07 일위대가목록표(0301) — Prn_SanMok · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) - [x] F5-08 일위대가표(0302) — Prn_iLWi · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) -- [ ] F5-09 일위대가표 수공양식(0303) — Prn_iLWi_S · 현재: 없음 +- [ ] F5-09 일위대가표 수공양식(0303) — Prn_iLWi_S · 현재: 부분(수공양식 · 원본 견본 없음) - [x] F5-10 단가산출근거목록표(0401) — Prn_SanMok · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) -- [ ] F5-11 단가산출근거(0402) — Calq_San · Edit_SanKun · 현재: 부분(식 원문만, 줄별 계산 펼침 없음) -- [ ] F5-12 재료·노무·경비 수량금액집계표(0501~0503) — Prn_Col_Dan · 현재: 없음(화면 0501 집계 없음) -- [ ] F5-13 일식·표준시장 수량금액집계표(0504·0508) — Prn_Col_Dan · 현재: 없음 -- [ ] F5-14 중기시간금액집계표(0505) — Prn_Col_Dan · 현재: 없음 -- [ ] F5-15 일위대가수량금액집계표(0506) — Prn_Col_Dan · 현재: 없음 -- [ ] F5-16 단가산출근거수량금액집계표(0507) — Calq_San · 현재: 없음 +- [x] F5-11 단가산출근거(0402) — Calq_San · Edit_SanKun · 현재: 구현(report_san 단가산출근거 펼침 0402 · 3건 · 봉화 3,889행 글·금액·쪽 일치) +- [x] F5-12 재료·노무·경비 수량금액집계표(0501~0503) — Prn_Col_Dan · 현재: 구현(report_coll 0501~0503 · 봉화·영월·대흥 원본 엑셀 셀 일치(test_stmate_report_coll)) +- [x] F5-13 일식·표준시장 수량금액집계표(0504·0508) — Prn_Col_Dan · 현재: 구현(일식견적 집계 0504 · 울진 변경1차 당초 차수 셀 일치) +- [x] F5-14 중기시간금액집계표(0505) — Prn_Col_Dan · 현재: 구현(중기시간금액집계표 0505 · 3건 셀 일치) +- [x] F5-15 일위대가수량금액집계표(0506) — Prn_Col_Dan · 현재: 구현(일위대가수량금액집계표 0506 · 울진 변경1차 셀 일치) +- [x] F5-16 단가산출근거수량금액집계표(0507) — Calq_San · 현재: 구현(단가산출근거수량금액집계표 0507 · 울진 변경1차 셀 일치) - [ ] F5-17 총괄내역서(0601·종양식 0602) — Prn_Nae · 현재: 부분(총괄내역서 원가 줄 4건 일치 · 공종 줄 단계 기본값만) - [x] F5-18 설계내역서(0603·종양식 0604·0693·0694) — Prn_Nae · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) -- [ ] F5-19 분할내역서(0605) — Prn_Nae · 현재: 없음 +- [ ] F5-19 분할내역서(0605) — Prn_Nae · 현재: 부분(분할내역서 · 엔진 합계 대조만) - [ ] F5-20 사·관급내역서(0607) — Prn_Nae · 현재: 부분(제외 구역 행만 걸러 표시) - [x] F5-21 공사원가계산서(0608) — Prn_Won_A · 현재: 구현(원가계산서 · 원본 엑셀 5건 대조(영덕 당초·변경·증감 양식 포함)) -- [ ] F5-22 총괄집계표(0609) — Prn_Col_Dan · 현재: 없음 -- [ ] F5-23 견적내역서(0613·0614)·견적단가대비표(0615) — PM_Nae_Est · 현재: 없음 -- [ ] F5-24 도공·굴착·시도·구도·비교 양식 내역서(0606·0651~0656·0672) — ST_P_Excav · Prn_Nae · 현재: 없음 -- [ ] F5-25 재료비·노무비·중장비 집계표(0675~0677) — Prn_Col_Dan · 현재: 없음 -- [ ] F5-26 장비·인력·자재 투입계획(0671·0678·0679)·간이명세서(0695) — Rep_Print · 현재: 없음 +- [ ] F5-22 총괄집계표(0609) — Prn_Col_Dan · 현재: 부분(총괄집계표 · 엔진 합계 대조만) +- [ ] F5-23 견적내역서(0613·0614)·견적단가대비표(0615) — PM_Nae_Est · 현재: 부분(견적내역서·대비표 · 원본 견본 없음) +- [ ] F5-24 도공·굴착·시도·구도·비교 양식 내역서(0606·0651~0656·0672) — ST_P_Excav · Prn_Nae · 현재: 부분(구·시도 양식 · 엔진 합계 대조만 · 굴착·비교 견본 없음) +- [ ] F5-25 재료비·노무비·중장비 집계표(0675~0677) — Prn_Col_Dan · 현재: 부분(재료·노무·중장비 집계표 · 견본 없음) +- [ ] F5-26 장비·인력·자재 투입계획(0671·0678·0679)·간이명세서(0695) — Rep_Print · 현재: 부분(간이명세서 엔진 합계 대조 · 투입계획 견본 없음) - [ ] F5-27 목차 시트(〓 목 차 〓) — 19번 §3 · 현재: 부분(목차·안내 시트와 바로가기 · 원본 양식 근사) - [ ] F5-28 안내 시트(※※안내※※) — 19번 §3 · 현재: 부분(목차·안내 시트와 바로가기 · 원본 양식 근사) -- [ ] F5-29 〓 INITIAL 〓 설정 스냅샷 시트 — 18번 §2.3 · 현재: 없음 -- [ ] F5-30 제잡비 요율표 인쇄 — PM_KAN_RATE · 현재: 없음 -- [ ] F5-31 엑셀 다중 시트 내보내기·수식 보존 — ST_P_XLS Prn_FMLA_INFO · 현재: 부분(엑셀 다중 시트 값·서식 · 수식 없음) -- [ ] F5-32 보고서 인쇄·미리보기 — PM_* · 현재: 부분(서식 목록·미리보기·A4 인쇄·엑셀·CSV) -- [ ] F5-33 가로형·토개공·수자원·산근양식 변형 — PM_*_L · PM_Prt · 현재: 없음 -- [ ] F5-34 출력별 반올림(내역 절사·집계 사사오입) — 06번 §3 · 18번 §2.4 · 현재: 부분(내역 절사·일위대가 끝수 맞춤 · 집계표 없음) -- [ ] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 없음 +- [x] F5-29 〓 INITIAL 〓 설정 스냅샷 시트 — 18번 §2.3 · 현재: 구현(report_init INITIAL 시트 · 3건 일치(파일 자리·작성자·날짜 제외)) +- [ ] F5-30 제잡비 요율표 인쇄 — PM_KAN_RATE · 현재: 부분(제잡비 요율표 인쇄 · 견본 없음) +- [ ] F5-31 엑셀 다중 시트 내보내기·수식 보존 — ST_P_XLS Prn_FMLA_INFO · 현재: 부분(엑셀 수식 = 내역·일위대가·집계 금액(원본 시트 간 참조 수식 없음)) +- [ ] F5-32 보고서 인쇄·미리보기 — PM_* · 현재: 부분(서식 목록·미리보기·인쇄·엑셀·CSV · 인쇄 옵션 연결) +- [ ] F5-33 가로형·토개공·수자원·산근양식 변형 — PM_*_L · PM_Prt · 현재: 부분(가로형 방향만 · 토개공·수자원 양식 없음) +- [x] F5-34 출력별 반올림(내역 절사·집계 사사오입) — 06번 §3 · 18번 §2.4 · 현재: 구현(출력별 반올림 · 집계표 사사오입 원본 일치) +- [ ] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 부분(금액 한글 표기 · 문서 예시 1건 확인) - [x] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 구현(SET_REP 서식 제목·시트 이름 적용 · 보고서 시험 포함) ### G6 설계변경·계약·실행·기성 (구현 13 · 부분 22 · 없음 1) — 66.7% diff --git a/tools/stmate-test/api_report.py b/tools/stmate-test/api_report.py index c308d5bfa..3ef915a7c 100644 --- a/tools/stmate-test/api_report.py +++ b/tools/stmate-test/api_report.py @@ -27,6 +27,8 @@ router = APIRouter() class ReportReq(BaseModel): sheets: list[dict] = [] filename: str = "STMATE_보고서.xlsx" + formulas: bool = False # 엑셀 수식 넣기(Prn_FMLA_INFO) + print: dict = {} # 인쇄 메뉴 옵션 — font · size · orient class XmlReq(BaseModel): @@ -42,7 +44,7 @@ def api_report_xlsx(req: ReportReq): if not req.sheets: raise HTTPException(status_code=400, detail="시트 모형 없음") return Response( - content=build_report_xlsx(req.sheets), + content=build_report_xlsx(req.sheets, req.formulas, req.print), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": _disposition(req.filename)}, ) diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js index 93f912304..8d9d08da8 100644 --- a/tools/stmate-test/calc.js +++ b/tools/stmate-test/calc.js @@ -297,10 +297,34 @@ const StmateCalc = (() => { u = { M: 0, L: 0, S: 0 }; ((a[to] = roundDown((bv * pct) / 100, 0)), (u[to] = bv)); const T = a.M + a.L + a.S; - const unitInfo = { M: u.M, L: u.L, S: u.S, T: bv, name: row.name, code: "PR", spec: "", unit: "%" }; - const out = { unitInfo, appliedQty: pct, qty: pct, mUnit: u.M, lUnit: u.L, sUnit: u.S, tUnit: bv }; + const unitInfo = { + M: u.M, + L: u.L, + S: u.S, + T: bv, + name: row.name, + code: "PR", + spec: "", + unit: "%", + }; + const out = { + unitInfo, + appliedQty: pct, + qty: pct, + mUnit: u.M, + lUnit: u.L, + sUnit: u.S, + tUnit: bv, + }; Object.assign(out, { mAmt: a.M, lAmt: a.L, sAmt: a.S, tAmt: T, M: a.M, L: a.L, S: a.S, T }); - Object.assign(out, { optimizDiff: 0, optimizTarget: "", aggM: a.M, aggL: a.L, aggS: a.S, aggT: T }); + Object.assign(out, { + optimizDiff: 0, + optimizTarget: "", + aggM: a.M, + aggL: a.L, + aggS: a.S, + aggT: T, + }); return { M: a.M, L: a.L, S: a.S, T, out }; } diff --git a/tools/stmate-test/calc_xrate.js b/tools/stmate-test/calc_xrate.js index f458611f6..cb9f175e4 100644 --- a/tools/stmate-test/calc_xrate.js +++ b/tools/stmate-test/calc_xrate.js @@ -24,9 +24,12 @@ const StmateXRate = (() => { function condFactor(text) { const s = String(text ?? "").replace(/\s+/g, ""); if (!s) return { ok: true, value: 1 }; - if (!/^[0-9.+\-*/^]+$/.test(s)) return { ok: false, value: 1, error: "조건식에 쓸 수 없는 글자" }; + if (!/^[0-9.+\-*/^]+$/.test(s)) + return { ok: false, value: 1, error: "조건식에 쓸 수 없는 글자" }; const v = Number(EV().evalExpr(s, {})); - return Number.isFinite(v) ? { ok: true, value: v } : { ok: false, value: 1, error: "조건식 오류" }; + return Number.isFinite(v) + ? { ok: true, value: v } + : { ok: false, value: 1, error: "조건식 오류" }; } // 자동 자리 — 기준수량 정수부 자릿수 + 1, 2~6 @@ -42,7 +45,7 @@ const StmateXRate = (() => { function compute(inp = {}) { const c = condFactor(inp.cond); const qty1 = Number(inp.qty1) || 0; - const qday = q(qty1 * c.value * (Number(inp.hrat) || 0) / 100, 11); + const qday = q((qty1 * c.value * (Number(inp.hrat) || 0)) / 100, 11); const point = inp.auto ? autoPoint(qty1) : Math.round(Number(inp.point) || 0); const per = inp.labor ? 1 : 8; const cqty = qday === 0 ? 0 : q(((Number(inp.lqty) || 0) * per) / qday, point); diff --git a/tools/stmate-test/report_book.js b/tools/stmate-test/report_book.js index 066feeb32..f06fb2bc8 100644 --- a/tools/stmate-test/report_book.js +++ b/tools/stmate-test/report_book.js @@ -10,6 +10,9 @@ const StmateReportBook = (() => { const C = g.STMATE_REPORT_CORE || require("./report_core.js"); const U = g.STMATE_REPORT_UNIT || require("./report_unit.js"); const B = g.STMATE_REPORT_BOQ || require("./report_boq.js"); + const Co = g.STMATE_REPORT_COLL || require("./report_coll.js"); + const Sa = g.STMATE_REPORT_SAN || require("./report_san.js"); + const In = g.STMATE_REPORT_INIT || require("./report_init.js"); const { num, str, code } = C; /** @@ -57,21 +60,33 @@ const StmateReportBook = (() => { return sh; } - // 시트 차례 — 원본 STMATE 엑셀 내보내기 순서(목차 번호) + // 시트 차례 — 원본 STMATE 엑셀 내보내기 기본 순서(영월·울진 출력 목차) · 인쇄순서(F1-52)로 바꿈 const ORDER = [ "공사원가계산서", "총괄내역서", "설계내역서", "일위대가목록표", "일위대가표", + "일위대가수량금액집계표", "단가산출근거목록표", + "단가산출근거", + "단가산출근거수량금액집계표", "환율및기초자료", "중기목록표", "중기사용료", "재료비목록표", "노무비목록표", "경비목록표", + "일식견적목록표", + "표준시장목록표", + "실적단가목록표", "자재단가대비표", + "재료비수량금액집계표", + "노무비수량금액집계표", + "경비수량금액집계표", + "일식견적수량금액집계표", + "표준시장수량금액집계표", + "중기시간금액집계표", ]; // 목차 시트(〓 목 차 〓) — 시트 이름과 바로가기(엑셀에서 하이퍼링크) @@ -117,11 +132,22 @@ const StmateReportBook = (() => { const unit = U.buildUnitSheets(dbf); const boq = B.buildBoqSheets(dbf, state, computed, n, opts); const pc = C.applyForm(priceCompareSheet(unit.ctx), dbf, "0105"); - const all = Object.assign({}, unit, boq, { 자재단가대비표: pc }); - const sheets = ORDER.filter((id) => all[id]).map((id) => Object.assign(all[id], { id })); + // 집계표(0501~0508) — 변경 차수면 당초·변경 두 줄 양식 · 단가산출근거(0402) 펼침 + const base = n > 1 ? B.boqTree(dbf, B.stageOf(state, computed, 1).boq, 1) : null; + const coll = Co.buildCollSheets( + unit.ctx, + boq.설계내역서._tree, + Object.assign({ baseTree: base }, opts), + ); + const san = C.applyForm(Sa.sanSheet(unit.ctx, opts), dbf, "0402"); + const all = Object.assign({}, unit, boq, coll, { 자재단가대비표: pc, 단가산출근거: san }); + const order = opts.order && opts.order.length ? opts.order : ORDER; + const sheets = order.filter((id) => all[id]).map((id) => Object.assign(all[id], { id })); const names = sheets.map((s) => s.name); const pname = C.projectName(dbf); - const book = [tocSheet(names, pname), guideSheet(boq.stage.kanjub)].concat(sheets); + const init = In.initialSheet(dbf, state, sheets, n, opts); + names.push(init.name); + const book = [tocSheet(names, pname), guideSheet(boq.stage.kanjub)].concat(sheets, [init]); for (const s of book) delete s._tree; return { sheets: book, byName: Object.fromEntries(book.map((s) => [s.name, s])) }; } @@ -138,6 +164,18 @@ const StmateReportBook = (() => { "0301": "일위대가목록표", "0302": "일위대가표", "0401": "단가산출근거목록표", + "0402": "단가산출근거", + "0104": "일식견적목록표", + "0106": "표준시장목록표", + "0206": "실적단가목록표", + "0501": "재료비수량금액집계표", + "0502": "노무비수량금액집계표", + "0503": "경비수량금액집계표", + "0504": "일식견적수량금액집계표", + "0505": "중기시간금액집계표", + "0506": "일위대가수량금액집계표", + "0507": "단가산출근거수량금액집계표", + "0508": "표준시장수량금액집계표", "0601": "총괄내역서", "0603": "설계내역서", "0608": "공사원가계산서", diff --git a/tools/stmate-test/report_boq.js b/tools/stmate-test/report_boq.js index 1ca0c3a5d..71e182d96 100644 --- a/tools/stmate-test/report_boq.js +++ b/tools/stmate-test/report_boq.js @@ -166,6 +166,7 @@ const StmateReportBoq = (() => { } else { Object.assign(cells, { D: fix(n.qty), E: C.unitText(r.UNIT1), _k: "item", _code: n.code }); fill(cells, n.unitP, n.amt, false); + if (!n.pct) cells._f = C.pairFormulas(order, PAIR, "D", 0); const rm = itemRemark(dbf, idx, n.code); if (rm) cells.N = rm; } diff --git a/tools/stmate-test/report_coll.js b/tools/stmate-test/report_coll.js index 8ebad785e..c539b7c42 100644 --- a/tools/stmate-test/report_coll.js +++ b/tools/stmate-test/report_coll.js @@ -87,7 +87,10 @@ const StmateReportColl = (() => { const c = String(r.CODE || "").trim(); const ref = idx.by[c]; if (!ref || /%$/.test(str(ref.UNIT).trim())) continue; - out.push([c, num(r.QTY1)]); + // 나누기수량(DEVD1) = 저장값의 역수(smt_eval 구성 조립과 같음) + const q = num(r.QTY1); + const devd = ["T", "t", "1"].includes(String(r.DEVD1 || "").trim()); + out.push([c, devd ? (q ? 1 / q : 0) : q]); } } else if (k[0] === "D") { const text = edited[k] || (memos[k] && memos[k].text) || ""; @@ -154,7 +157,8 @@ const StmateReportColl = (() => { const money = (q, p) => { if (!bimok) { const unit = C.fix(p.M + p.L + p.S); - return { F: unit, G: half(q * unit) }; + // 엑셀 수식 — 집계 금액 = ROUND(수량 × 단가, 0)(지식DB 06 §3) + return { F: unit, G: half(q * unit), _f: { G: "=ROUND(D{r}*F{r},0)" } }; } const v = { M: half(q * p.M), L: half(q * p.L), S: half(q * p.S) }; v.T = v.M + v.L + v.S; @@ -176,7 +180,8 @@ const StmateReportColl = (() => { const rm = U.remarkOf(po, c, lbl, r); if (rm) cells[bimok ? "J" : "H"] = rm; C.push(sh, cells); - if (qs.length > 1) C.push(sh, Object.assign({ D: qs[1], _k: "item2", _code: c }, money(qs[1], p))); + if (qs.length > 1) + C.push(sh, Object.assign({ D: qs[1], _k: "item2", _code: c }, money(qs[1], p))); } return sh; } @@ -231,11 +236,19 @@ const StmateReportColl = (() => { const F = (sh, indx) => C.applyForm(sh, u.dbf, indx); const has = (p) => u.idx.ofPrefix(p).length > 0; const out = { _explode: ex }; - if (has("W")) out.일식견적목록표 = F(wListSheet(u, "일식견적목록표", "일식/견적 목록표", "W"), "0104"); + if (has("W")) + out.일식견적목록표 = F(wListSheet(u, "일식견적목록표", "일식/견적 목록표", "W"), "0104"); if (has("P")) - out.표준시장목록표 = F(wListSheet(u, "표준시장목록표", "표준시장/시장시공 목록표", "P"), "0106"); + out.표준시장목록표 = F( + wListSheet(u, "표준시장목록표", "표준시장/시장시공 목록표", "P"), + "0106", + ); + // 실적단가(A — 실적 → 일식 변환 wM_Conv_A2W 의 A) 목록표(0206) + if (has("A")) + out.실적단가목록표 = F(wListSheet(u, "실적단가목록표", "실적공사비 단가목록표", "A"), "0206"); for (const [id, title, p, indx] of COLL) - if ("WP".includes(p) ? has(p) : true) out[id] = F(collSheet(u, exs, id, title, p, opts), indx); + if ("WP".includes(p) ? has(p) : true) + out[id] = F(collSheet(u, exs, id, title, p, opts), indx); return out; } diff --git a/tools/stmate-test/report_core.js b/tools/stmate-test/report_core.js index 4c56a35a3..9c2c4e77f 100644 --- a/tools/stmate-test/report_core.js +++ b/tools/stmate-test/report_core.js @@ -244,6 +244,28 @@ const StmateReportCore = (() => { return sh; } + /** + * 엑셀 수식(ST_P_XLS Prn_FMLA_INFO) — 비목 쌍(단가·금액) 줄 수식 · {r} = 행 번호(report_xlsx 가 채움) + * 금액 = ROUNDDOWN(단가 × 수량, 자리) · 합계 쌍 = 세 비목 합(원본 STMATE 출력 수식 꼴) + * zeroGuard = 수량 0 이면 0(일위대가표 「IF(C=0,0,…)」) + */ + function pairFormulas(order, pairs, qtyCol, digits, zeroGuard) { + const f = {}; + const at = (k) => pairs[order.indexOf(k)]; + for (const k of order) { + const [u, a] = at(k); + if (k === "T") { + const parts = order.filter((x) => x !== "T").map((x) => at(x)); + f[u] = "=" + parts.map(([pu]) => pu + "{r}").join("+"); + f[a] = "=" + parts.map(([, pa]) => pa + "{r}").join("+"); + } else { + const core = `ROUNDDOWN(${u}{r}*${qtyCol}{r},${digits})`; + f[a] = zeroGuard ? `=IF(${qtyCol}{r}=0,0,${core})` : "=" + core; + } + } + return f; + } + // 공사명 — PRJLST.DBF PNAME const projectName = (dbf) => str((dbf && dbf.prjlst && dbf.prjlst.PNAME) || ""); @@ -267,6 +289,7 @@ const StmateReportCore = (() => { spread, projectName, applyForm, + pairFormulas, }; })(); diff --git a/tools/stmate-test/report_forms.js b/tools/stmate-test/report_forms.js new file mode 100644 index 000000000..78a2e5e60 --- /dev/null +++ b/tools/stmate-test/report_forms.js @@ -0,0 +1,442 @@ +/* -*- coding: utf-8 -*- */ +/** + * 보고서 양식 변형 (report_forms.js) — 원본 출력 견본이 없는 SET_REP 서식(머리 칸·제목은 SET_REP · 값은 계산 엔진) + * 0303 일위대가표(수공양식) · 0605 분할내역서 · 0609 총괄집계표 · 0613·0614 견적내역서 · 0615 견적단가대비표 + * 0606·0654·0656 내역서(도공·굴착·시도) · 0651·0655 구.시도(시기금·구기금) · 0652·0653·0672 총괄 변형 + * 0675~0677 재료비·노무비·중장비 집계표 · 0671·0678·0679 투입계획 · 0695 간이명세서 · 0206 실적단가목록표 + * 근거: 지식DB STmate/old/20_분석 20 §3(SET_REP FLD_N·FLD_W) · old/30_원자료/print_form_map.txt · 24 §2.4(AMTTOHAN) + * 판정: 부분 — 원본 STMATE 출력 견본이 없어 열 배치는 SET_REP 칸 이름 차례를 따름(셀 대조 없음) + */ +const StmateReportForms = (() => { + const g = typeof globalThis !== "undefined" ? globalThis : {}; + const C = g.STMATE_REPORT_CORE || require("./report_core.js"); + const B = g.STMATE_REPORT_BOQ || require("./report_boq.js"); + const Co = g.STMATE_REPORT_COLL || require("./report_coll.js"); + const In = g.STMATE_REPORT_INIT || require("./report_init.js"); + const { num, str, code, half, trunc } = C; + + // SET_REP 칸 이름(FLD_N1~) — 빈 이름 빼고 + function fields(dbf, indx) { + const r = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx); + const out = []; + if (r) for (let i = 1; i <= 11; i++) out.push(str(r[`FLD_N${i}`]).trim()); + return out; + } + const form = (sh, dbf, indx) => C.applyForm(sh, dbf, indx); + const T4 = (s) => s.M + s.L + s.S; + const excluded = (n) => { + for (let p = n.parent; p; p = p.parent) if (p.excluded) return true; + return false; + }; + + /** 0303 일위대가표(수공양식) — 호표마다 「항목번호 · 제 n 호표 · 공종 · 단위」 머리 + 구성 줄(재·노·경 단가·금액) */ + function iLwiHandSheet(u) { + const { dbf, idx, evals, hopyoNo } = u; + const f = fields(dbf, "0303"); + const sh = C.newSheet("일위대가표수공", "일 위 대 가 표", C.projectName(dbf), "O"); + const head = { A: "명 칭", B: "규 격", C: "단위", D: "수 량", _k: "head" }; + Object.assign(head, { E: "재료비 단가", F: "재료비 금액", G: "노무비 단가", H: "노무비 금액" }); + Object.assign(head, { I: "경비 단가", J: "경비 금액", K: "합계 금액", L: "비 고" }); + for (const r of idx.ofPrefix("B")) { + const c = code(r); + const ev = evals[c]; + C.push(sh, { + A: `${f[0] || "항 목 번 호 :"} ${c}`, + E: `${f[1] || "제"} ${hopyoNo[c]} ${f[2] || "호표"}`, + _k: "hopyo", + }); + C.push(sh, { + A: `${f[3] || "공 종 :"} ${str(r.NAME)} ${str(r.SIZE)}`.trim(), + _k: "parent", + }); + C.push(sh, { A: `${f[4] || "단 위 :"} ${C.unitText(r.UNIT)}`, _k: "parent" }); + C.push(sh, Object.assign({}, head)); + if (!ev) continue; + for (const ln of ev.lines) { + if (ln.text) continue; + const ref = ln.ref || {}; + const cells = { + A: ref.name || ln.code, + B: ref.spec || "", + C: ln.pct ? "%" : ref.unitDisp || ln.unit, + }; + Object.assign(cells, { + D: ln.qty, + E: C.fix(ln.u.M), + F: C.fix(ln.a.M, 1), + G: C.fix(ln.u.L), + H: C.fix(ln.a.L, 1), + }); + Object.assign(cells, { + I: C.fix(ln.u.S), + J: C.fix(ln.a.S, 1), + K: C.fix(T4(ln.a), 1), + L: ln.code, + _k: "line", + }); + C.push(sh, cells); + } + C.push(sh, { A: "합 계", F: ev.tot.M, H: ev.tot.L, J: ev.tot.S, K: ev.T, _k: "total" }); + } + return form(sh, dbf, "0303"); + } + + /** + * 0605 분할내역서(횡양식) — 「전체」(설계 수량·금액) · 「금회」(분할 비율 opts.splitRate %) 두 묶음 + * 금회 수량 = 전체 × 비율(소수 셋째 자리 사사오입) · 금액 = 단가 × 금회 수량 원 미만 절사 + */ + function splitSheet(dbf, tree, opts = {}) { + const f = fields(dbf, "0605"); + const rate = opts.splitRate == null ? 100 : num(opts.splitRate); + const sh = C.newSheet("분할내역서", "분 할 내 역 서", C.projectName(dbf), "L"); + C.push(sh, { + A: "공 종", + B: "명 칭", + C: "규 격", + D: "단위", + E: f[0] || "전 체", + H: f[1] || "금 회", + K: "비 고", + _k: "head", + }); + C.push(sh, { + E: "수 량", + F: "단 가", + G: "금 액", + H: "수 량", + I: "금 액", + J: "비율(%)", + _k: "head", + }); + let tot = 0, + cur = 0; + for (const n of tree.out) { + if (n.kind === "T") { + C.push(sh, { + A: str(n.row.ITNUM).trim(), + B: str(n.row.NAME1), + G: T4(n.sum), + _k: `T${n.level}`, + }); + continue; + } + if (n.kind !== "item") continue; + const unit = T4(n.unitP); + const q2 = half((n.qty * rate) / 100, 3); + const a2 = n.pct ? trunc((T4(n.amt) * rate) / 100) : trunc(unit * q2); + if (!excluded(n)) ((tot += T4(n.amt)), (cur += a2)); + const cells = { + B: str(n.row.NAME1), + C: str(n.row.SIZE1), + D: C.unitText(n.row.UNIT1), + E: n.qty, + F: unit, + }; + Object.assign(cells, { G: T4(n.amt), H: q2, I: a2, J: rate, _k: "item", _code: n.code }); + C.push(sh, cells); + } + C.push(sh, { + B: "합 계", + G: tot, + I: cur, + J: tot ? C.fix((cur / tot) * 100, 2) : 0, + _k: "final", + }); + return form(sh, dbf, "0605"); + } + + /** + * 0609 총괄집계표 — 구분(공종 묶음 · 원가 항목) · 금액 · 구성비(총공사비 대비 %) · 비고 + * 공종 = opts.level 단(기본 2) 묶음의 직접공사비 · 원가 항목 = 공사원가계산서 줄 + */ + function totalSheet(dbf, stage, tree, opts = {}) { + const f = fields(dbf, "0609"); + const sh = C.newSheet("총괄집계표", "총 괄 집 계 표", C.projectName(dbf), "F"); + C.push(sh, { + A: f[0] || "구 분", + C: f[1] || "금 액", + D: f[2] || "구 성 비", + E: f[3] || "비 고", + _k: "head", + }); + const grand = stage.kanjub.grandTotal || 1; + const level = opts.level || 2; + for (const n of tree.out) + if (n.kind === "T" && n.level === level && !n.excluded) { + const v = T4(n.sum); + C.push(sh, { + A: str(n.row.NAME1).trim(), + C: v, + D: C.fix((v / grand) * 100, 4), + E: "직접공사비", + _k: "item", + }); + } + const won = B.wonSheet(dbf, stage, null); + for (const r of won.rows.slice(4)) { + if (r.D == null) continue; + const lbl = [r.A, r.B, r.C].filter((x) => x && String(x).trim()).pop(); + C.push(sh, { + A: String(lbl || "").trim(), + C: r.D, + D: C.fix((r.D / grand) * 100, 4), + E: r.F || "", + _k: "cost", + }); + } + C.push(sh, { A: In.amtToHan(stage.kanjub.grandTotal), _k: "final" }); + return form(sh, dbf, "0609"); + } + + /** + * 0615 견적단가대비표 — 재료(M) 중 견적 가격지(5칸 AMT_51 · JMOK5) 값이 있는 것 · 견적단가 ↔ 기준(적용) 단가 · 차이 · 비율 + */ + function estimateCompareSheet(u) { + const { dbf, idx, ini } = u; + const f = fields(dbf, "0615"); + const sh = C.newSheet("견적단가대비표", "견 적 단 가 대 비 표", C.projectName(dbf), "J"); + const est = str(ini.JMOK5 || "견적단가").trim(); + C.push(sh, { + A: "코드번호", + B: "명 칭", + C: "규 격", + D: "단위", + E: f[0] || est, + F: f[1] || "기준단가", + G: "차 이", + H: "비율(%)", + I: "비 고", + _k: "head", + }); + for (const r of idx.ofPrefix("M")) { + const e = num(r.AMT_51); + if (!e) continue; + const c = code(r); + const p = idx.price(c, 1); + const base = T4(p); + const cells = { + A: c, + B: str(r.NAME), + C: str(r.SIZE), + D: C.unitText(r.UNIT), + E: e, + F: base, + G: C.fix(e - base, 2), + }; + Object.assign(cells, { + H: base ? C.fix((e / base) * 100, 2) : 0, + I: str(r.PG_51).trim(), + _k: "item", + _code: c, + }); + C.push(sh, cells); + } + return form(sh, dbf, "0615"); + } + + /** + * 0651·0655 구.시도 양식 — 금액을 시기금·구기금으로 나눔(opts.fundRate = 시기금 %, 기본 100) · 합계 + * 0655 = 품목 줄 · 0651 = 공종 묶음 줄 + */ + function fundSheet(dbf, tree, indx, opts = {}) { + const f = fields(dbf, indx); + const rate = opts.fundRate == null ? 100 : num(opts.fundRate); + const items = indx === "0655"; + const id = items ? "설계내역서구시도" : "총괄내역서구시도"; + const sh = C.newSheet( + id, + items ? "설 계 내 역 서 (구.시도)" : "총 괄 내 역 서 (구.시도)", + C.projectName(dbf), + "J", + ); + C.push(sh, { + A: "공 종", + B: "명 칭", + C: "규 격", + D: items ? "수 량" : "", + E: f[0] || "시 기 금", + F: f[1] || "구 기 금", + G: f[2] || "합 계", + _k: "head", + }); + for (const n of tree.out) { + if (!(n.kind === "T" || (items && n.kind === "item"))) continue; + const v = T4(n.kind === "T" ? n.sum : n.amt); + const si = trunc((v * rate) / 100); + const cells = { + A: n.kind === "T" ? str(n.row.ITNUM).trim() : "", + B: str(n.row.NAME1), + C: str(n.row.SIZE1), + }; + if (n.kind === "item") cells.D = n.qty; + Object.assign(cells, { E: si, F: v - si, G: v, _k: n.kind === "T" ? `T${n.level}` : "item" }); + C.push(sh, cells); + } + return form(sh, dbf, indx); + } + + /** + * 공종별 집계(0675 재료비 · 0676 노무비 · 0677 중장비) · 투입계획(0678 자재 · 0679 인력 · 0671 장비) + * 공종 = 설계내역 opts.level 단(기본 2) 묶음 · 작업명 = 품목 명칭 · 구분 = 투입 자원 · 수량 = 투입 수량 전개(report_coll) + */ + function groupCollSheet(u, tree, indx, prefix, opts = {}) { + const { dbf, idx } = u; + const f = fields(dbf, indx); + const rep = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx) || {}; + const title = str(rep.HEADTITLE).trim() || indx; + const sh = C.newSheet(title.replace(/[\s,/]+/g, ""), title, C.projectName(dbf), "J"); + const plan = ["0671", "0678", "0679"].includes(indx); + C.push(sh, { + A: f[0] || "공 종", + B: f[1] || "작 업 명", + C: f[2] || "구 분", + D: "규 격", + E: "단위", + F: "수 량", + G: "단 가", + H: plan ? "투입 시기" : "금 액", + _k: "head", + }); + const level = opts.level || 2; + const groupOf = (n) => { + let p = n.parent; + while (p && p.row && p.level > level) p = p.parent; + return p && p.row ? p : null; + }; + const blocks = new Map(); + for (const n of tree.out) { + if (n.kind !== "item" || n.pct || excluded(n)) continue; + const gnode = groupOf(n); + const key = gnode ? gnode.row._recno : "-"; + if (!blocks.has(key)) blocks.set(key, { node: gnode, items: [] }); + blocks.get(key).items.push(n); + } + for (const { node, items } of blocks.values()) { + C.push(sh, { A: node ? str(node.row.NAME1).trim() : "", _k: "T2" }); + for (const it of items) { + const ex = Co.explode(dbf, [{ code: it.code, qty: it.qty }]); + const used = Object.keys(ex.qty) + .filter((c) => c[0] === prefix && c !== it.code) + .sort(); + if (it.code[0] === prefix) used.unshift(it.code); + for (const c of used) { + const r = idx.by[c]; + if (!r) continue; + const q = half(ex.qty[c], 2); + const p = idx.price(c, 1); + const cells = { + B: str(it.row.NAME1), + C: str(r.NAME), + D: str(r.SIZE), + E: C.unitText(r.UNIT), + F: q, + G: T4(p), + }; + if (!plan) cells.H = half(q * T4(p)); + C.push(sh, Object.assign(cells, { _k: "item", _code: c })); + } + } + } + return form(sh, dbf, indx); + } + + /** 0695 간이공사비 산출명세서 — 원가 항목 · 금액 · 한글 금액 */ + function simpleSheet(dbf, stage) { + const sh = C.newSheet("간이공사비산출명세서", "간이공사비 산출명세서", C.projectName(dbf), "E"); + C.push(sh, { A: "항 목", B: "금 액", C: "산 출 근 거", _k: "head" }); + const won = B.wonSheet(dbf, stage, null); + for (const r of won.rows.slice(4)) { + if (r.D == null) continue; + const lbl = [r.A, r.B, r.C].filter((x) => x && String(x).trim()).pop(); + C.push(sh, { A: String(lbl || "").trim(), B: r.D, C: r.F || "", _k: "cost" }); + } + C.push(sh, { A: "총공사비(한글)", B: In.amtToHan(stage.kanjub.grandTotal), _k: "final" }); + return form(sh, dbf, "0695"); + } + + // 제목만 바뀌는 변형 — 같은 시트 모형을 복사해 SET_REP 제목·이름(0606 도공 · 0654 굴착 · 0656 시도 · 0613 견적 · 0652·0653·0672 총괄) + function retitle(sh, dbf, indx, id) { + const copy = { + name: sh.name, + title: sh.title, + rows: sh.rows.map((r) => Object.assign({}, r)), + cols: {}, + }; + const rep = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx); + const title = rep ? str(rep.HEADTITLE).trim() : sh.title; + copy.title = title; + if (copy.rows[0] && copy.rows[0]._k === "title") copy.rows[0].A = title; + copy.name = `${title.replace(/[\s,/().]+/g, "")}${indx}`; + copy.id = id; + copy.indx = indx; + return copy; + } + + /** + * 변형 묶음 — 보고서 화면 서식 목록에서 고를 때 만듦(묶음 기본 차례 밖) + * 돌려줌: { id: 시트 모형 } + */ + function buildFormSheets(u, dbf, state, computed, n = 1, opts = {}) { + const stage = B.stageOf(state, computed, n); + const design = B.designSheet(dbf, stage, opts); + const tree = design._tree; + const summary = B.summarySheet(dbf, stage, design, opts); + const out = { + 일위대가표수공양식: iLwiHandSheet(u), + 분할내역서: splitSheet(dbf, tree, opts), + 총괄집계표: totalSheet(dbf, stage, tree, opts), + 견적내역서: retitle(design, dbf, "0613", "견적내역서"), + 견적내역서종양식: retitle(design, dbf, "0614", "견적내역서종양식"), + 견적단가대비표: estimateCompareSheet(u), + 내역서도공양식: retitle(design, dbf, "0606", "내역서도공양식"), + 설계내역서굴착: retitle(design, dbf, "0654", "설계내역서굴착"), + 설계내역서시도: retitle(design, dbf, "0656", "설계내역서시도"), + 총괄내역서시도: retitle(summary, dbf, "0652", "총괄내역서시도"), + 총괄내역서구도: retitle(summary, dbf, "0653", "총괄내역서구도"), + 총괄내역서비교: retitle(summary, dbf, "0672", "총괄내역서비교"), + 총괄내역서구시도: fundSheet(dbf, tree, "0651", opts), + 설계내역서구시도: fundSheet(dbf, tree, "0655", opts), + 재료비집계표: groupCollSheet(u, tree, "0675", "M", opts), + 노무비집계표: groupCollSheet(u, tree, "0676", "L", opts), + 중장비집계표: groupCollSheet(u, tree, "0677", "X", opts), + 장비투입계획: groupCollSheet(u, tree, "0671", "X", opts), + 자재사용계획: groupCollSheet(u, tree, "0678", "M", opts), + 인력투입계획: groupCollSheet(u, tree, "0679", "L", opts), + 간이명세서: simpleSheet(dbf, stage), + 제잡비요율표: In.rateSheet(dbf, opts), + }; + for (const [id, sh] of Object.entries(out)) Object.assign(sh, { id }); + delete design._tree; + return out; + } + + // 서식 번호 → 변형 시트 id + const FORM_SHEET = { + "0303": "일위대가표수공양식", + "0605": "분할내역서", + "0609": "총괄집계표", + "0613": "견적내역서", + "0614": "견적내역서종양식", + "0615": "견적단가대비표", + "0606": "내역서도공양식", + "0654": "설계내역서굴착", + "0656": "설계내역서시도", + "0652": "총괄내역서시도", + "0653": "총괄내역서구도", + "0672": "총괄내역서비교", + "0651": "총괄내역서구시도", + "0655": "설계내역서구시도", + "0675": "재료비집계표", + "0676": "노무비집계표", + "0677": "중장비집계표", + "0671": "장비투입계획", + "0678": "자재사용계획", + "0679": "인력투입계획", + "0695": "간이명세서", + }; + + return { buildFormSheets, FORM_SHEET, fields, splitSheet, totalSheet, groupCollSheet }; +})(); + +if (typeof window !== "undefined") window.STMATE_REPORT_FORMS = StmateReportForms; +if (typeof module !== "undefined" && module.exports) module.exports = StmateReportForms; diff --git a/tools/stmate-test/report_init.js b/tools/stmate-test/report_init.js new file mode 100644 index 000000000..78dcc3322 --- /dev/null +++ b/tools/stmate-test/report_init.js @@ -0,0 +1,271 @@ +/* -*- coding: utf-8 -*- */ +/** + * 보고서 부속 (report_init.js) — 〓 INITIAL 〓 설정 시트 · 금액 한글 표기(AMTTOHAN) · 제잡비 요율표 인쇄 + * 근거: 지식DB STmate/old/20_분석 18 §2.3(INITIAL = 엑셀 → STmate 재등록 메타) · 24 §2.4(AMTTOHAN 「금 일억삼천사백이십만원정」) + * · §3(CONV_XLS_2_ST) · 06 §1~2 · 15 §4(제비율표 좌표 — scr_kanjub_rate.js StmateRate) + * 정답 대조: resources/tester/test_stmate_report_coll.py(INITIAL — 원본 STMATE 출력 엑셀) + */ +const StmateReportInit = (() => { + const g = typeof globalThis !== "undefined" ? globalThis : {}; + const C = g.STMATE_REPORT_CORE || require("./report_core.js"); + const { num, str } = C; + const tf = (v) => (String(v ?? "").trim() === "1" || v === true ? "True" : "False"); + + /** + * [Sheet정보] 18칸 — 칸 이름 · 시트 id · 열 자리(ITNAME NAME SIZE QTY UNIT SPOS · TAMT…NAMOUNT · INFO) + * 열 자리 = 각 시트 양식의 열 차례(재등록 때 읽는 자리) · EPOS = 그 시트 마지막 행 + */ + const F = (it, nm, sz, q, un, sp, amts, info) => ({ it, nm, sz, q, un, sp, amts, info }); + const LIST = F(null, 2, 3, null, 4, 4, [5], 7); + const BIMOK = F(null, 2, 3, null, 4, 4, [5, null, 7, null, 6, null, 8], 10); + const SLOTS = [ + ["재료비목록표", "재료비목록표", LIST], + ["노무비목록표", "노무비목록표", LIST], + ["경비목록표", "경비목록표", LIST], + ["일식목록표", "일식견적목록표", BIMOK], + ["환율 및 기초자료", "환율및기초자료", F(2, 3, 4, null, 5, 6, [6], 9)], + ["중기목록표", "중기목록표", BIMOK], + ["실적단가표", null, null], + ["일위대가목록표", "일위대가목록표", BIMOK], + ["산출근거목록표", "단가산출근거목록표", BIMOK], + [ + "자재단가대비표", + "자재단가대비표", + F(null, 2, 3, null, 4, 5, [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], 18), + ], + ["내역서", "설계내역서", F(1, 2, 3, 4, 5, 5, [6, 7, 10, 11, 8, 9, 12, 13], 15)], + ["총괄내역서", "총괄내역서", F(1, 2, 3, null, 5, 4, [4, 9, 13, 14, 15, 16], 8)], + ["중기사용료", "중기사용료", F(null, 1, 2, 3, 4, 5, [5, 6, 9, 10, 7, 8, 11, 12], 14)], + ["일위대가표", "일위대가표", F(null, 1, 2, 3, 4, 5, [5, 6, 9, 10, 7, 8, 11, 12], 14)], + [ + "단가산출근거", + "단가산출근거", + F(1, 2, null, null, null, 5, [3, null, 5, null, 4, null, 6], 7), + ], + ["", null, null], + ["", null, null], + ["표준시장목록표", "표준시장목록표", BIMOK], + ]; + const COLS = "CDEFGHIJKLMNOPQRSTUVWX".split(""); + + // 차수 목록 — P_ORD [지금 차수, 저장된 차수…] 다섯 칸 + function stageList(state, n) { + const ks = Object.keys((state && state.kanjubStages) || {}) + .map(Number) + .filter((k) => k > 0) + .sort((a, b) => a - b); + const list = ks.length ? ks : [1]; + return [n].concat(list, [0, 0, 0, 0, 0]).slice(0, 6).map(String); + } + + /** + * 〓 INITIAL 〓 — sheets = 묶음 시트 모형(id·name·rows) · n 차수 · opts.date(출력 일시) · opts.path(파일 자리) + * 절 머리(B) 의 D 칸 = 다음 절 머리 행 번호 + */ + function initialSheet(dbf, state, sheets, n = 1, opts = {}) { + const ini = (dbf && dbf.start_ini) || {}; + const prj = (dbf && dbf.prjlst) || {}; + const sh = { name: "〓 INITIAL 〓", title: "INITIAL", rows: [], cols: {} }; + const put = (r, cells) => C.put(sh, r, cells); + put(2, { + B: "※※ 현재 Sheet를 수정 또는 삭제하지 마십시요[수정/삭제시 STmate 데이타로 변환할 수 없습니다] ※※", + }); + const byId = {}; + for (const s of sheets || []) byId[s.id || s.name] = s; + put(4, { B: "[Sheet정보]", C: false, D: 24 }); + const hd = { B: "목차", C: "Sheet", D: "FPos", E: "ITNAME", F: "NAME", G: "SIZE", H: "QTY" }; + Object.assign(hd, { I: "UNIT", J: "SPOS", K: "EPOS" }); + "TAMT TAMOUNT MAMT MAMOUNT LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT" + .split(" ") + .forEach((t, i) => (hd[COLS[9 + i]] = t)); + hd.X = "INFO"; + put(5, hd); + SLOTS.forEach(([label, id, f], i) => { + const cells = { B: `${i + 1}. ${label}` }; + const s = id && (byId[id] || Object.values(byId).find((x) => x.name === id)); + if (s && f) { + Object.assign(cells, { + C: s.name, + D: `${opts.path || ""}[${opts.file || "STMATE"}]${s.name}`, + }); + const pos = { E: f.it, F: f.nm, G: f.sz, H: f.q, I: f.un, J: f.sp, K: s.rows.length }; + f.amts.forEach((v, j) => (pos[COLS[9 + j]] = v)); + pos.X = f.info; + for (const [k, v] of Object.entries(pos)) if (v != null) cells[k] = v; + } + put(6 + i, cells); + }); + // [공사정보] + put(24, { B: "[공사정보]", D: 33 }); + const info = [ + ["M_VER", str(prj.M_VER || "")], + ["PNAME", C.projectName(dbf)], + ["PCLAS", str(prj.PCLASS ?? "0").trim() || "0"], + ["SCLAS", str(prj.SCLASS ?? "0").trim() || "0"], + ["K_ORD", str(prj.K_ORD ?? "0").trim() || "0"], + ["PRIPR", tf(prj.PRIV_PR === "T" || prj.PRIV_PR === true ? "1" : prj.PRIV_PR)], + ["ACTYP", str(prj.ACTTYP ?? "0").trim() || "0"], + ["ACTOP", str(prj.ACTOPT ?? "0").trim() || "0"], + ]; + info.forEach(([k, v], i) => put(25 + i, v === "" ? { B: k } : { B: k, C: v })); + // [초기값] — 환율 8칸 = 달러 저·고 · 달러 저·고 · 유로 둘 · 엔 둘(원본 출력 차례) + put(33, { B: "[초기값]", D: 52 }); + const pOrd = stageList(state, n); + put(34, { B: "P_ORD", C: pOrd[0], D: pOrd[1], E: pOrd[2], F: pOrd[3], G: pOrd[4], H: pOrd[5] }); + const ex = ["EXLNM1", "EXHNM1", "EXLNM1", "EXHNM1", "EXEUR1", "EXEUR1", "EXYEN1", "EXYEN1"]; + const exRow = { B: "EXNM_" }; + ex.forEach((k, i) => (exRow[COLS[i]] = num(ini[k]))); + put(35, exRow); + const init = [ + ["EXRNM", tf(ini.EXRNM)], + ["RXNM_", str(ini.RXXNM)], + ["COND_", str(ini.CONLD)], + ["KCDNM", str(ini.KCDNM ?? "1")], + ["JCDNM", str(ini.JCDNM ?? "0")], + ["ECDNM", str(ini.ECDNM ?? "0")], + ["ACDNM", str(ini.ACDNM ?? "1")], + ["BPNNM", str(ini.BPNNM ?? "1")], + ["ZCDNM", str(ini.ZCDNM ?? "2")], + ["CHGNM", tf(ini.CHGNM)], + ["CHBNM", tf(ini.CHBNM)], + ["CHNNM", tf(ini.CHNNM)], + ["CUTNM", tf(ini.CUTNM)], + ["OPTNM", str(ini.OPTNM || "SML")], + ["S_JDA", tf(ini.S_JDA)], + ["SUBNM", str(ini.SUBNM)], + ]; + init.forEach(([k, v], i) => put(36 + i, { B: k, C: v })); + // [인쇄정보] + put(52, { B: "[인쇄정보]", D: 75 }); + const bm = [1, 2, 3, 4].map((i) => str(ini[`BMK_STR${i}`])); + put(53, { B: "BMSTR", C: bm[0], D: bm[1], E: bm[2], F: bm[3] }); + put(54, { B: "BMKNM", C: str(ini.BMKNM || "TLMS") }); + put(55, { B: "JEMNM", C: str(ini.JEMNM) }); + for (let i = 1; i <= 6; i++) { + const cells = { B: `JMKN${i}`, C: str(ini[`JMOK${i}`]).trim() }; + if (i < 6) cells.D = str(ini[`JMK${i}0`]).trim(); + put(55 + i, cells); + } + const caps = "CODE NO IBRD EQCOD ITNUM NAME SIZE QTY SUNIT AMT PAGE AMOUNT BIGO".split(" "); + caps.forEach((k, i) => put(62 + i, { B: `CAP_${k}`, C: str(ini[`CAP_${k}`]) })); + // [PREPARE] + put(75, { B: "[PREPARE]", D: 79 }); + put(76, { B: "PREP_VER", C: `w${str(ini.PRG_V)}` }); + put(77, { B: "PREP_COP", C: str(opts.cop || "STMATE 재현 앱") }); + put(78, { B: "PREP_DATE", C: str(opts.date || "") }); + put(79, { B: "[.끝.]" }); + return sh; + } + + /** + * 금액 한글 표기(AMTTOHAN) — 「금 일억삼천사백이십만원정」 + * 네 자리 묶음(만·억·조·경)마다 천·백·십 · 자리 숫자 1 도 「일」로 적음(금액 고침 막기 · 원본 예 「일억」) + */ + const DIG = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"]; + const SMALL = ["천", "백", "십", ""]; + const BIG = ["", "만", "억", "조", "경"]; + function amtToHan(v, opts = {}) { + const n = Math.trunc(Math.abs(num(v))); + if (!n) return opts.bare ? "영" : "금 영원정"; + const s = String(n); + const groups = []; + for (let i = s.length; i > 0; i -= 4) + groups.unshift(s.slice(Math.max(0, i - 4), i).padStart(4, "0")); + let out = ""; + groups.forEach((gp, gi) => { + let part = ""; + for (let k = 0; k < 4; k++) if (gp[k] !== "0") part += DIG[gp[k]] + SMALL[k]; + if (part) out += part + BIG[groups.length - 1 - gi]; + }); + const sign = num(v) < 0 ? "마이너스 " : ""; + return opts.bare ? sign + out : `금 ${sign}${out}원정`; + } + + /** + * 제잡비 요율표 인쇄(PM_KAN_RATE) — 공사규모 묶음 × 공사기간 · 간접노무비·기타경비(공종별) · 일반관리비 · 이윤 + * + 부속표(보험·안전관리비) · 값 = STC 안 RATE_R.DBF(행렬 좌표는 StmateRate) + */ + function rateSheet(dbf, opts = {}) { + const R = g.StmateRate || require("./scr_kanjub_rate.js"); + const src = opts.table || "RATE_R.DBF"; + const rows = ((dbf && dbf.rate_tables) || {})[src]; + const m = R.matrixFromRows(rows || []); + const sh = C.newSheet("제잡비요율표", "제 잡 비 요 율 표", C.projectName(dbf), "R"); + if (!m.length) { + C.push(sh, { A: `${src} 없음`, _k: "item" }); + return sh; + } + const kongs = [ + [1, "토목"], + [2, "산설(토)"], + [3, "조경"], + [4, "건축"], + [5, "산설(건)"], + ]; + const cols = "CDEFGHIJKLMNOP".split(""); + const h3 = { A: "공사규모", B: "공사기간", _k: "head" }; + kongs.forEach(([, l], i) => ((h3[cols[i]] = `간노 ${l}`), (h3[cols[i + 5]] = `기타 ${l}`))); + Object.assign(h3, { M: "일반관리비", N: "일반관리비(전문)", O: "이윤" }); + C.push(sh, h3); + for (const b of R.BANDS) { + if (b.optional && !kongs.some(([c]) => R.cell(m, b.row, c))) continue; + R.KIKAN.forEach((k, p) => { + const row = b.row + p; + const cells = { A: p === 0 ? b.name : "", B: k, _k: "item" }; + kongs.forEach(([c], i) => { + cells[cols[i]] = R.cell(m, row, c); + cells[cols[i + 5]] = R.cell(m, row, c + 5); + }); + if (p === 0) + Object.assign(cells, { + M: R.cell(m, row, 11), + N: R.cell(m, row, 15), + O: R.cell(m, row, 16), + }); + C.push(sh, cells); + }); + } + C.push(sh, {}); + C.push(sh, { A: "부속표", B: "구분", C: "요율", _k: "head" }); + const annex = [ + ["산재보험료", 45, 1], + ["건강보험료", 46, 1], + ["연금보험료", 46, 6], + ["노인장기요양보험료", 46, 11], + ]; + for (const [l, r, c] of annex) C.push(sh, { B: l, C: R.cell(m, r, c), _k: "item" }); + for (let gr = 1; gr <= 7; gr++) + C.push(sh, { + B: `고용보험 ${gr < 7 ? `${gr} 등급` : "7 등급이하"}`, + C: R.cell(m, 45, 10 + gr), + _k: "item", + }); + C.push(sh, {}); + C.push(sh, { + A: "안전관리비", + B: "공종", + C: "5억 미만", + D: "5~50억 미만", + E: "기초액(천원)", + F: "50억 이상", + _k: "head", + }); + for (const [l, t] of [ + ["건축", 0], + ["토목", 1], + ["중건설", 3], + ["특수건설", 4], + ]) { + const o = R.ANJUN_OFF[t]; + const cells = { B: l, C: R.cell(m, 10 + o, 17), D: R.cell(m, 20 + o, 17), _k: "item" }; + Object.assign(cells, { E: R.cell(m, 20 + o, 18), F: R.cell(m, 25 + o, 17) }); + C.push(sh, cells); + } + return sh; + } + + return { initialSheet, amtToHan, rateSheet, SLOTS }; +})(); + +if (typeof window !== "undefined") window.STMATE_REPORT_INIT = StmateReportInit; +if (typeof module !== "undefined" && module.exports) module.exports = StmateReportInit; diff --git a/tools/stmate-test/report_san.js b/tools/stmate-test/report_san.js new file mode 100644 index 000000000..876e866aa --- /dev/null +++ b/tools/stmate-test/report_san.js @@ -0,0 +1,315 @@ +/* -*- coding: utf-8 -*- */ +/** + * 단가산출근거 펼침 (report_san.js) — 0402 단가산출근거 · 식 줄마다 계산값을 넣은 글 + 줄 금액 + 계표시 합 + * 근거: 지식DB STmate/old/20_분석 23(SMT 전수파싱) · 26 §3(계표시 이름 DISP_N1~5 · DISP_EX · DISP_TOT) + * REA 30 result_text_restoration(따옴표 글 · 끝 '=' 뒤 금액) · old/30_원자료/print_form_map.txt(0402) + * 줄 계산 = smt_eval.evaluate(계산 엔진과 같은 코드) · 이 파일은 글 꾸밈과 쪽 배치만 + * 정답 대조: resources/tester/test_stmate_report_san.py (원본 STMATE 출력 엑셀) + */ +const StmateReportSan = (() => { + const g = typeof globalThis !== "undefined" ? globalThis : {}; + const C = g.STMATE_REPORT_CORE || require("./report_core.js"); + const { num, str, code } = C; + // 한 쪽 줄 수 — 호표마다 쪽 끝 줄에 총계 · 원본 출력 실측(LNP_CNT 20 → 35줄 · 25 → 50줄)에 맞춘 꼴(추정) + const pageRows = (ini) => Math.max(20, 3 * (num(ini.LNP_CNT) || 20) - 25); + // 수치 글 다듬기(FLT_JAB=1) — 따옴표 밖 수치의 끝 0 을 걷음(1.40 → 1.4 · 1.0 → 1) · 원본 출력 실측(추정) + const trimNum = (seg) => + seg.replace(/(^|[^\w.])(\d+\.\d+)(?![\w.])/g, (m, a, n) => a + String(Number(n))); + const COLS = ["C", "D", "E", "F"]; + const REF_RE = /&([A-Z][A-Z_0-9]*?)(?::([LMS]))?&/g; + + const fmt = (v, d) => + new Intl.NumberFormat("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }).format( + v, + ); + // 참조 단가 글 — 소수 있으면 그 자리까지(최대 4) · 천 단위 쉼표 + const fmtBase = (v) => { + const s = String(C.fix(v, 4)); + const d = s.includes(".") ? s.split(".")[1].length : 0; + return fmt(v, d); + }; + + // 따옴표 밖 조각만 fn 으로 고침 · 따옴표는 그대로 둠 + function mapOutside2(line, fn) { + return line + .split(/('[^']*'?)/) + .map((seg) => (seg.startsWith("'") ? seg : fn(seg))) + .join(""); + } + + // 따옴표 밖 조각마다 fn 을 돌리고 따옴표는 빈칸으로(원본 출력과 같은 글 자리) + function mapOutside(line, fn) { + let out = "", + buf = "", + inQ = false; + for (const ch of line) { + if (ch === "'") { + if (!inQ) out += fn(buf); + else out += buf; + buf = ""; + out += " "; + inQ = !inQ; + continue; + } + buf += ch; + } + return out + (inQ ? buf : fn(buf)); + } + + // 따옴표 밖 글에서 n 번째 '=' 자리들(괄호 밖 · '==' 아님) — [시작 위치] + function eqPositions(s) { + const out = []; + let depth = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "(" || c === "[") depth++; + else if (c === ")" || c === "]") depth--; + else if (c === "=" && depth === 0 && s[i - 1] !== "=" && s[i + 1] !== "=" && s[i - 1] !== ":") + out.push(i); + } + return out; + } + + /** + * 대입 줄 글 — 「이름 = 식 =」 의 둘째 '=' 뒤에 값(ZCDNM 자리) · 쉼표로 나뉜 조각마다 + * vals = evaluate 줄의 assigned 「이름=값」 차례 + */ + function renderAssign(line, assigned, digits, plain) { + const vals = {}; + for (const a of assigned || []) { + const at = a.indexOf("="); + vals[a.slice(0, at).toUpperCase()] = Number(a.slice(at + 1)); + } + // 따옴표 밖 글을 한 줄로 잇고(따옴표 자리 표시) '=' 위치를 찾음 + const marks = []; + let flat = ""; + let inQ = false; + for (const ch of line) { + if (ch === "'") inQ = !inQ; + flat += inQ || ch === "'" ? "\u0001" : ch; + } + let name = null; + let seen = 0; + const eqs = eqPositions(flat); + for (const p of eqs) { + // 앞 쉼표(따옴표 밖) 이후의 첫 '=' 는 대입 기호 · 둘째가 값 자리 + const prevComma = flat.lastIndexOf(",", p); + const seg = flat.slice(prevComma + 1, p).replace(/\u0001+/g, " "); + const m = seg.match(/^\s*([A-Za-z_][A-Za-z_0-9]*)\s*$/); + if (m) { + name = m[1].toUpperCase(); + seen = 1; + continue; + } + if (seen === 1 && name && vals[name] !== undefined) { + marks.push([p, vals[name]]); + seen = 2; + } + } + let out = ""; + let last = 0; + for (const [p, v] of marks) { + out += + line.slice(last, p + 1) + + " " + + (plain ? C.half(v, digits).toFixed(digits) : fmt(v, digits)); // FLT_JAB=1 이면 쉼표 없음 + last = p + 1; + } + out += line.slice(last); + return out.replace(/'/g, " "); + } + + // 금액 줄 글 — &코드& 를 참조 단가로 · 끝 '=' 뒤에 줄 금액(lineDigits 자리) + // 「:=A01」(금액 이름표)는 「=」 자리로 · 「->」(합계 제외 표시)는 글에서 뺌 + function renderAmount(line, base, amount, digits, vars, pctBase) { + line = line.replace(/:=(\s*)[A-Z]\d{2}/, "=$1").replace(/->\s*$/, ""); + let flat = ""; + let inQ = false; + for (const ch of line) { + if (ch === "'") inQ = !inQ; + flat += inQ || ch === "'" ? "\u0001" : ch; + } + const eqs = eqPositions(flat.replace(/:=.*$/, (m) => "\u0002".repeat(m.length))); + const at = eqs.length ? eqs[eqs.length - 1] : -1; + let s = at >= 0 ? line.slice(0, at + 1) + " " + fmt(amount, digits) + line.slice(at + 1) : line; + // [식] = 그 줄 시점의 변수 값 · &코드& = 참조 단가 + const X = + g.STMATE_SMT_EXPR || (typeof require !== "undefined" ? require("./scr_edit_expr.js") : null); + const bracket = (m, e) => { + const r = X ? X.evaluate(e, vars || {}) : null; + return r && r.value != null && !r.error ? fmtBase(r.value) : m; + }; + s = mapOutside(s, (seg) => + seg + .replace(REF_RE, () => (pctBase ? fmt(base, digits) : fmtBase(base))) + .replace(/\[([^\]]*)\]/g, bracket), + ); + return s; + } + + // 결과적용 비목(SBMOK) 이름 — 총계 줄 꼬리 + const SBMOK_NAME = { M: "재료비", 1: "재료비", L: "노무비", 2: "노무비", S: "경비", 3: "경비" }; + // 비율 단위(M%·L%·S%·T%) 참조 — 밑수는 줄 금액 자리(소수 한 자리)로 보임 + const isPct = (lookup, tok) => { + const m = String(tok).match(/^([A-Z]\d{1,5})[LMS]?$/); + if (!m) return true; // 금액 이름표·누계(PA01 · PS_ · PC_ …)도 줄 금액 합 + const c = m[1].length < 6 ? m[1][0] + m[1].slice(1).padStart(5, "0") : m[1]; + const r = lookup(c); + return !!r && /%$/.test(String(r.unit || "").trim()); + }; + + // 참조 단가(글에 보일 값) — 비목 지정이면 그 비목 · 자원은 합계 · 그 밖은 세 비목 합 + function shownBase(lookup, tok, ln) { + const m = String(tok).match(/^([A-Z]\d{1,5})([LMS])?$/); + if (!m) return ln.base; + const c = m[1].length < 6 ? m[1][0] + m[1].slice(1).padStart(5, "0") : m[1]; + const r = lookup(c); + if (!r) return 0; + if (/%$/.test(String(r.unit || "").trim())) return ln.base; + if (m[2]) return r[m[2]]; + return r.M + r.L + r.S; + } + + /** + * 0402 시트 — u = report_unit unitContext(호표 번호·출력 설정) + * 호표 = [호표 줄 · 이름 줄 · 식 줄(원문 한 줄 = 한 행) · 빈 줄 · 총계] · 쪽(35줄) 단위로 채움 + */ + function sanSheet(u, opts = {}) { + const SE = g.STMATE_SMT_EVAL; + const EN = g.STMATE_ENGINE; + const { dbf, idx, ini, order, po, hopyoNo } = u; + const sh = C.newSheet("단가산출근거", "단 가 산 출 근 거", C.projectName(dbf), "G"); + C.push(sh, { A: "공 종", B: "산 출 내 역", _k: "head" }); + order.forEach((k, i) => (sh.rows[2][COLS[i]] = C.bimokLabel(ini, k))); + C.push(sh, { A: "산 출 근 거", _k: "head" }); + const memos = (dbf && dbf.costn_memos) || {}; + const edited = opts.editedSmt || {}; + const ctx = { dbfData: dbf, state: {}, uiState: {} }; + const { lookup } = EN.costnLookup(ctx); + const dispN = (k) => + str(ini[`DISP_N${k}`] || ["소계", "계", "합계", "총계", "총합계"][k - 1]).trim(); + const dispEx = str(ini.DISP_EX || "제외금액").trim(); + const dispTot = str(ini.DISP_TOT || "총 계"); + const page = pageRows(ini); + const lineDigits = 1; + const put4 = (cells, v) => { + const t = { M: v.M, L: v.L, S: v.S, T: v.M + v.L + v.S }; + order.forEach((k, i) => (cells[COLS[i]] = C.fix(t[k], 6))); + return cells; + }; + for (const r of idx.ofPrefix("D")) { + const c = code(r); + const start = sh.rows.length; + const no = hopyoNo[c]; + const lbl = po.codeMode ? c : `제${String(no).padStart(4)}호표`; + const p = idx.price(c, 1); + const head = put4({ B: lbl, _k: "hopyo", _code: c }, p); + // 호표 줄 비고 — 호표 비고면 호표 글 · 호표 모드면 가격지 위치(BQCODE) + const hrm = po.remarkHopyo && !po.codeMode ? lbl : po.remarkCode ? c : str(r.BQCODE).trim(); + if (hrm) head.A = hrm; + C.push(sh, head); + C.push(sh, { + B: `${str(r.NAME)} ${str(r.SIZE)} / ${str(r.UNIT).trim()}`, + _k: "parent", + _code: c, + }); + const text = edited[c] || (memos[c] && memos[c].text) || ""; + const src = text.split(/\r?\n/); + const ev = text.trim() ? SE.evaluate(text, lookup, EN.smtOptions(ctx, c)) : { lines: [] }; + const byNo = {}; + for (const ln of ev.lines) byNo[ln.lineNo] = ln; + const cur = {}; // 그 줄 시점의 변수 값(대입 줄 assigned 차례로) + const acc = [null, 0, 0, 0, 0, 0].map(() => ({ M: 0, L: 0, S: 0 })); + const digits = num(ini.ZCDNM ?? 2) || 2; + const tidy = + String(ini.FLT_JAB ?? "2").trim() === "1" ? (t) => mapOutside2(t, trimNum) : (t) => t; + // 원문이 비면 빈 호표 한 쪽 + for (let i = 0; i < (text ? src.length : 0); i++) { + const raw = tidy(src[i]); + let ln = byNo[i + 1]; + if (!ln) { + C.push(sh, { _k: "blank" }); + continue; + } + const mk = + ln && + ln.kind === "text" && + String(raw) + .trim() + .match(/^\((=+)\)/); + if (mk && mk[1].length <= 5) ln = { kind: "section", level: mk[1].length }; + if (ln.kind === "section") { + const v = acc[ln.level]; + C.push(sh, put4({ B: dispN(ln.level), _k: "sub" }, v)); + for (let k = 1; k <= ln.level; k++) acc[k] = { M: 0, L: 0, S: 0 }; + continue; + } + if (ln.kind === "exclude") { + // (-=) 지금 구간을 위 단 합에서 뺌 · (-==) 이전 전부를 지움(smt_eval 과 같음) + const v = { ...acc[ln.level] }; + C.push(sh, put4({ B: `${dispN(ln.level)}(${dispEx})`, _k: "sub" }, v)); + const cut = { ...acc[1] }; + for (let k = 1; k <= 5; k++) + for (const x of "MLS") acc[k][x] = ln.level === 1 && k > 1 ? acc[k][x] - cut[x] : 0; + continue; + } + if (ln.kind === "amount") { + const a = ln.amounts; + const shown = shownBase(lookup, ln.ref, ln); + const cells = { + B: renderAmount(raw, shown, a.M + a.L + a.S, lineDigits, cur, isPct(lookup, ln.ref)), + _k: "line", + }; + put4(cells, a); + const rm = refRemark(u, ln.ref); + if (rm) cells.A = rm; + if (!ln.excluded) for (let k = 1; k <= 5; k++) for (const x of "MLS") acc[k][x] += a[x]; + C.push(sh, cells); + continue; + } + // 「;;」 줄 = 숨은 줄(계산만 · 인쇄 안 함) + const hidden = /^s*;;/.test(raw); + const body = hidden + ? "" + : ln.kind === "assign" + ? renderAssign(raw, ln.assigned, digits, String(ini.FLT_JAB ?? "2").trim() === "1") + : raw.replace(/'/g, " "); + for (const a of ln.assigned || []) { + const at = a.indexOf("="); + cur[a.slice(0, at).toUpperCase()] = Number(a.slice(at + 1)); + } + C.push(sh, body.trim() ? { B: body, _k: "text" } : { _k: "blank" }); + } + // 쪽 채움 — 빈 줄 하나 이상 띄우고 총계는 쪽 끝 줄 + const used = sh.rows.length - start + 2; + const pages = Math.max(1, Math.ceil(used / page)); + while (sh.rows.length - start < pages * page - 1) C.push(sh, { _k: "blank" }); + const to = SBMOK_NAME[str(r.SBMOK).trim()]; + C.push( + sh, + put4({ A: to ? `${dispTot} (${to} 로적용)` : dispTot, _k: "total", _code: c }, p), + ); + } + return sh; + } + + // 참조 비고 — 코드 모드 = 원문 참조 글(L00002L) · 호표 비고(SH_TYP3=1) = 종류 이름 + 번호 + 비목 · 그 밖 = 가격지 위치 + function refRemark(u, tok) { + const m = String(tok).match(/^([A-Z]\d{1,5})([LMS])?$/); + if (!m) return ""; + const c = m[1].length < 6 ? m[1][0] + m[1].slice(1).padStart(5, "0") : m[1]; + const r = u.idx.by[c]; + if (!r) return ""; + if (u.po.codeMode || u.po.remarkCode) return `${c}${m[2] || ""}`; + const kind = u.po.kind[c[0]] || ""; + if (u.po.remarkHopyo && "BD".includes(c[0])) + return `${kind}${String(u.hopyoNo[c]).padStart(4)}호표`; + if (u.po.remarkHopyo) return `${kind}${String(u.hopyoNo[c]).padStart(5)}${m[2] || ""}`; + return str(r.BQCODE).trim(); + } + + return { sanSheet, renderAssign, renderAmount, pageRows }; +})(); + +if (typeof window !== "undefined") window.STMATE_REPORT_SAN = StmateReportSan; +if (typeof module !== "undefined" && module.exports) module.exports = StmateReportSan; diff --git a/tools/stmate-test/report_unit.js b/tools/stmate-test/report_unit.js index ef148aa14..d4de3a7a4 100644 --- a/tools/stmate-test/report_unit.js +++ b/tools/stmate-test/report_unit.js @@ -170,8 +170,8 @@ const StmateReportUnit = (() => { po.codeMode || po.remarkCode ? c : po.remarkHopyo - ? `${po.kind[prefix]}${pad(no, 5)}` - : str(r.BQCODE).trim(); + ? `${po.kind[prefix]}${pad(no, 5)}` + : str(r.BQCODE).trim(); if (rm) hd.M = rm; C.push(sh, hd); const ev = ctx.evals[c]; @@ -197,6 +197,8 @@ const StmateReportUnit = (() => { cells[PAIR_COLS[i][0]] = fix(u[k]); cells[PAIR_COLS[i][1]] = fix(a[k], 1); }); + if (!ln.pct && !ln.labor && ln.unit !== "$") + cells._f = C.pairFormulas(order, PAIR_COLS, "C", 1, true); if (ln.code !== "PR") { const rm2 = lineRemark(ctx, ref, ln.code, ln.pct); if (rm2) cells.M = rm2; diff --git a/tools/stmate-test/report_xlsx.py b/tools/stmate-test/report_xlsx.py index 0c5dcf921..af54ae1d8 100644 --- a/tools/stmate-test/report_xlsx.py +++ b/tools/stmate-test/report_xlsx.py @@ -5,6 +5,8 @@ 근거: 지식DB STmate/old/20_분석 19 §3(목차·안내 시트) · 20 §3(SET_REP 서식) · 06 §3(출력별 반올림). 쓰는 것: 값 · 글꼴(제목·머리·묶음 줄 굵게) · 테두리 · 숫자 서식(정수 #,##0 · 소수 자리 그대로) · 열 폭 · 인쇄 설정(A4 · 폭 맞춤 · 머리 행 반복 · 쪽 넘김) · 목차 바로가기 하이퍼링크. +수식(formulas=True) — 행의 _f {열: '=…{r}…'} 를 수식으로(ST_P_XLS Prn_FMLA_INFO) · 값은 엑셀이 다시 셈. +인쇄 설정(print) — 방향(landscape·portrait) · 글꼴 이름 · 크기(SET_REP 인쇄 메뉴 옵션). """ from __future__ import annotations @@ -48,7 +50,10 @@ def sheet_title(name: str, used: set[str]) -> str: return title -def write_sheet(ws, sheet: dict) -> None: +def write_sheet(ws, sheet: dict, formulas: bool = False, prn: dict | None = None) -> None: + prn = prn or {} + font_name = prn.get("font") or FONT + font_size = float(prn.get("size") or 10) rows = sheet.get("rows") or [] width: dict[int, int] = {} max_col = 1 @@ -62,10 +67,13 @@ def write_sheet(ws, sheet: dict) -> None: for col, val in _cells(row).items(): c_idx = column_index_from_string(col) max_col = max(max_col, c_idx) - cell = ws.cell(row=r_idx, column=c_idx, value=val) + fml = (row.get("_f") or {}).get(col) if formulas else None + cell = ws.cell( + row=r_idx, column=c_idx, value=fml.replace("{r}", str(r_idx)) if fml else val + ) bold = kind in BOLD_KINDS or kind == "title" - size = 16 if kind == "title" else 10 - cell.font = Font(name=FONT, size=size, bold=bold) + size = 16 if kind == "title" else font_size + cell.font = Font(name=font_name, size=size, bold=bold) if isinstance(val, (int, float)) and not isinstance(val, bool): cell.number_format = _num_format(val) cell.alignment = Alignment(horizontal="right", vertical="center") @@ -96,7 +104,10 @@ def write_sheet(ws, sheet: dict) -> None: w = width.get(c_idx, 4) ws.column_dimensions[get_column_letter(c_idx)].width = min(max(w * 1.6 + 2, 6), 48) ws.page_setup.paperSize = ws.PAPERSIZE_A4 - ws.page_setup.orientation = "landscape" if max_col > 8 else "portrait" + auto = "landscape" if max_col > 8 else "portrait" + ws.page_setup.orientation = ( + prn.get("orient") if prn.get("orient") in ("landscape", "portrait") else auto + ) ws.page_setup.fitToWidth = 1 ws.page_setup.fitToHeight = 0 ws.sheet_properties.pageSetUpPr.fitToPage = True @@ -105,16 +116,18 @@ def write_sheet(ws, sheet: dict) -> None: ws.freeze_panes = ws.cell(row=head_rows + 1, column=1) if head_rows else None -def build_report_xlsx(sheets: list[dict]) -> bytes: +def build_report_xlsx(sheets: list[dict], formulas: bool = False, prn: dict | None = None) -> bytes: """시트 모형 여럿 → 한 통 엑셀.""" wb = openpyxl.Workbook() wb.remove(wb.active) used: set[str] = set() for sheet in sheets: ws = wb.create_sheet(title=sheet_title(sheet.get("name", ""), used)) - write_sheet(ws, sheet) + write_sheet(ws, sheet, formulas, prn) if not wb.worksheets: wb.create_sheet("빈 보고서") + if formulas: + wb.calculation.fullCalcOnLoad = True # 수식 값은 엑셀이 열 때 셈 buf = BytesIO() wb.save(buf) return buf.getvalue() diff --git a/tools/stmate-test/scr_kanjub.js b/tools/stmate-test/scr_kanjub.js index 65e0bdb6d..f2bd73186 100644 --- a/tools/stmate-test/scr_kanjub.js +++ b/tools/stmate-test/scr_kanjub.js @@ -210,7 +210,20 @@ : tab === "ord" ? `