diff --git a/resources/tester/helper_stmate_report.cjs b/resources/tester/helper_stmate_report.cjs index aa3b7de53..1fb566fdc 100644 --- a/resources/tester/helper_stmate_report.cjs +++ b/resources/tester/helper_stmate_report.cjs @@ -1,4 +1,5 @@ -// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage, mode(pps = 조달청 XML · forms = 양식 변형), opts } · 연쇄 재계산(계산완료) 뒤 시트 모형 +// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage, mode(pps = 조달청 XML · forms = 양식 변형 · +// ui = 인쇄 선택 화면 상태(scr_report_opts effective)로 묶음), opts } · 연쇄 재계산(계산완료) 뒤 시트 모형(_f 수식 칸 포함) globalThis.window = globalThis; globalThis.document = globalThis.document || { readyState: "complete" }; // 노드 — 화면 전용 분기 막음 require("../../tools/stmate-test/calc.js"); @@ -37,8 +38,17 @@ process.stdin.on("end", () => { process.stdout.write(JSON.stringify(out)); return; } + if (mode === "ui") { + require("../../tools/stmate-test/scr_report_opts.js"); + const uctx = { dbfData: load, uiState: { reportOpts: opts || {} } }; + const eff = globalThis.STMATE_REPORT_OPTS.effective(uctx); + const bk = Book.buildBook(eff.dbf, ctx.state, computed, stage || 1, eff.opts); + const sheets = bk.sheets.map((s) => ({ id: s.id || s.name, name: s.name, rows: s.rows })); + process.stdout.write(JSON.stringify({ sheets, print: eff.print, formulas: eff.formulas })); + 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 }; + for (const s of book.sheets) out[s.id || s.name] = Object.assign({}, s); process.stdout.write(JSON.stringify(out)); }); diff --git a/resources/tester/test_stmate_report_link.py b/resources/tester/test_stmate_report_link.py new file mode 100644 index 000000000..95558dd03 --- /dev/null +++ b/resources/tester/test_stmate_report_link.py @@ -0,0 +1,340 @@ +"""STMATE 재현 앱 보고서 3차(G5) — 엑셀 수식 · 목차 · 안내 · 금액 한글 · 인쇄 선택을 원본 STMATE 엑셀과 대조. + +정답 = 실무 STC 옆 원본 출력 엑셀(.xlsx) — 수식은 openpyxl 수식 읽기(data_only 아님) 글 그대로. +앱 쪽 = report_link.js(시트 간 참조 수식 · 바로가기) · report_book.js(목차 · 안내) · scr_report_opts.js(인쇄 선택). +빼는 칸: 시트 이름표 열 3행부터(재등록 숨은 메타 글) · 호표 머리 메타(N열 HYPERLINK) · +설계내역서 행 번호(원본은 인쇄 쪽 채움 빈 줄이 더 있어 행 번호만 다름 — 같은 시트 안 참조 번호를 # 로 맞댐). +""" + +import json +import re +import subprocess +import sys +import warnings +from functools import lru_cache +from pathlib import Path + +import openpyxl +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_stmate_report_sheets import ( # noqa: E402 + DOCS, + FULL, + HELPER, + PAIRS, + book, + norm, + original, + server, +) + +ROOT = Path(__file__).resolve().parents[2] +SAME = re.compile(r"(? 2 + and (isinstance(v, str) and (v.startswith("=") or k[0] in "OPQ")) + and "목차 →" not in v + } + got = { + k: v + for k, v in mine_formulas(sh).items() + if k[0] in cols and k[1] > 2 and "목차 →" not in str(v) + } + if tag: + want = {k: v for k, v in want.items() if k[0] != tag} + bad = [(f"{c}{r}", want.get((c, r)), got.get((c, r))) for (c, r) in set(want) | set(got)] + bad = [b for b in bad if b[1] != b[2]] + assert len(want) > 1, f"{name} {sid} 원본 수식 없음" + assert not bad, f"{name} {sid} 수식 불일치 {len(bad)}건 {sorted(bad)[:4]}" + + +def content_index(rows_get, n, start=3): + return [i for i in range(start, n) if any(norm(rows_get(i, c)) for c in "ABCDEFGHIJKLMN")] + + +@pytest.mark.parametrize("name", FULL) +def test_design_sheet_formula_patterns_match_original(name): + """설계내역서 — 묶음 SUMIF · 품목 단가 참조 · ROUNDDOWN · 열쇠 P/Q/R · 바로가기(같은 시트 행 번호만 #).""" + sh = book(name)["설계내역서"] + vals = original(name, sh["name"]) + orig = formulas(name, sh["name"]) + mine = mine_formulas(sh) + oi = content_index( + lambda i, c: vals[i][ord(c) - 65] if ord(c) - 65 < len(vals[i]) else None, len(vals) + ) + mi = content_index(lambda i, c: sh["rows"][i].get(c), len(sh["rows"])) + assert len(oi) == len(mi) > 50 + nz = lambda v: SAME.sub(r"\1#", v) if isinstance(v, str) else v # noqa: E731 + bad = [] + for a, b in zip(oi, mi): + for c in ["F", "G", "H", "I", "J", "K", "L", "M", "P", "Q", "R", "AC"]: + ov = orig.get((c, a + 1)) + ov = ov if isinstance(ov, str) and (ov.startswith("=") or c in "PQR") else None + if nz(ov) != nz(mine.get((c, b + 1))): + bad.append((f"{c}{a + 1}", ov, mine.get((c, b + 1)))) + assert not bad, f"{name} 설계내역서 수식 {len(bad)}건 {bad[:3]}" + + +@pytest.mark.parametrize("name", FULL) +def test_summary_group_rows_point_to_design_groups(name): + """총괄 공종 줄 · 순공사비 = 설계내역서 묶음 줄 금액 참조 — 가리키는 설계 행의 공종·명칭이 원본과 같음.""" + bk = book(name) + sm, ds = bk["총괄내역서"], bk["설계내역서"] + orig = formulas(name, sm["name"]) + dvals = original(name, ds["name"]) + mine = mine_formulas(sm) + ref = re.compile(r"^=[^!]+!G(\d+)$") + checked = 0 + for (c, r), v in orig.items(): + if c != "D" or not isinstance(v, str) or not ref.match(v): + continue + orow = int(ref.match(v).group(1)) + m = ref.match(mine.get(("D", r), "") or "") + assert m, f"{name} 총괄 D{r} 참조 없음" + mrow = int(m.group(1)) + o_key = [norm(x) for x in dvals[orow - 1][:3]] + m_key = [norm(ds["rows"][mrow - 1].get(k)) for k in "ABC"] + if o_key == [None, None, None]: # 끝 줄(순공사비) — 둘 다 끝 줄 + assert ds["rows"][mrow - 1].get("_k") == "final" + else: + assert o_key == m_key, f"{name} 총괄 D{r} {o_key} ≠ {m_key}" + checked += 1 + assert checked > 3 + + +def build(name, opts=None, mode=None): + stc, _, stage = PAIRS[name] + load = server.api_load(server.LoadReq(source_path=str(DOCS / stc))) + body = {"load": load, "stage": stage, "opts": opts or {}} + if mode: + body["mode"] = mode + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps(body, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + return json.loads(out) + + +@lru_cache(maxsize=None) +def ordered_like_original(name): + """원본 엑셀 시트 차례를 인쇄순서(F1-52)로 넘겨 짠 묶음.""" + ids = {v["name"]: k for k, v in book(name).items()} + wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], read_only=True) + order = [ids[n] for n in wb.sheetnames if n in ids and not n.startswith(("〓", "※"))] + return wb.sheetnames, build(name, {"order": order}) + + +@pytest.mark.parametrize("name", FULL) +def test_print_order_and_toc_sheet_match_original(name): + """인쇄순서 = 원본 시트 차례 그대로 · 목차 시트 B·D 글 · 바로가기 수식 꼴.""" + names, bk = ordered_like_original(name) + assert [v["name"] for v in bk.values()] == names + toc = bk["〓 목 차 〓"] + orig = original(name, "〓 목 차 〓") + for i, o in enumerate(orig): + m = toc["rows"][i] if i < len(toc["rows"]) else {} + for j, c in enumerate("BD"): + ov = o[1 + j * 2] if 1 + j * 2 < len(o) else None + assert (ov or None) == (m.get(c) or None), f"{name} 목차 {c}{i + 1}" + of = formulas(name, "〓 목 차 〓") + mf = mine_formulas(toc) + for r in range(4, len(orig) + 1): + assert of.get(("D", r)) == mf.get(("D", r)), f"{name} 목차 D{r} 수식" + assert of.get(("D", 1)) == mf.get(("D", 1)) + + +@pytest.mark.parametrize("name", FULL) +def test_guide_sheet_matches_original(name): + """안내 시트 — 판·산근 수식 갈래 글 · 알림 줄(% 단위 · 이윤보정액 · 총공사비 확인) · 설계내역서 줄 번호만 #.""" + _, bk = ordered_like_original(name) + g = bk["※※안내※※"]["rows"] + orig = original(name, "※※안내※※") + nz = lambda v: re.sub(r"【설계내역서】\d+", "【설계내역서】#", v) if isinstance(v, str) else v # noqa: E731 + bad = [] + for i in range(max(len(orig), len(g))): + o = orig[i] if i < len(orig) else [] + m = g[i] if i < len(g) else {} + for j, c in enumerate("ABC"): + ov = o[j] if j < len(o) else None + if nz(ov or None) != nz(m.get(c) or None): + bad.append((f"{c}{i + 1}", ov, m.get(c))) + assert not bad, f"{name} 안내 불일치 {bad[:3]}" + + +def test_amount_to_hangul_on_every_original_sample(): + """AMTTOHAN — 원본 엑셀이 보인 한글 금액 전부(NUMBERSTRING(n,1) 꼴): 영덕 금액대비표 2 · 대흥 2공구 속표지 3 · 울진 속표지 6.""" + warnings.filterwarnings("ignore") + samples = [] + yd = PAIRS["영덕변경"][1] + ws = openpyxl.load_workbook(DOCS / yd, data_only=True)["설계변경금액대비표"] + for r in (7, 8): + samples.append((ws[f"F{r}"].value, ws[f"C{r}"].value, False)) + for f, sn in [ + ("2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/4.2 수량산출(2공구).xlsx", "속표지"), + ( + "25년 산불진화임도(기번8)(울진소광)/1. 설계원본/03-내역/01-개요-2025년 산불진화임도사업(울진 금강송 소광).xlsx", + "속표지(남부)", + ), + ( + "25년 산불진화임도(기번8)(울진소광)/1. 설계원본/03-내역/01-개요-2025년 산불진화임도사업(울진 금강송 소광).xlsx", + "속표지(울진)", + ), + ]: + ws = openpyxl.load_workbook(DOCS / f, data_only=True)[sn] + for r in (9, 10, 11): + samples.append((ws[f"E{r}"].value, ws[f"F{r}"].value, True)) + js = ( + "const I=require('./tools/stmate-test/report_init.js');" + f"const s={json.dumps([[a, b] for a, _, b in samples])};" + "process.stdout.write(JSON.stringify(s.map(([v,b])=>I.amtToHan(v,{bare:b}))))" + ) + got = json.loads(subprocess.check_output(["node", "-e", js], cwd=ROOT, encoding="utf-8")) + want = [t if not bare else t[2:-2] for _, t, bare in samples] # 「(금…원)」 → 금·원 걷은 본문 + assert len(samples) == 11 and got == want + + +def test_print_options_change_generated_report(): + """인쇄 선택(F1-51~53) — 코드필드 · 비고 · 번호 자리 · 집계수준 · 순서 · 글꼴 · 방향이 묶음·엑셀을 실제로 바꿈.""" + from io import BytesIO + + from fastapi.testclient import TestClient + + base = build("영월2024", {}, "ui") + alt = build( + "영월2024", + {"codeField": "1", "remark": "1", "padNo": "1", "level": 3, "font": "돋움체", "size": 9} + | {"orient": "portrait", "formulas": True}, + "ui", + ) + two = build("영월2024", {"order": ["설계내역서", "총괄내역서"]}, "ui") + by = lambda b: {s["id"]: s for s in b["sheets"]} # noqa: E731 + b0, b1 = by(base), by(alt) + # 코드필드 — 영월 기본(SH_TYP1=0) 코드 · 호표면 번호(자리 맞춤 4칸) + assert b0["재료비목록표"]["rows"][3]["A"].startswith("M") + assert b1["재료비목록표"]["rows"][3]["A"] == " 1" + # 비고 — 기본(SH_TYP3=2) 가격지 위치 · 호표(1) 면 호표 번호 + assert b0["노무비목록표"]["rows"][3]["F"] != b1["노무비목록표"]["rows"][3]["F"] == " 1" + # 집계수준 — 기본 FLT_JAB=1 → 계(+==) 이상 · 3 이면 T2 줄 빠짐 + k0 = [r["_k"] for r in b0["총괄내역서"]["rows"] if r.get("_k", "").startswith("T")] + k1 = [r["_k"] for r in b1["총괄내역서"]["rows"] if r.get("_k", "").startswith("T")] + assert "T2" in k0 and "T2" not in k1 and len(k1) < len(k0) + # 순서 — 고른 두 시트만 그 차례로(목차·안내·INITIAL 은 앞뒤 고정) + assert [s["id"] for s in two["sheets"]][2:] == ["설계내역서", "총괄내역서", "〓 INITIAL 〓"] + assert two["sheets"][0]["rows"][4]["B"] == "2. 설계내역서" + # 엑셀 — 글꼴·크기·방향·수식 + client = TestClient(server.app) + body = {"sheets": alt["sheets"], "filename": "o.xlsx", "formulas": alt["formulas"]} + res = client.post("/api/report/xlsx", json=body | {"print": alt["print"]}) + wb = openpyxl.load_workbook(BytesIO(res.content)) + ws = wb[b1["설계내역서"]["name"]] + assert ws["B5"].font.name == "돋움체" and ws["B5"].font.sz == 9 + assert ws.page_setup.orientation == "portrait" + assert ws["G5"].value.startswith("=SUMIF(") + assert ws.column_dimensions[tag_col(b1["설계내역서"])].hidden + toc = wb["〓 목 차 〓"] + assert toc["D5"].value.startswith('=HYPERLINK("#"&공사원가계산서!G2') + res0 = client.post("/api/report/xlsx", json={"sheets": base["sheets"], "filename": "v.xlsx"}) + ws0 = openpyxl.load_workbook(BytesIO(res0.content))[b0["설계내역서"]["name"]] + assert isinstance(ws0["G5"].value, (int, float)) and ws0["AC4"].hyperlink is not None + + +def test_stage_selection_switches_change_forms(): + """인쇄영역 차수(F1-53) — 영덕 1차수 = 단일 원가계산서 · 2차수 = 당초·변경·증감 양식(원본 엑셀 대조는 report_sheets).""" + one = {s["id"]: s for s in build("영덕변경", {}, "ui")["sheets"]} + stc, _, _ = PAIRS["영덕변경"] + load = server.api_load(server.LoadReq(source_path=str(DOCS / stc))) + body = {"load": load, "stage": 1, "opts": {}, "mode": "ui"} + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps(body, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + first = {s["id"]: s for s in json.loads(out)["sheets"]} + assert one["공사원가계산서"]["rows"][3].get("D") == "당 초" + assert first["공사원가계산서"]["rows"][3].get("D") is None + + +def test_change_summary_two_line_form_matches_original(): + """총괄내역서 변경 양식(영덕 2차수) — 줄마다 당초(이름 칸) · 변경 두 줄 · 원가 줄 설명 · 마. 도급공사비로 끝(관급 없음).""" + mine = book("영덕변경")["총괄내역서"] + orig = original("영덕변경", mine["name"]) + clean = lambda v: v.strip() if isinstance(v, str) else v # noqa: E731 + bad = [] + for i in range(2, 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 j, c in enumerate("ABCDEFG"): + ov = clean(norm(o[j])) if j < len(o) else None + if ov != clean(norm(m.get(c))): + bad.append((f"{c}{i + 1}", ov, m.get(c))) + assert len(orig) > 70 and not bad, f"영덕 변경 총괄 {bad[:3]}" + + +def test_gov_sheet_lists_excluded_zone_and_totals(): + """사·관급내역서(0607 · 견본 없음 부분) — 제외금액 구역 줄만 · 끝 계 = 설계내역서 제외 묶음 금액 합.""" + from test_stmate_report_coll import _forms + + f = _forms("영월2024", {}) + g = f["관급내역서"]["rows"] + ds = book("영월2024")["설계내역서"]["rows"] + top = [r for r in ds if r.get("N") == "(제외금액)"] + assert top and g[-1]["_k"] == "final" + assert g[-1]["G"] == sum(r["G"] for r in top) + items = [r for r in g if r.get("_k") == "item"] + assert items and all(r.get("_code") for r in items) diff --git a/resources/tester/test_stmate_report_sheets.py b/resources/tester/test_stmate_report_sheets.py index 6c6dd2c48..7e678e011 100644 --- a/resources/tester/test_stmate_report_sheets.py +++ b/resources/tester/test_stmate_report_sheets.py @@ -160,7 +160,7 @@ def test_design_boq_rows_match_original(name): @pytest.mark.parametrize("name", FULL + ("울진착공",)) def test_summary_cost_lines_and_groups_match_original(name): - """총괄내역서 — 원가 줄(가.~바.)은 그대로 · 공종 묶음 줄은 원본에 나온 단계만 차례대로(출력 단계 선택).""" + """총괄내역서 — 원가 줄(가.~바.) · 공종 묶음 줄 전부 그대로(단계 = Start8x.Ini FLT_JAB + 1 이상).""" mine = book(name)["총괄내역서"] cols = "ABCDEFG" orig = content_rows(original(name, mine["name"])[3:], cols, cell) @@ -168,9 +168,8 @@ def test_summary_cost_lines_and_groups_match_original(name): 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]}" + bad = [(o, g) for o, g in zip(orig[:cut_o], got[:cut_g]) if o != g] + assert cut_o == cut_g > 2 and not bad, f"{name} 공종 줄 {cut_o}/{cut_g} 불일치 {bad[:3]}" def test_xlsx_export_writes_every_sheet_with_model_values(): diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md index d1dc0d65b..976f4fab6 100644 --- a/tools/stmate-test/PLAN.md +++ b/tools/stmate-test/PLAN.md @@ -28,9 +28,9 @@ ## 진행률 -- 합계: 전체 246 · 구현 167 · 부분 74 · 없음 5 → 진행률 82.9%((167 + 74×0.5) / 246) · 구현만 세면 67.9% +- 합계: 전체 246 · 구현 173 · 부분 68 · 없음 5 → 진행률 84.1%((173 + 68×0.5) / 246) · 구현만 세면 70.3% -### G1 화면·메뉴 (구현 34 · 부분 19 · 없음 1) — 80.6% +### G1 화면·메뉴 (구현 37 · 부분 16 · 없음 1) — 83.3% - [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui)) - [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치)) @@ -82,9 +82,9 @@ - [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152) - [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510) - [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸)) -- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 부분(scr_report_opts 인쇄 메뉴 옵션 화면) -- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 부분(인쇄순서·집계수준 화면) -- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 부분(인쇄영역(당초/변경/분할·레벨·차수) 화면) +- [x] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 구현(인쇄 옵션 바꾸면 결과물 바뀜(test_stmate_report_link)) +- [x] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 구현(인쇄순서로 원본 3건 시트 차례 재현) +- [x] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 구현(인쇄영역 당초/변경/분할·레벨·차수 반영) - [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음) ### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0% @@ -216,7 +216,7 @@ - [ ] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 없음(구버전 변환 근거 없음) - [x] F4-29 자동백업·자료 복원 — wM_D_Restore · wM_ST_Restore · 현재: 구현(api_file 자동 백업·복원) -### G5 보고서·내보내기 (구현 19 · 부분 17 · 없음 0) — 76.4% +### G5 보고서·내보내기 (구현 22 · 부분 14 · 없음 0) — 80.6% - [x] F5-01 재료비·노무비·경비 목록표(0101~0103) — Prn_SanMok · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) - [x] F5-02 일식·견적(W코드) 목록표(0104) — Prn_SanMok · 현재: 구현(일식견적 목록 0104 · 울진 변경1차 당초 차수 셀 일치) @@ -234,25 +234,25 @@ - [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-17 총괄내역서(0601·종양식 0602) — Prn_Nae · 현재: 구현(공종 줄 단계 = Start8x.Ini FLT_JAB+1 이상 · 원본 5건 · 영덕 변경 양식 셀 전부 일치(S_JDA 해석 추정)) - [x] F5-18 설계내역서(0603·종양식 0604·0693·0694) — Prn_Nae · 현재: 구현(report_* · 원본 STMATE 엑셀 셀 대조 3건(test_stmate_report_sheets)) - [ ] F5-19 분할내역서(0605) — Prn_Nae · 현재: 부분(분할내역서 · 엔진 합계 대조만) -- [ ] F5-20 사·관급내역서(0607) — 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-27 목차 시트(〓 목 차 〓) — 19번 §3 · 현재: 부분(목차·안내 시트와 바로가기 · 원본 양식 근사) -- [ ] F5-28 안내 시트(※※안내※※) — 19번 §3 · 현재: 부분(목차·안내 시트와 바로가기 · 원본 양식 근사) +- [x] F5-27 목차 시트(〓 목 차 〓) — 19번 §3 · 현재: 구현(report_link 목차 3건 셀·바로가기 수식 일치(울진 판 표기만 다름)) +- [ ] F5-28 안내 시트(※※안내※※) — 19번 §3 · 현재: 부분(안내 3건 일치 · 설계내역서 줄 번호만 다름 · 알림 조건 추정) - [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-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-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 구현(금액 한글 표기 · 원본 한글 금액 11건 전부 일치) - [x] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 구현(SET_REP 서식 제목·시트 이름 적용 · 보고서 시험 포함) ### G6 설계변경·계약·실행·기성 (구현 14 · 부분 22 · 없음 0) — 69.4% diff --git a/tools/stmate-test/report_book.js b/tools/stmate-test/report_book.js index f06fb2bc8..5860620d4 100644 --- a/tools/stmate-test/report_book.js +++ b/tools/stmate-test/report_book.js @@ -13,6 +13,7 @@ const StmateReportBook = (() => { 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 Lk = g.STMATE_REPORT_LINK || require("./report_link.js"); const { num, str, code } = C; /** @@ -89,41 +90,198 @@ const StmateReportBook = (() => { "중기시간금액집계표", ]; - // 목차 시트(〓 목 차 〓) — 시트 이름과 바로가기(엑셀에서 하이퍼링크) - function tocSheet(names, pname) { - const sh = { name: "〓 목 차 〓", title: "목 차", rows: [], cols: {} }; + const TOC = "〓 목 차 〓"; + const GUIDE = "※※안내※※"; + // 재등록 최소 판 — 24 판 출력은 「w24.02」 · 25 판부터 프로그램 판(원본 목차 D2 실측 · 울진 25.07 은 출력 프로그램 판) + function regVersion(ini) { + const v = str(ini.PRG_V || "").trim(); + return num(v) >= 25 ? `w${v}` : "w24.02"; + } + + /** + * 목차 시트(〓 목 차 〓) — 원본 출력 그대로: D1 INITIAL 지움 검사 수식 · B2 제목 · D2~D3 재등록 안내 · + * 4행부터 「n. 시트이름」 + 바로가기(→) · 안내 시트가 1번 · INITIAL 은 목록 밖 + */ + function tocSheet(sheets, dbf) { + const ini = (dbf && dbf.start_ini) || {}; + const sh = { + name: TOC, + title: "목 차", + rows: [], + cols: { B: 25.625, D: 23.625 }, + tab: "0000FF", + }; + const chk = `=IF('${Lk.INIT}'!C4,"※※ 기본정보의 일부가 삭제되어 Stmate로 변환할 수 없습니다 ※※","")`; + C.put(sh, 1, { _f: { D: chk }, _sz: { D: 12 }, _bold: { D: true } }); C.put(sh, 2, { B: "목 차", - D: "※ 본 엑셀자료는 STMATE 재현 앱에서 만든 출력본입니다.", - _k: "title", + D: `※ 본 엑셀자료는 【STmate ${regVersion(ini)}】버전 이상에서 STmate 자료로 다시 등록할 수 있습니다.`, + _sz: { B: 20, D: 9 }, + }); + C.put(sh, 3, { + D: " 단, 임의로 수정한 내용(Sheet삭제, 산근내용변경, 기타계산식변경..)은 반영되지 않을 수 있습니다.", + _sz: { D: 9 }, + }); + sheets.forEach((s, i) => { + const cells = { B: `${i + 1}. ${s.name}`, D: `${s.name} →`, _sz: { B: 11, D: 9 } }; + cells._links = { D: `${Lk.q(s.name)}!A1` }; + const tag = Lk.tagOf(s); + cells._f = { + D: tag + ? `=HYPERLINK("#"&${Lk.q(s.name)}!${tag}2&"!A1","${s.name} →")` + : `=HYPERLINK("#${Lk.q(s.name)}!A1","${s.name} →")`, + }; + C.put(sh, 4 + i, cells); }); - C.put(sh, 3, { D: ` 공사명 : ${pname}` }); - ["※※안내※※"] - .concat(names) - .forEach((n, i) => C.put(sh, 4 + i, { B: `${i + 1}. ${n}`, D: `${n} →`, _link: n })); return sh; } - // 안내 시트(※※안내※※) — 원본 안내문 요지 + 금액 확인 줄 - function guideSheet(k) { - const sh = { name: "※※안내※※", title: "안내", rows: [], cols: {} }; + /** + * 안내 시트(※※안내※※) — 원본 출력 글 · 판·산근 수식 갈래 · 아래 알림(안내 및 오류) 줄 + * 알림 차례 = 설계내역서 % 단위 → 총괄 이윤보정액(I) · 총공사비(D) → 일위대가표 → 단가산출근거 → 중기사용료 + */ + function guideSheet(sheets, dbf, k) { + const ini = (dbf && dbf.start_ini) || {}; + const sh = { name: GUIDE, title: "안내", rows: [], cols: { A: 9.125, B: 4.75 }, tab: "FF0000" }; + const by = {}; + for (const s of sheets) by[s.id || s.name] = s; C.put(sh, 2, { A: "목차 →", B: "=== 엑셀파일에 작성에 대한 안내문(반드시 읽어보세요) ===", - _link: "〓 목 차 〓", + _links: { A: `${Lk.q(TOC)}!B2` }, + _f: { A: `=HYPERLINK("#${Lk.q(TOC)}!B2","목차 →")` }, + _sz: { A: 9, B: 12 }, + _bold: { B: true }, }); - C.put(sh, 4, { B: "◈ 금액은 STMATE 재현 앱 계산 엔진(calc.js) 결과를 값으로 적었습니다." }); - C.put(sh, 5, { B: "◈ 각 시트의 비목 열 순서는 설정(BMKNM)을 따릅니다." }); - C.put(sh, 6, { B: "◈ [목차] 및 [안내] 시트의 이름을 바꾸지 마십시요." }); - if (k) { - C.put(sh, 8, { B: "※※※ 아래의 내용(안내 및 오류)을 확인/검토 하세요 ※※※" }); - C.put(sh, 9, { - C: `【총괄내역서】 총공사비 = ${C.commas(k.grandTotal)}원 (이윤 보정액 ${C.commas(k.pretaxProfitAdj || 0)}원)`, + const line = (r, t, extra) => + C.put(sh, r, Object.assign({ B: t, _sz: { B: 10 } }, extra || {})); + line(4, "엑셀파일 수정후 계산식 반영이 안될경우 :【 Ctrl+Alt(+Shift)+F9 】", { + _sz: { B: 14 }, + _bold: { B: true }, + }); + [ + "◈ 엑셀파일내의 산식은 기초단가, 복합단가, 내역서 및 총괄내역서까지 연결됩니다.", + " (모든산식은 단가만을 불러오며, 명칭, 규격, 단위, 비고.. 등은 TEXT로 입력됩니다)", + "◈ 각 Sheet우측에는 해당 항목을 찾아가는 위치이동(→) 링크가 있으며, 클릭하면 이동됩니다.", + " (Alt + ← 키를 이용하여 찾아갔던 항목을 되돌아 올 수 있습니다.)", + "◈ 자재단가 대비표의 적용단가는 수식이 적용되지 않았으나, 우측에 최소단가 수식을 입력하였습니다.", + "◈ 일위대가표 및 중기사용료 및 총괄내역서의 양식은 일반양식을 적용합니다.", + ].forEach((t, i) => line(6 + i, t)); + // 산근 수량 산식 갈래 — report_san 수치 글 다듬기와 같은 판단(FLT_JAB=1 · 원본 5건 실측 · 추정) + const sanFml = String(ini.FLT_JAB ?? "2").trim() === "1"; + const san = by.단가산출근거; + const t12 = "◈ 단가산출근거의 수량부분은 산출근거 우측 연두색 부분에서 수정합니다. →"; + if (sanFml && san && Lk.tagOf(san)) + line(12, t12, { + _links: { B: `${Lk.q(san.name)}!Y3` }, + _f: { B: `=HYPERLINK("#"&${Lk.q(san.name)}!${Lk.tagOf(san)}2&"!Y3","${t12}")` }, + }); + else line(12, "◈ 단가산출근거의 수량부분은 계산식이 입력되지 않았습니다."); + let r = 13; + const v25 = num(ini.PRG_V) >= 25; + if (v25 || sanFml) + line( + r++, + `◈ [목차] 및 [안내]${v25 ? " " : ""}Sheet의 이름을 변경하지 마시고, Sheet이름을 변경할때 공백문자 및 특수문자를 사용하지 마십시요.`, + ); + r += 1; + line(r++, "※※※ 아래의 내용(안내 및 오류)을 확인/검토 하세요 ※※※", { + _bold: { B: true }, + }); + for (const w of guideNotes(by, dbf, k)) { + const ref = `${Lk.q(w.sh.name)}!${w.col}${w.row}`; + C.put(sh, r++, { + B: " → ", + C: w.text, + _links: { B: ref }, + _f: { B: Lk.jump(w.sh, w.col, w.row, " → ") }, + _sz: { B: 9, C: 9 }, + _bold: w.bold ? { C: true } : undefined, }); } return sh; } + /** + * 안내 알림 줄 — % 단위 코드 줄(산식 못 넣음 · 손료계산 형식 권고) · 총괄 이윤보정액 · 총공사비 확인 + * 총공사비 확인 줄 = 이윤보정액이 천 원 끝전 맞춤 범위(1,000 미만)일 때(원본 5건 실측 · 대흥 905,980 = 없음 · 추정) + * 중기 % 줄 = 규격 「주연료의 %」(잡품·잡유)는 연료 줄 밑수 산식이 있어 알림 없음(대흥 실측) + */ + function guideNotes(by, dbf, k) { + const unit = {}; + for (const r of (dbf && dbf.costn_rows) || []) unit[code(r)] = str(r.UNIT).trim(); + const isP = (c) => /%$/.test(unit[c] || ""); + const out = []; + const pctMsg = (s, row, c) => + `【${s.name}】${row}번째줄 에서 사용된【${c}】코드의 %단위(${unit[c]}) 는 산식을 입력할 수 없으니 손료계산 형식으로 변경하십시요.`; + const scan = (s, kind, get, col) => + s && + s.rows.forEach((r, i) => { + const c = r._k === kind && get(r); + if (c && isP(c)) out.push({ sh: s, col: col(c), row: i + 1, text: pctMsg(s, i + 1, c) }); + }); + scan( + by.설계내역서, + "item", + (r) => r._code, + () => "A", + ); + const sm = by.총괄내역서; + if (sm) { + // 변경 양식(당초·변경 두 줄)이면 둘 다 — 변경 줄 = 이름 칸 빈 다음 줄(영덕 실측) + const pair = (rn) => { + const nx = sm.rows[rn]; + return nx && !nx.A && !nx.B && nx.D != null ? [rn, rn + 1] : [rn]; + }; + const iy = sm.rows.findIndex((r) => r._k === "cost" && /이\s*윤/.test(String(r.B || ""))) + 1; + const n = sm.rows.length; + const last = n > 1 && !sm.rows[n - 1].A && !sm.rows[n - 1].B ? [n - 1, n] : [n]; + if (iy > 0) + for (const r of pair(iy)) + out.push({ + sh: sm, + col: "I", + row: r, + bold: true, + text: `【${sm.name}】${r}번째줄의 I열 이윤보정액은 총공사비 금액이 다를경우 임의조정(±α) 하십시요.`, + }); + if (k && Math.abs(num(k.pretaxProfitAdj)) < 1000) + for (const r of last) + out.push({ + sh: sm, + col: "D", + row: r, + text: `【${sm.name}】${r}번째줄의 D열에 계산된 총공사비 금액을 확인하십시요.`, + }); + } + const own = { M: "I", L: "G", S: "K" }; + scan( + by.일위대가표, + "line", + (r) => r._code, + (c) => own[c[0]] || "C", + ); + scan( + by.단가산출근거, + "line", + (r) => r._ref, + () => "B", + ); + const spec = Lk.specOf({ _dbf: dbf }); + const jm = by.중기사용료; + if (jm) + jm.rows.forEach((r, i) => { + if (r._k === "line" && isP(r._code) && !/주연료/.test(spec[r._code] || "")) + out.push({ + sh: jm, + col: own[r._code[0]] || "C", + row: i + 1, + text: pctMsg(jm, i + 1, r._code), + }); + }); + return out; + } + /** * 전체 묶음 — dbf(불러온 자료) · state(sim_state) · computed(calculateAll) · n 차수 * 돌려줌: { sheets:[시트 모형…], byName } @@ -143,11 +301,10 @@ const StmateReportBook = (() => { 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 init = In.initialSheet(dbf, state, sheets, n, opts); - names.push(init.name); - const book = [tocSheet(names, pname), guideSheet(boq.stage.kanjub)].concat(sheets, [init]); + Lk.linkBook(sheets, dbf); // 시트 간 참조 수식 · 바로가기(→) · 이름표 수식 + const guide = guideSheet(sheets, dbf, boq.stage.kanjub); + const book = [tocSheet([guide].concat(sheets), dbf), guide].concat(sheets, [init]); for (const s of book) delete s._tree; return { sheets: book, byName: Object.fromEntries(book.map((s) => [s.name, s])) }; } diff --git a/tools/stmate-test/report_boq.js b/tools/stmate-test/report_boq.js index 71e182d96..93e23b45d 100644 --- a/tools/stmate-test/report_boq.js +++ b/tools/stmate-test/report_boq.js @@ -58,13 +58,14 @@ const StmateReportBoq = (() => { continue; } if (!b || b === ":") { - out.push({ kind: b === ":" ? "page" : "blank", row: r }); + out.push({ kind: b === ":" ? "page" : "blank", row: r, parent: top }); continue; } const item = { kind: "item", row: r, parent: top, code: b }; const unit = str(r.UNIT1).trim(); if (b === "PR" || /%$/.test(unit)) { item.pct = true; + if (b === "PR") item.pr = { in: COMP[num(r.PRIN)] || "M", out: COMP[num(r.PROUT)] || "M" }; const inK = COMP[num(r.PRIN)] || "M", outK = COMP[num(r.PROUT)] || (b[0] === "L" ? "L" : b[0] === "S" ? "S" : "M"); const sibs = top.children.filter((c) => c.kind === "item" && !c.pct); @@ -147,6 +148,11 @@ const StmateReportBoq = (() => { cells[PAIR[i][1]] = fix(a[k]); }); }; + // 엑셀 수식 열쇠(report_link) — 묶음 단(_lv) · 윗 묶음 열쇠(_pk = E단 · 제외 구역 C단 · 맨 위 E10) · 묶음 끝 행(_end) + const pkey = (p) => (p && p.level ? `${p.excluded ? "C" : "E"}${p.level}_1` : "E10_1"); + const seen = (node, rn) => { + for (let p = node; p; p = p.parent) p._last = rn; + }; // 쪽 넘김(:) — 빈 줄 하나에 쪽 나눔 표시(인쇄 미리보기에서 다음 쪽으로) for (const n of tree.out) { const r = n.row; @@ -155,23 +161,32 @@ const StmateReportBoq = (() => { continue; } if (n.kind === "blank") { - C.push(sh, { F: 0, _k: "blank" }); + seen(n.parent, C.push(sh, { F: 0, _k: "blank", _pk: pkey(n.parent) })); continue; } const cells = { A: str(r.ITNUM).trim(), B: str(r.NAME1), C: str(r.SIZE1) }; if (n.kind === "T") { - Object.assign(cells, { _k: `T${n.level}` }); + Object.assign(cells, { _k: `T${n.level}`, _lv: n.level, _ex: !!n.excluded }); + if (n.parent && n.parent.level) cells._pk = pkey(n.parent); + else if (n.parent) cells._pk = "E10_1"; fill(cells, { M: 0, L: 0, S: 0 }, n.sum, true); if (n.excluded) cells.N = dispEx; + n._cells = cells; } 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; + cells._pk = pkey(n.parent); + if (n.pr) cells._pr = n.pr; + if (str(r.PRPT).trim() === "1") cells._mark = true; } - C.push(sh, cells); + const rn = C.push(sh, cells); + seen(n.kind === "T" ? n : n.parent, rn); + if (n.kind === "T") n._row = rn; } + for (const n of tree.out) if (n.kind === "T" && n._cells) n._cells._end = n._last; if (tree.finalRow) { const cells = { _k: "final" }; const s = tree.finalRow.node.sum; @@ -239,8 +254,22 @@ const StmateReportBoq = (() => { return { exp, adds, govMat, fixedKeys }; } - // 총괄내역서(0601) — 공종 묶음 줄 + 원가 계산 줄 - function summarySheet(dbf, stage, design, opts = {}) { + /** + * 총괄 공종 줄 단계 — Start8x.Ini FLT_JAB = PM_Sel_St rd_Filter 고른 자리(0 소계(+=) 이상 … 4 총합계) + * 출력 = T 단계 ≥ FLT_JAB + 1 · 근거: ST_Prt.CPM 인쇄 키 표(SH_TYP0~3 · SAN_TYP · KAN_TYP · FLT_JAB) · + * 원본 엑셀 5건(봉화·울진 FLT_JAB=2 → 합계 이상 · 영월·대흥·영덕 1 → 계 이상) + */ + function summaryLevel(ini) { + const f = String((ini && ini.FLT_JAB) ?? "").trim(); + return /^\d$/.test(f) ? Number(f) + 1 : 2; + } + + /** + * 총괄내역서(0601) — 공종 묶음 줄 + 원가 계산 줄 + * base(당초 차수 { stage, tree })를 주면 변경 양식 — 줄마다 당초(이름 칸 있음) · 변경(이름 칸 빔) 두 줄(영덕 원본 실측) + * 바. 총공사비 줄 = 관급 · 4단계 추가금액이 있을 때만(영덕 = 마. 도급공사비로 끝) + */ + function summarySheet(dbf, stage, design, opts = {}, base = null) { const ini = (dbf && dbf.start_ini) || {}; const order = C.bimokOrder(ini); const cols = ["D", "E", "F", "G"]; @@ -249,48 +278,72 @@ const StmateReportBoq = (() => { const h3 = { A: "공 종", B: "명 칭", C: "규 격", _k: "head" }; order.forEach((k, i) => (h3[cols[i]] = C.bimokLabel(ini, k))); C.push(sh, h3); - const minLevel = opts.minLevel ?? 2; + const now = summaryRows(dbf, stage, design._tree, opts); + const old = base ? summaryRows(dbf, base.stage, base.tree, opts) : null; + if (!old || old.length !== now.length) { + for (const r of now) C.push(sh, r); + return sh; + } + now.forEach((r, i) => { + C.push(sh, old[i]); + const second = Object.assign({}, r); + for (const c of ["A", "B", "C"]) delete second[c]; + C.push(sh, second); + }); + return sh; + } + + // 총괄 줄 — 차수 하나(stage · 공종 나무)의 셀 묶음 차례 + function summaryRows(dbf, stage, tree, opts) { + const ini = (dbf && dbf.start_ini) || {}; + const order = C.bimokOrder(ini); + const cols = ["D", "E", "F", "G"]; + const out = []; + const minLevel = opts.minLevel || summaryLevel(ini); const put4 = (cells, s) => { const v = { M: s.M, L: s.L, S: s.S, T: s.M + s.L + s.S }; order.forEach((k, i) => (cells[cols[i]] = v[k])); }; - for (const n of design._tree.out) + for (const n of tree.out) if (n.kind === "T" && n.level >= minLevel) { const cells = { A: str(n.row.ITNUM).trim(), B: str(n.row.NAME1), C: str(n.row.SIZE1) }; put4(Object.assign(cells, { _k: `T${n.level}` }), n.sum); - C.push(sh, cells); + out.push(cells); } const k = stage.kanjub; const L = costLines(k, stage.cfg); - const direct = design._tree.finalRow - ? design._tree.finalRow.node.sum - : { M: k.dM, L: k.dL, S: k.dS }; + const direct = tree.finalRow ? tree.finalRow.node.sum : { M: k.dM, L: k.dL, S: k.dS }; const ga = { A: " 가.", B: "순공사비계", _k: "sub" }; put4(ga, direct); - C.push(sh, ga); + out.push(ga); let no = 0; const line = (label, amount, note, extra) => { no += 1; const cells = { B: `${String(no).padStart(2)}. ${label}`, D: amount, _k: "cost" }; if (note) cells.E = note; - return C.push(sh, Object.assign(cells, extra || {})); + out.push(Object.assign(cells, extra || {})); }; const sub = (a, label, amount) => - C.push(sh, { A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" }); + out.push({ A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" }); line( "간접노무비", k.kanno, L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`, ); - for (const e of L.exp.filter((x) => x.code !== "KANNO")) + for (const e of L.exp.filter((x) => x.code !== "KANNO")) { + const note = + e.code === "ANJUN" && stage.cfg.anjunAuto === 2 && stage.slot > 1 + ? "당초금액 + 안전관리비 증감액" + : e.note; line( e.short, e.amount, - e.note, + note, e.code === "ANJUN" && L.govMat > 0 && !L.fixedKeys.has("ANJUN") ? { C: "A)관급재/1.1포함 적용" } : null, ); + } for (const a of L.adds(1)) line(a.name, num(a.amount)); const na = k.pureConstructionCost; sub("나.", "소 계", na); @@ -315,8 +368,8 @@ const StmateReportBoq = (() => { `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`, ); for (const a of L.adds(4)) line(a.name, num(a.amount)); - sub("바.", "총공사비", k.grandTotal); - return sh; + if (k.govFinal || L.adds(4).length) sub("바.", "총공사비", k.grandTotal); + return out; } // 부가세 설명 — I_BUGA 갈래(공급가액 · 산림조합 · 면세품 · 재료비) @@ -448,8 +501,9 @@ const StmateReportBoq = (() => { function buildBoqSheets(dbf, state, computed, n = 1, opts = {}) { const stage = stageOf(state, computed, n); const design = C.applyForm(designSheet(dbf, stage, opts), dbf, "0603"); - const summary = C.applyForm(summarySheet(dbf, stage, design, opts), dbf, "0601"); const base = n > 1 && opts.changeForm !== false ? stageOf(state, computed, 1) : null; + const sBase = base ? { stage: base, tree: boqTree(dbf, base.boq, 1) } : null; + const summary = C.applyForm(summarySheet(dbf, stage, design, opts, sBase), dbf, "0601"); const won = C.applyForm(wonSheet(dbf, stage, base), dbf, "0608"); return { stage, 공사원가계산서: won, 총괄내역서: summary, 설계내역서: design }; } diff --git a/tools/stmate-test/report_core.js b/tools/stmate-test/report_core.js index 9c2c4e77f..52d981ae1 100644 --- a/tools/stmate-test/report_core.js +++ b/tools/stmate-test/report_core.js @@ -157,7 +157,19 @@ const StmateReportCore = (() => { u[ok] = fix(bv); a[ok] = q ? trunc((bv * q) / 100, 1) : 0; sum[ok] += a[ok]; - lines.push({ code: "PR", pct: true, qty: q, unit: "%", u, a, marked: false }); + const prpt = !["", "0"].includes(String(row.PRPT ?? "").trim()); + lines.push({ + code: "PR", + pct: true, + qty: q, + unit: "%", + u, + a, + marked: false, + prpt, + inK: bk, + outK: ok, + }); continue; } if (/^C\d$/.test(c)) closed = true; @@ -246,7 +258,7 @@ const StmateReportCore = (() => { /** * 엑셀 수식(ST_P_XLS Prn_FMLA_INFO) — 비목 쌍(단가·금액) 줄 수식 · {r} = 행 번호(report_xlsx 가 채움) - * 금액 = ROUNDDOWN(단가 × 수량, 자리) · 합계 쌍 = 세 비목 합(원본 STMATE 출력 수식 꼴) + * 금액 = ROUNDDOWN(단가 × 수량, 자리) · 합계 쌍 = 재료 + 노무 + 경비(원본 STMATE 출력 수식 꼴) * zeroGuard = 수량 0 이면 0(일위대가표 「IF(C=0,0,…)」) */ function pairFormulas(order, pairs, qtyCol, digits, zeroGuard) { @@ -255,7 +267,7 @@ const StmateReportCore = (() => { for (const k of order) { const [u, a] = at(k); if (k === "T") { - const parts = order.filter((x) => x !== "T").map((x) => at(x)); + const parts = ["M", "L", "S"].map((x) => at(x)); // 원본 합 차례 = 재료 + 노무 + 경비 f[u] = "=" + parts.map(([pu]) => pu + "{r}").join("+"); f[a] = "=" + parts.map(([, pa]) => pa + "{r}").join("+"); } else { diff --git a/tools/stmate-test/report_forms.js b/tools/stmate-test/report_forms.js index 78a2e5e60..158e9a30d 100644 --- a/tools/stmate-test/report_forms.js +++ b/tools/stmate-test/report_forms.js @@ -354,6 +354,34 @@ const StmateReportForms = (() => { return form(sh, dbf, "0695"); } + /** + * 0607 사·관급내역서 — 설계내역서 중 제외금액 구역(C 로 닫힌 묶음 · 관급) 줄만 · 끝 줄 = 관급 계 + * SET_REP 0607 칸 이름(사급분 · 계) · 원본 견본 없음(부분) — 설계내역서 행과 공종 나무 차례 1:1 + */ + function govSheet(design, tree, dbf) { + const sh = C.newSheet("관급내역서", "관 급 내 역 서", C.projectName(dbf), "O"); + for (const r of design.rows.slice(2, 4)) C.push(sh, Object.assign({}, r)); + const sum = { M: 0, L: 0, S: 0 }; + tree.out.forEach((n, i) => { + const inGov = (n.kind === "T" && n.excluded) || excluded(n); + if (!inGov) return; + const r = design.rows[4 + i]; + if (r) C.push(sh, Object.assign({}, r, { _f: undefined })); + if (n.kind === "T" && n.excluded && !excluded(n)) for (const k of "MLS") sum[k] += n.sum[k]; + }); + const f = fields(dbf, "0607"); + const last = { A: f[1] || "계", _k: "final" }; + const head = design.rows[2] || {}; + const amt = { 합: T4(sum), 노: sum.L, 재: sum.M, 경: sum.S }; + for (const [c, lbl] of Object.entries(head)) + if (/^[FHJL]$/.test(c)) { + const key = String(lbl).replace(/\s/g, "")[0]; + last[String.fromCharCode(c.charCodeAt(0) + 1)] = amt[key] ?? 0; + } + C.push(sh, last); + return form(sh, dbf, "0607"); + } + // 제목만 바뀌는 변형 — 같은 시트 모형을 복사해 SET_REP 제목·이름(0606 도공 · 0654 굴착 · 0656 시도 · 0613 견적 · 0652·0653·0672 총괄) function retitle(sh, dbf, indx, id) { const copy = { @@ -403,6 +431,7 @@ const StmateReportForms = (() => { 자재사용계획: groupCollSheet(u, tree, "0678", "M", opts), 인력투입계획: groupCollSheet(u, tree, "0679", "L", opts), 간이명세서: simpleSheet(dbf, stage), + 관급내역서: govSheet(design, tree, dbf), 제잡비요율표: In.rateSheet(dbf, opts), }; for (const [id, sh] of Object.entries(out)) Object.assign(sh, { id }); @@ -433,6 +462,7 @@ const StmateReportForms = (() => { "0678": "자재사용계획", "0679": "인력투입계획", "0695": "간이명세서", + "0607": "관급내역서", }; return { buildFormSheets, FORM_SHEET, fields, splitSheet, totalSheet, groupCollSheet }; diff --git a/tools/stmate-test/report_link.js b/tools/stmate-test/report_link.js new file mode 100644 index 000000000..7e934f0a5 --- /dev/null +++ b/tools/stmate-test/report_link.js @@ -0,0 +1,525 @@ +/* -*- coding: utf-8 -*- */ +/** + * 보고서 시트 잇기 (report_link.js) — 원본 STMATE 엑셀의 시트 간 참조 수식 · 바로가기(→) · 시트 이름표 수식 + * 근거: 지식DB STmate/old/20_분석 24 §3(ST_P_XLS Prn_FMLA_INFO) · 19 §3(목차·안내 시트) · + * 원본 출력 엑셀 5건 수식 실측(봉화·영월·대흥·울진·영덕 — openpyxl 수식 읽기) + * 정답 대조: resources/tester/test_stmate_report_link.py(원본 수식 글 그대로) + * 시트 모형 행에 붙이는 것: + * _f {열: 값} — 수식 넣기 때만 쓰는 칸(수식 · 숨은 열쇠 글) · 값 칸은 계산 엔진 값 그대로 + * _links {열: '시트!칸'} — 값 모드 바로가기(하이퍼링크) + */ +const StmateReportLink = (() => { + const TOC = "〓 목 차 〓"; + const GUIDE = "※※안내※※"; + const INIT = "〓 INITIAL 〓"; + const FILE_NAME = + '=MID(CELL("filename",$A$1),FIND("]",CELL("filename",$A$1))+1,LEN(CELL("filename",$A$1)))'; + // 시트 이름 — 한글·영문·숫자만이면 따옴표 없이(원본 꼴) + const q = (n) => + /^[가-힣A-Za-z_][가-힣A-Za-z0-9_]*$/.test(n) ? n : `'${n.replace(/'/g, "''")}'`; + const colNum = (c) => c.split("").reduce((a, ch) => a * 26 + ch.charCodeAt(0) - 64, 0); + const colName = (n) => + (n > 26 ? String.fromCharCode(64 + Math.floor((n - 1) / 26)) : "") + + String.fromCharCode(65 + ((n - 1) % 26)); + const isCol = (k) => /^[A-Z]{1,2}$/.test(k); + + // 시트 이름표 칸 — 2행에서 시트 이름이 적힌 열 + const tagOf = (sh) => + sh.rows[1] ? Object.keys(sh.rows[1]).find((k) => isCol(k) && sh.rows[1][k] === sh.name) : null; + // 목차 바로가기 열 — 숨은 도움 열이 있는 시트는 그 뒤(원본 실측) · 그 밖 이름표 다음 열 + const LINK_COL = { + 총괄내역서: "Q", + 설계내역서: "AC", + 일위대가표: "Z", + 중기사용료: "Z", + 단가산출근거: "Y", + }; + const NO_LINK = new Set([TOC, GUIDE, INIT, "환율및기초자료"]); + function linkCol(sh) { + const id = sh.id || sh.name; + if (LINK_COL[id]) return LINK_COL[id]; + const t = tagOf(sh); + return t ? colName(colNum(t) + 1) : null; + } + const headRow = (sh) => { + let h = 0; + sh.rows.slice(0, 6).forEach((r, i) => r._k === "head" && (h = i + 1)); + return h || 3; + }; + + const setF = (row, col, v) => ((row._f = row._f || {}), (row._f[col] = v)); + const setLink = (row, col, target, text) => { + row[col] = text; + row._links = Object.assign(row._links || {}, { [col]: target }); + }; + + /** + * 바로가기 수식 — =HYPERLINK("#"&시트!이름표2&"!열"&ROW(시트!열행),"글") + * 이름표 칸 수식이 시트 이름을 돌려줘 시트 이름을 바꿔도 따라감(원본 꼴) + */ + function jump(target, col, row, text) { + const n = q(target.name); + return `=HYPERLINK("#"&${n}!${tagOf(target)}2&"!${col}"&ROW(${n}!${col}${row}),"${text}")`; + } + /** + * 바로가기 글 — 코드 · 호표 비고 모드(SH_TYP1=1 · SH_TYP3=1)면 종류 이름 + 목록 차례 5자리(「자재 14」 · 대흥 실측) + * 종류 이름 = Start8x.Ini COD_FMT_1~6(노무·자재·경비·대가·중기·산근) + */ + function codeLabel(by, c) { + const ini = (by._dbf && by._dbf.start_ini) || {}; + const hop = String(ini.SH_TYP1 ?? "0") !== "0" && String(ini.SH_TYP3 ?? "2") === "1"; + const at = "LMSBXD".indexOf(c[0]); + if (!hop || at < 0) return `${c} →`; + const list = by[LIST_OF[c[0]]]; + const n = list + ? list.rows.filter((r) => r._k === "item").findIndex((r) => r._code === c) + 1 + : 0; + return n > 0 ? `${String(ini[`COD_FMT_${at + 1}`] || "")}${String(n).padStart(5)} →` : `${c} →`; + } + function putJump(row, at, target, col, r, text) { + if (!at || !target || !tagOf(target)) return; + setLink(row, at, `${q(target.name)}!${col}${r}`, text); + setF(row, at, jump(target, col, r, text)); + } + + // 시트 안 코드 → 행 번호(1부터) — kind 가 같은 행만 + function rowsBy(sh, kind) { + const m = {}; + if (!sh) return m; + sh.rows.forEach((r, i) => { + if (r._code && (!kind || r._k === kind) && !(r._code in m)) m[r._code] = i + 1; + }); + return m; + } + + // 머리 행의 비목 열 — 이름 → 열(합계·노무·재료·경비) · BMKNM 차례로 쓴 머리 글에서 찾음 + function bimokCols(ini, head, cols) { + const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); + const out = {}; + for (const k of "TLMS") { + const lbl = C.bimokLabel(ini, k); + const c = cols.find((x) => head[x] === lbl); + if (c) out[k] = c; + } + return out; + } + + // 코드 → COSTN 단위(M%·L%·…) · 규격 + function costnMap(by, key, field) { + if (!by[key]) { + by[key] = {}; + for (const r of (by._dbf && by._dbf.costn_rows) || []) + by[key][String(r.CODE || "").trim()] = String(r[field] || "").trim(); + } + return by[key]; + } + const unitOf = (by) => costnMap(by, "_unit", "UNIT"); + const specOf = (by) => costnMap(by, "_spec", "SIZE"); + + // 코드 머리 → 목록 시트 id + const LIST_OF = { + M: "재료비목록표", + L: "노무비목록표", + S: "경비목록표", + B: "일위대가목록표", + D: "단가산출근거목록표", + X: "중기목록표", + W: "일식견적목록표", + }; + const DETAIL_OF = { + 일위대가목록표: ["일위대가표", "parent"], + 중기목록표: ["중기사용료", "parent"], + 단가산출근거목록표: ["단가산출근거", "hopyo"], + }; + const COLL_OF = { + 재료비수량금액집계표: "재료비목록표", + 노무비수량금액집계표: "노무비목록표", + 경비수량금액집계표: "경비목록표", + 중기시간금액집계표: "중기목록표", + 일위대가수량금액집계표: "일위대가목록표", + 단가산출근거수량금액집계표: "단가산출근거목록표", + 일식견적수량금액집계표: "일식견적목록표", + }; + + /** + * 목록 층 — 재료비목록표 단가 = 자재단가대비표 적용 단가 · 대가·중기·산근 목록 = 호표 합계 줄 · + * 수량금액집계표 단가 = 목록 단가 · 비목 집계(중기·대가·산근) = ROUND(수량 × 목록 비목 단가) + */ + function linkLists(by, ini) { + const pc = by.자재단가대비표; + const mat = by.재료비목록표; + if (pc && mat) { + const at = rowsBy(pc, "item"); + const lc = linkCol(mat); + mat.rows.forEach((r) => { + const pr = r._k === "item" && at[r._code]; + if (!pr) return; + setF(r, "E", `=${q(pc.name)}!O${pr}`); + putJump(r, lc, pc, "A", pr, codeLabel(by, r._code)); + }); + } + for (const [lid, [did, kind]] of Object.entries(DETAIL_OF)) { + const list = by[lid]; + const det = by[did]; + if (!list || !det) continue; + const tot = rowsBy(det, "total"); + const top = rowsBy(det, kind); + const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); + const dcols = + did === "단가산출근거" + ? bimokCols(ini, det.rows[2] || {}, ["C", "D", "E", "F"]) + : bimokCols(ini, det.rows[2] || {}, ["E", "G", "I", "K"]); + const lc = "K"; + list.rows.forEach((r) => { + if (r._k !== "item" || !tot[r._code]) return; + for (const k of "TLMS") { + if (!lcols[k] || !dcols[k]) continue; + const dc = did === "단가산출근거" ? dcols[k] : colName(colNum(dcols[k]) + 1); + setF(r, lcols[k], `=${q(det.name)}!${dc}${tot[r._code]}`); + } + const hr = kind === "parent" ? top[r._code] - 1 : top[r._code]; + if (hr > 0) putJump(r, lc, det, "A", hr, codeLabel(by, r._code)); + }); + } + for (const [cid, lid] of Object.entries(COLL_OF)) { + const sh = by[cid]; + const list = by[lid]; + if (!sh || !list) continue; + const at = rowsBy(list, "item"); + const lc = linkCol(sh); + const head = sh.rows.slice(0, 4).find((r) => r._k === "head") || {}; + const ccols = bimokCols(ini, head, ["F", "G", "H", "I"]); + const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); + const single = !ccols.L; // 자원 집계 — 단가 한 칸 + sh.rows.forEach((r) => { + const lr = r._k === "item" && at[r._code]; + if (!lr) return; + if (single) setF(r, "F", `=${q(list.name)}!E${lr}`); + else { + setF(r, ccols.T, `=${ccols.M}{r}+${ccols.L}{r}+${ccols.S}{r}`); + for (const k of "LMS") + setF(r, ccols[k], `=ROUND(D{r}*${q(list.name)}!${lcols[k]}${lr},0)`); + } + putJump(r, lc, list, "A", lr, codeLabel(by, r._code)); + }); + } + } + + /** + * 구성 호표(일위대가표 · 중기사용료) — 줄 단가 = 목록 단가(노임 시간 환산은 환율및기초자료) · + * 줄 금액 = IF(수량=0,0,ROUNDDOWN(단가×수량,1)) · % 단위 = ÷100 · 합계 = 같은 열쇠 줄 SUMIF 원 미만 버림 + * 총액우선(일위대가 · CHBNM=0) = 끝수 배분 도움 열 S~Y(소수 셋째 끝수 큰 비목부터 · 같으면 OPTNM 차례) + * 숨은 열쇠 — O = 코드_1(줄) · T7_1(합계) · P = T7_1(줄이 속한 합계) + */ + function linkComposition(by, sh, ini) { + if (!sh) return; + const PAIR = { T: ["E", "F"], L: ["G", "H"], M: ["I", "J"], S: ["K", "L"] }; + const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); + const order = C.bimokOrder(ini); + ["E", "G", "I", "K"].forEach((u, i) => (PAIR[order[i]] = [u, colName(colNum(u) + 1)])); + const tf = (sh.id || sh.name) === "일위대가표" && String(ini.CHBNM ?? "1") === "0"; + const opt = String(ini.OPTNM || "SML") + .toUpperCase() + .split("") + .filter((k) => "MLS".includes(k)); + const nonT = order.filter((k) => k !== "T"); // 도움 열 S·T·U 차례 = 단가 열 차례 + const help = {}; + nonT.forEach((k, i) => (help[k] = { frac: "STU"[i], adj: "WXY"[i] })); + const lab = by.환율및기초자료 ? rowsBy(by.환율및기초자료) : {}; + const rowsOf = {}; + const sum3 = (side) => `=${PAIR.M[side]}{r}+${PAIR.L[side]}{r}+${PAIR.S[side]}{r}`; + const helpers = (row, base, d, step) => { + for (const k of nonT) + setF( + row, + help[k].frac, + `=ROUND(ROUNDDOWN(${base(PAIR[k][0])},${d + 2})-ROUNDDOWN(${base(PAIR[k][0])},${d}),${d + 2})`, + ); + const parts = ["M", "L", "S"].map((k) => `ROUNDDOWN(${base(PAIR[k][0])},${d})`).join("+"); + setF( + row, + "V", + `=ROUNDDOWN(ROUND(ROUNDDOWN(${base(PAIR.T[0])},${d})-(${parts}),${d + 2}),${d})`, + ); + for (const k of nonT) { + const me = help[k].frac; + const ahead = opt + .slice(0, opt.indexOf(k)) + .map((j) => `+IF(${help[j].frac}{r}=${me}{r},1,0)`) + .join(""); + setF( + row, + help[k].adj, + `=IF(V{r}<0,-1,1)*IF(COUNTIF(R{r}:U{r},IF(V{r}<0,"<",">")&${me}{r})${ahead}= 25; + // 중기 % 코드 줄 중 규격 「주연료의 %」(잡품·잡유) = 앞 연료 줄 금액을 밑수 수식으로(대흥 「=J9」 실측) · + // 그 밖 % 코드(재료비의 % 등)는 산식 없음(안내 시트 알림) + const fuelPct = (c) => /주연료/.test(specOf(by)[c] || ""); + const amounts = (r, pct) => { + setF(r, PAIR.T[0], sum3(0)); + setF(r, PAIR.T[1], sum3(1)); + for (const k of nonT) { + const [u, a] = PAIR[k]; + const core = `ROUNDDOWN(${u}{r}*C{r}${pct ? "/100" : ""},1)`; + setF(r, a, `=IF(C{r}=0,0,${core}${tf ? `+${help[k].adj}{r}` : ""})`); + } + if (tf) helpers(r, (u) => `${u}{r}*C{r}`, 1, 0.1); + }; + // 비율 줄 밑수 — 표시 줄(Q=1_01)의 밑수 비목 금액 합 · 25 판부터 칸 더하기(울진 실측) + const prBase = (r, rn, a, marks, inK, outK) => { + const inA = PAIR[inK === "T" ? "M" : inK][1]; + const out = PAIR[outK === "T" ? "M" : outK][0]; + if (!marks.length) return; + if (v25) setF(r, out, "=" + marks.map((m) => `${inA}${m}`).join("+")); + else setF(r, out, `=SUMIF(Q${a}:Q${rn - 1},"1_01",${inA}${a}:${inA}${rn - 1})`); + }; + let first = 0; + let marks = []; + const lc = linkCol(sh); + sh.rows.forEach((r, i) => { + const rn = i + 1; + if (r._k === "parent") { + setF(r, "O", r._code); + first = rn + 1; + marks = []; + return; + } + if (r._k === "text" && first) { + // 글 줄 — 합계 밖 열쇠 + setF(r, "O", "_1"); + setF(r, "P", "_1"); + return; + } + if (r._k === "line" && r._code === "PR" && r._pr) { + setF(r, "O", "PRA01_1"); + setF(r, "P", "T7_1"); + amounts(r, true); + prBase(r, rn, first, marks, r._pr.in, r._pr.out); + if (r._mark) { + setF(r, "Q", "1_01"); + marks.push(rn); + } + return; + } + if (r._k === "line" && r._code && r._code !== "PR") { + const c = r._code; + const pct = r.D === "%"; + if (r._mark) { + setF(r, "Q", "1_01"); + marks.push(rn); + } + setF(r, "O", `${c}_1`); + if (pct && isJ && fuelPct(c)) { + // 표준 구성 % 줄 — 밑수 = 앞 줄 중 그 비목 금액이 있는 칸 더하기(대흥 「=J9」 실측) + const k = (unitOf(by)[c] || "M%")[0]; + const kk = k === "T" ? "M" : k; + const mk = []; + for (let j = first; j < rn; j++) { + const x = sh.rows[j - 1]; + if (x._k === "line" && Number(x[PAIR[kk][1]]) !== 0) mk.push(`${PAIR[kk][1]}${j}`); + } + if (mk.length) setF(r, PAIR[kk][0], "=" + mk.join("+")); + } + setF(r, "P", "T7_1"); + amounts(r, pct); + const labor = c[0] === "L" && lab[c] && isJ; + const list = labor ? by.환율및기초자료 : by[LIST_OF[c[0]]]; + if (!list) return; + const lr = labor + ? lab[c] + : (rowsOf[list.name] = rowsOf[list.name] || rowsBy(list, "item"))[c]; + if (!lr) return; + if (!pct) { + if (labor) setF(r, PAIR.L[0], `=${q(list.name)}!G${lr}`); + else if ("MLS".includes(c[0])) setF(r, PAIR[c[0]][0], `=${q(list.name)}!E${lr}`); + else { + const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); + for (const k of nonT) setF(r, PAIR[k][0], `=${q(list.name)}!${lcols[k]}${lr}`); + } + } + putJump(r, lc, list, "A", lr, codeLabel(by, c)); + return; + } + if (r._k === "total" && first) { + const a = first; + const b = rn - 1; + const toS = /경비/.test(String(r.A || "")); // 결과적용 비목 경비(SBMOK=S) — 세 비목 합을 경비 칸에 + setF(r, "O", "T7_1"); + const sumif = (col) => `SUMIF(P${a}:P${b},O{r},${col}${a}:${col}${b})`; + const fin = (k) => `ROUNDDOWN(${sumif(PAIR[k][1])}${tf ? `+${help[k].adj}{r}` : ""},0)`; + setF(r, PAIR.T[1], sum3(1)); + for (const k of nonT) { + const [u, am] = PAIR[k]; + if (tf) setF(r, u, `=${sumif(am)}`); + if (!toS) setF(r, am, `=${fin(k)}`); + } + if (toS) setF(r, PAIR.S[1], "=" + ["M", "L", "S"].map(fin).join("+")); + if (tf) { + setF(r, PAIR.T[0], sum3(0)); + helpers(r, (u) => `${u}{r}`, 0, 1); + } + first = 0; + } + }); + } + + /** + * 설계내역서 — 묶음 금액 = SUMIF(아래 행 Q 열쇠 = 이 묶음 P 열쇠) · 품목 단가 = 목록 단가 · 금액 = ROUNDDOWN(단가×수량,0) + * 열쇠 — 묶음 P = E단_1(제외 구역 C단_1 · 윗 묶음에 안 더함) · 품목 P = 코드_1(비율 줄 PR_1) · Q = 윗 묶음 열쇠 · + * 빈 줄 P = CMT_1 · 비율 줄 밑수 표시 R = 1_2_01 · 끝 줄 = 맨 위 열쇠(E10_1) 합 + */ + function linkDesign(by, ini) { + const sh = by.설계내역서; + if (!sh) return; + const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); + const PAIR = {}; + const order = C.bimokOrder(ini); + ["F", "H", "J", "L"].forEach((u, i) => (PAIR[order[i]] = [u, colName(colNum(u) + 1)])); + const nonT = order.filter((k) => k !== "T"); + const sum3 = (side) => `=${PAIR.M[side]}{r}+${PAIR.L[side]}{r}+${PAIR.S[side]}{r}`; + const lc = linkCol(sh); + const rowsOf = {}; + let first = 0; + let grpStart = 0; + let last = 0; + sh.rows.forEach((r, i) => { + const rn = i + 1; + if (/^T\d$/.test(r._k || "")) { + if (!first) first = rn; + grpStart = rn + 1; + last = rn; + setF(r, "P", `${r._ex ? "C" : "E"}${r._lv}_1`); + if (!r._ex && r._pk) setF(r, "Q", r._pk); + const end = r._end > rn ? r._end : rn; + for (const k of order) { + const am = PAIR[k][1]; + if (end > rn) setF(r, am, `=SUMIF(Q${rn + 1}:Q${end},P{r},${am}${rn + 1}:${am}${end})`); + } + return; + } + if (r._k === "blank") { + setF(r, "P", "CMT_1"); + if (r._pk) setF(r, "Q", r._pk); + last = rn; + return; + } + if (r._k !== "item") return; + last = rn; + const c = r._code; + setF(r, "P", c === "PR" ? "PR_1" : `${c}_1`); + setF(r, "Q", r._pk); + if (r._mark) setF(r, "R", "1_2_01"); + const pct = r.E === "%"; + if (pct) { + setF(r, PAIR.T[0], sum3(0)); + setF(r, PAIR.T[1], sum3(1)); + for (const k of nonT) setF(r, PAIR[k][1], `=ROUNDDOWN(${PAIR[k][0]}{r}*D{r}/100,0)`); + } + if (c === "PR" && r._pr) { + const inA = PAIR[r._pr.in === "T" ? "M" : r._pr.in][1]; + const out = PAIR[r._pr.out === "T" ? "M" : r._pr.out][0]; + if (grpStart < rn) + setF( + r, + out, + `=SUMIF(R${grpStart}:R${rn - 1},"1_2_01",${inA}${grpStart}:${inA}${rn - 1})`, + ); + return; + } + const list = by[LIST_OF[c[0]]]; + if (!list) return; + const lr = (rowsOf[list.name] = rowsOf[list.name] || rowsBy(list, "item"))[c]; + if (!lr) return; + if (!pct) { + if ("MLS".includes(c[0])) setF(r, PAIR[c[0]][0], `=${q(list.name)}!E${lr}`); + else { + const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); + for (const k of nonT) + if (lcols[k]) setF(r, PAIR[k][0], `=${q(list.name)}!${lcols[k]}${lr}`); + } + } + putJump(r, lc, list, "A", lr, codeLabel(by, c)); + }); + const fin = sh.rows.findIndex((r) => r._k === "final"); + if (fin >= 0 && first) + for (const k of order) { + const am = PAIR[k][1]; + setF(sh.rows[fin], am, `=SUMIF(Q${first}:Q${last},"E10_1",${am}${first}:${am}${last})`); + } + } + + /** + * 총괄내역서 공종 줄 · 순공사비 = 설계내역서 묶음 줄(끝 줄) 금액 참조 + 「내역 →」 바로가기 + */ + function linkSummary(by, ini) { + const sm = by.총괄내역서; + const ds = by.설계내역서; + if (!sm || !ds) return; + const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); + const order = C.bimokOrder(ini); + const dcol = {}; + ["G", "I", "K", "M"].forEach((c, i) => (dcol[order[i]] = c)); + const scol = {}; + ["D", "E", "F", "G"].forEach((c, i) => (scol[order[i]] = c)); + // 설계 묶음 줄 — 같은 단·공종 번호·명칭이 차례로 나오는 행을 맞춤 + const groups = ds.rows.map((r, i) => [r, i + 1]).filter(([r]) => /^T\d$/.test(r._k || "")); + let gi = 0; + const fin = ds.rows.findIndex((r) => r._k === "final") + 1; + const lc = linkCol(sm); + sm.rows.forEach((r) => { + let target = 0; + if (/^T\d$/.test(r._k || "")) { + while (gi < groups.length) { + const [g, rn] = groups[gi++]; + if (g._k === r._k && g.A === r.A && g.B === r.B) { + target = rn; + break; + } + } + } else if (r._k === "sub" && /가\./.test(String(r.A || "")) && fin > 0) target = fin; + if (!target) return; + for (const k of order) setF(r, scol[k], `=${q(ds.name)}!${dcol[k]}${target}`); + putJump(r, lc, ds, "A", target, "내역 →"); + }); + } + + /** + * 시트마다 — 이름표 칸 수식(파일 안 시트 이름) · 목차 바로가기(머리 끝 행 · 바로가기 열) + */ + function linkFrame(sheets) { + for (const sh of sheets) { + if (NO_LINK.has(sh.name) && sh.name !== "환율및기초자료") continue; + const tag = tagOf(sh); + if (tag) setF(sh.rows[1], tag, FILE_NAME); + if (NO_LINK.has(sh.name)) continue; + const lc = linkCol(sh); + if (!lc) continue; + const row = sh.rows[headRow(sh) - 1]; + setLink(row, lc, `${q(TOC)}!B2`, "목차 →"); + setF(row, lc, `=HYPERLINK("#${q(TOC)}!B2","목차 →")`); + } + } + + // 묶음 전체 잇기 — 시트 id 로 찾음(이름은 SET_REP 제목에 따라 바뀜) + function linkBook(sheets, dbf) { + const ini = (dbf && dbf.start_ini) || {}; + const by = { _dbf: dbf }; + for (const s of sheets) by[s.id || s.name] = s; + linkFrame(sheets); + linkLists(by, ini); + linkComposition(by, by.일위대가표, ini); + linkComposition(by, by.중기사용료, ini); + linkDesign(by, ini); + linkSummary(by, ini); + return sheets; + } + + return { linkBook, specOf, jump, tagOf, linkCol, headRow, q, colName, colNum, TOC, GUIDE, INIT }; +})(); + +if (typeof window !== "undefined") window.STMATE_REPORT_LINK = StmateReportLink; +if (typeof module !== "undefined" && module.exports) module.exports = StmateReportLink; diff --git a/tools/stmate-test/report_san.js b/tools/stmate-test/report_san.js index 876e866aa..f482697a6 100644 --- a/tools/stmate-test/report_san.js +++ b/tools/stmate-test/report_san.js @@ -263,6 +263,8 @@ const StmateReportSan = (() => { put4(cells, a); const rm = refRemark(u, ln.ref); if (rm) cells.A = rm; + const rc = String(ln.ref).match(/^([A-Z])(\d{1,5})[LMS]?$/); + if (rc) cells._ref = rc[1] + rc[2].padStart(5, "0"); // 참조 코드(안내 시트 % 단위 알림) 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; diff --git a/tools/stmate-test/report_unit.js b/tools/stmate-test/report_unit.js index d4de3a7a4..3ea088ccc 100644 --- a/tools/stmate-test/report_unit.js +++ b/tools/stmate-test/report_unit.js @@ -199,6 +199,8 @@ const StmateReportUnit = (() => { }); if (!ln.pct && !ln.labor && ln.unit !== "$") cells._f = C.pairFormulas(order, PAIR_COLS, "C", 1, true); + if (ln.marked || ln.prpt) cells._mark = true; // 비율 줄 밑수 표시(PRPT) — 엑셀 수식 SUMIF 열쇠 + if (ln.code === "PR") cells._pr = { in: ln.inK, out: ln.outK }; 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 af54ae1d8..ff97fd37b 100644 --- a/tools/stmate-test/report_xlsx.py +++ b/tools/stmate-test/report_xlsx.py @@ -5,7 +5,10 @@ 근거: 지식DB STmate/old/20_분석 19 §3(목차·안내 시트) · 20 §3(SET_REP 서식) · 06 §3(출력별 반올림). 쓰는 것: 값 · 글꼴(제목·머리·묶음 줄 굵게) · 테두리 · 숫자 서식(정수 #,##0 · 소수 자리 그대로) · 열 폭 · 인쇄 설정(A4 · 폭 맞춤 · 머리 행 반복 · 쪽 넘김) · 목차 바로가기 하이퍼링크. -수식(formulas=True) — 행의 _f {열: '=…{r}…'} 를 수식으로(ST_P_XLS Prn_FMLA_INFO) · 값은 엑셀이 다시 셈. +수식(formulas=True) — 행의 _f {열: '=…{r}…'} 를 수식으로(ST_P_XLS Prn_FMLA_INFO) · 값은 엑셀이 다시 셈 · + = 로 시작하지 않는 _f 값은 숨은 열쇠 글(SUMIF 묶음 열쇠) 그대로. +바로가기 — 행의 _links {열: '시트!칸'} = 하이퍼링크(수식 모드면 HYPERLINK 수식이 대신) · 칸별 글꼴 _sz · _bold. +원본 꼴 — 이름표 열 숨김 · 인쇄 영역 A~이름표 앞 열 · 시트 cols 고정 폭 · tab 색. 인쇄 설정(print) — 방향(landscape·portrait) · 글꼴 이름 · 크기(SET_REP 인쇄 메뉴 옵션). """ @@ -50,6 +53,14 @@ def sheet_title(name: str, used: set[str]) -> str: return title +def _tag_col(sheet: dict) -> str | None: + """시트 이름표 열 — 2행에서 시트 이름이 적힌 칸(원본 숨은 열 · 인쇄 영역 끝 다음).""" + rows = sheet.get("rows") or [] + if len(rows) < 2: + return None + return next((k for k, v in rows[1].items() if COL_RE.match(k) and v == sheet.get("name")), 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 @@ -64,20 +75,31 @@ def write_sheet(ws, sheet: dict, formulas: bool = False, prn: dict | None = None head_rows = r_idx if kind == "page" and r_idx > 1: ws.row_breaks.append(Break(id=r_idx - 1)) - for col, val in _cells(row).items(): + fmls = (row.get("_f") or {}) if formulas else {} + sizes = row.get("_sz") or {} + bolds = row.get("_bold") or {} + links = dict(row.get("_links") or {}) + if row.get("_link") and not links: # 옛 꼴 — D(없으면 A) 칸 바로가기 + at = next((c for c in ("D", "A") if c in row), None) + if at: + links[at] = f"'{row['_link']}'!A1" + cells = _cells(row) + for col in fmls: + if COL_RE.match(col) and col not in cells: + cells[col] = None + for col, val in cells.items(): c_idx = column_index_from_string(col) max_col = max(max_col, c_idx) - 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 font_size + fml = fmls.get(col) + out = fml.replace("{r}", str(r_idx)) if isinstance(fml, str) else val + cell = ws.cell(row=r_idx, column=c_idx, value=out) + bold = bolds.get(col, kind in BOLD_KINDS or kind == "title") + size = sizes.get(col) or (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") - else: + elif val is not None: text = str(val) cell.alignment = Alignment( horizontal="center" if kind == "head" else "left", @@ -86,23 +108,37 @@ def write_sheet(ws, sheet: dict, formulas: bool = False, prn: dict | None = None ) if kind == "head": cell.fill = HEAD_FILL - if kind not in ("title", "pname") and r_idx > 2: + if kind not in ("title", "pname") and r_idx > 2 and val is not None: cell.border = BOX - n = max(len(s) for s in str(val).split("\n")) if not isinstance(val, float) else 12 - width[c_idx] = max(width.get(c_idx, 0), n) - link = row.get("_link") - if link: - target = next((c for c in ("D", "A") if c in row), None) - if target: - cell = ws[f"{target}{r_idx}"] - cell.hyperlink = f"#'{link}'!A1" - cell.font = Font(name=FONT, size=10, color="1D4ED8", underline="single") + if val is not None: + n = max(len(s) for s in str(val).split("\n")) if not isinstance(val, float) else 12 + width[c_idx] = max(width.get(c_idx, 0), n) + target = links.get(col) + if target and not (isinstance(fml, str) and fml.startswith("=HYPERLINK")): + cell.hyperlink = f"#{target}" + cell.font = Font(name=font_name, size=size, color="1D4ED8", underline="single") if rows and rows[0].get("_k") == "title" and max_col > 1: - ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max_col) + tag = _tag_col(sheet) + end = column_index_from_string(tag) - 1 if tag else max_col + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(end, 1)) ws.cell(row=1, column=1).alignment = Alignment(horizontal="center", vertical="center") + fixed = sheet.get("cols") or {} for c_idx in range(1, max_col + 1): - w = width.get(c_idx, 4) - ws.column_dimensions[get_column_letter(c_idx)].width = min(max(w * 1.6 + 2, 6), 48) + letter = get_column_letter(c_idx) + w = fixed.get(letter) + if not isinstance(w, (int, float)): + w = min(max(width.get(c_idx, 4) * 1.6 + 2, 6), 48) + ws.column_dimensions[letter].width = w + # 원본 꼴 — 이름표 열 숨김 · 인쇄 영역 = A ~ 이름표 앞 열 · 탭 색 + tag = _tag_col(sheet) + if tag: + ws.column_dimensions[tag].hidden = True + last = get_column_letter(max(column_index_from_string(tag) - 1, 1)) + ws.print_area = f"A1:{last}{max(len(rows), 1)}" + elif sheet.get("print_cols"): + ws.print_area = f"A1:{sheet['print_cols']}{max(len(rows), 1)}" + if sheet.get("tab"): + ws.sheet_properties.tabColor = sheet["tab"] ws.page_setup.paperSize = ws.PAPERSIZE_A4 auto = "landscape" if max_col > 8 else "portrait" ws.page_setup.orientation = ( diff --git a/tools/stmate-test/scr_report.js b/tools/stmate-test/scr_report.js index 10ec0788c..87fdeeb9e 100644 --- a/tools/stmate-test/scr_report.js +++ b/tools/stmate-test/scr_report.js @@ -20,6 +20,7 @@ "report_coll.js", "report_san.js", "report_init.js", + "report_link.js", "report_book.js", "report_forms.js", "report_pps.js", @@ -79,11 +80,18 @@ const findSheet = (bk, id) => bk.sheets.find((s) => (s.id || s.name) === id) || (bk.extra || {})[id]; - // 시트 모형 → 표(미리보기 · 인쇄) - function sheetHtml(sh) { + // 인쇄 영역 끝 열 — 시트 이름표 열 앞까지(원본 인쇄 영역 A~이름표 앞 · 바로가기 → 칸은 인쇄 밖) + function printEnd(sh) { + const r2 = sh.rows[1] || {}; + const tag = Object.keys(r2).find((k) => /^[A-Z]{1,2}$/.test(k) && r2[k] === sh.name); + return tag ? colIdx(tag) - 1 : 0; + } + // 시트 모형 → 표(미리보기 · 인쇄 — 인쇄면 인쇄 영역만) + function sheetHtml(sh, forPrint) { let maxC = 1; for (const r of sh.rows) for (const k of Object.keys(r)) if (/^[A-Z]{1,2}$/.test(k)) maxC = Math.max(maxC, colIdx(k)); + if (forPrint && printEnd(sh) > 0) maxC = Math.min(maxC, printEnd(sh)); const cols = Array.from({ length: maxC }, (_, i) => colName(i + 1)); const body = sh.rows .map((r) => { @@ -256,7 +264,7 @@ const body = list .map( (sh, i) => - `

