feat(stmate-test): 3차 보고서 — 총괄내역서 공종 단계·목차·금액 한글 원본 일치 · 인쇄 옵션 결과 반영 · 장부
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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%
|
||||
|
||||
@@ -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])) };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user