feat(stmate-test): 3차 보고서 — 총괄내역서 공종 단계·목차·금액 한글 원본 일치 · 인쇄 옵션 결과 반영 · 장부

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 20:52:15 +09:00
co-authored by Claude Opus 5.5
parent 086750b6d4
commit e8b76dd65b
14 changed files with 1277 additions and 94 deletions
+12 -2
View File
@@ -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));
});
+340
View File
@@ -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"(?<![!A-Z$])([A-Z]{1,2})(\d+)")
# 시트 id → 수식 대조 열(원본 수식·숨은 열쇠 칸)
EXACT = {
"재료비목록표": "EH",
"일위대가목록표": "EFGHK",
"단가산출근거목록표": "EFGHK",
"중기목록표": "EFGHK",
"재료비수량금액집계표": "FGJ",
"노무비수량금액집계표": "FGJ",
"경비수량금액집계표": "FGJ",
"중기시간금액집계표": "FGHIL",
"일위대가표": ["E", "F", "G", "H", "I", "J", "K", "L", "O", "P", "Q"]
+ ["S", "T", "U", "V", "W", "X", "Y", "Z"],
"중기사용료": ["E", "F", "G", "H", "I", "J", "K", "L", "O", "P", "Q", "Z"],
}
@lru_cache(maxsize=None)
def formulas(name, sheet):
"""원본 시트 칸 → 수식 글(또는 숨은 열쇠 글)."""
warnings.filterwarnings("ignore")
wb = openpyxl.load_workbook(DOCS / PAIRS[name][1], read_only=True)
out = {}
for row in wb[sheet].iter_rows():
for c in row:
if c.value is not None:
out[(c.column_letter, c.row)] = c.value
return out
def mine_formulas(sh):
out = {}
for i, r in enumerate(sh["rows"], start=1):
for k, v in (r.get("_f") or {}).items():
out[(k, i)] = v.replace("{r}", str(i)) if isinstance(v, str) else v
return out
def tag_col(sh):
return next((k for k, v in sh["rows"][1].items() if v == sh["name"]), None)
@pytest.mark.parametrize("name", FULL)
@pytest.mark.parametrize("sid", list(EXACT))
def test_cross_sheet_formulas_match_original(name, sid):
"""목록 · 집계 · 일위대가표 · 중기사용료 — 참조 수식 · 끝수 배분 도움 열 · SUMIF 열쇠 · 바로가기 글 그대로."""
sh = book(name)[sid]
cols = set(EXACT[sid])
orig = formulas(name, sh["name"])
tag = tag_col(sh)
want = {
k: v
for k, v in orig.items()
if k[0] in cols
and k[1] > 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)
@@ -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():
+13 -13
View File
@@ -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%
+181 -24
View File
@@ -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])) };
}
+74 -20
View File
@@ -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 };
}
+15 -3
View File
@@ -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 {
+30
View File
@@ -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 };
+525
View File
@@ -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}<ROUND(ABS(V{r})/${step},0),${step},0)`,
);
}
};
const isJ = (sh.id || sh.name) === "중기사용료";
const v25 = Number(ini.PRG_V) >= 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;
+2
View File
@@ -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;
+2
View File
@@ -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;
+58 -22
View File
@@ -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 = (
+11 -3
View File
@@ -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) =>
`<section style="${i ? "page-break-before:always" : ""}"><h2 style="text-align:center">${esc(sh.title)}</h2>${sheetHtml(sh)}</section>`,
`<section style="${i ? "page-break-before:always" : ""}"><h2 style="text-align:center">${esc(sh.title)}</h2>${sheetHtml(sh, true)}</section>`,
)
.join("");
w.document.write(
+11 -3
View File
@@ -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 @@
<label><input type="checkbox" data-ro="changeOnly" ${o.changeOnly ? "checked" : ""}> 변경분만(집계표)</label></div>
<div>공종 집계수준 ${sel(
"level",
[1, 2, 3, 4, 5, 6].map((n) => [n, `${n} 단 이상`]),
[
"소계(+=)",
"계(+==)",
"합계(+===)",
"총계(+====)",
"총합계(+=====)",
"총대계(+======)",
].map((t, i) => [i + 1, `${t} 이상`]),
o.level,
)}</div>
<div>분할 비율 <input data-ro="splitRate" value="${esc(o.splitRate)}" size="4">% · 시기금 비율 <input data-ro="fundRate" value="${esc(o.fundRate)}" size="4">%</div>