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