${esc(sh.title)}

${sheetHtml(sh)}
`, + `

${esc(sh.title)}

${sheetHtml(sh, true)}
`, ) .join(""); w.document.write( diff --git a/tools/stmate-test/scr_report_opts.js b/tools/stmate-test/scr_report_opts.js index debba2ea1..83ec92db7 100644 --- a/tools/stmate-test/scr_report_opts.js +++ b/tools/stmate-test/scr_report_opts.js @@ -2,7 +2,7 @@ /** * 보고서 인쇄 선택 (scr_report_opts.js) — PM_Prt 인쇄 메뉴 옵션 · PM_Prn_Ord 인쇄순서 · PM_Sel_St 집계수준 · wM_Sel_Prn 인쇄영역 * 옵션(F1-51) — 글꼴 · 글자 크기 · 코드필드(SH_TYP1 코드/호표) · 비고(SH_TYP3 코드/호표/가격지) · 번호 자리 맞춤(CH_PRN_COD) · 방향 - * 순서·수준(F1-52) — 내보낼 시트 차례(위·아래) · 총괄·집계 공종 단(1~6) + * 순서·수준(F1-52) — 내보낼 시트 차례(위·아래) · 총괄 공종 집계수준(PM_Sel_St rd_Filter · 기본 FLT_JAB+1) * 영역(F1-53) — 차수(당초 · 변경 1~8차) · 변경분만 · 분할 비율 · 구.시도 시기금 비율 · 엑셀 수식 넣기 * 근거: 지식DB STmate/old/20_분석 20 §2.4(Start8x.Ini 출력 설정) · 34(화면 구조 PM_Prt · wM_Sel_Prn) · 24 §3(Prn_FMLA_INFO) * 화면 상태 = ctx.uiState.reportOpts · 보고서 묶음은 effective() 로 설정을 덮어 만든 dbf 로 다시 짬 @@ -26,7 +26,8 @@ padNo: String(ini.CH_PRN_COD ?? "0"), orient: "auto", order: null, - level: 2, + // 공종 집계수준 — Start8x.Ini FLT_JAB(PM_Sel_St 고른 자리) + 1 단 이상(report_boq summaryLevel) + level: /^\d$/.test(String(ini.FLT_JAB ?? "").trim()) ? Number(ini.FLT_JAB) + 1 : 2, changeOnly: false, splitRate: 100, fundRate: 100, @@ -138,7 +139,14 @@
공종 집계수준 ${sel( "level", - [1, 2, 3, 4, 5, 6].map((n) => [n, `${n} 단 이상`]), + [ + "소계(+=)", + "계(+==)", + "합계(+===)", + "총계(+====)", + "총합계(+=====)", + "총대계(+======)", + ].map((t, i) => [i + 1, `${t} 이상`]), o.level, )}
분할 비율 % · 시기금 비율 %