From 64a162ea07fcd76870e9bd60d86dbc298fa4e7b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 18:43:13 +0900 Subject: [PATCH] auto: 2026-11-13 18:43 (EOMSANGDON-HOME) --- tools/stmate-test/kanjub_calc.js | 5 +- tools/stmate-test/report_xlsx.py | 138 +++++++++++++++++++++++++++++++ tools/stmate-test/scr_edit_eq.js | 106 ++++++++++++++++++++++++ tools/stmate-test/scr_ui_boq.js | 16 +++- 4 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 tools/stmate-test/report_xlsx.py create mode 100644 tools/stmate-test/scr_edit_eq.js diff --git a/tools/stmate-test/kanjub_calc.js b/tools/stmate-test/kanjub_calc.js index 653d99e81..143d5289f 100644 --- a/tools/stmate-test/kanjub_calc.js +++ b/tools/stmate-test/kanjub_calc.js @@ -9,10 +9,11 @@ * 정답 대조: resources/tester/test_stmate_kanjub_stc.py · test_stmate_kanjub_opts.py */ const StmateKanjubFactory = (h) => { - const { num, roundDown, roundHalfUp, roundBanker } = h; + const { num, roundDown, roundUp, roundHalfUp, roundBanker } = h; // 설정 읽개·항목 표·결과 묶음 — kanjub_parts.js const P = (globalThis.STMATE_KANJUB_PARTS || require("./kanjub_parts.js"))(h); - const { keyOf, S8, reader, addList, finish } = P; + const { keyOf, LABEL, S8, reader, addList, finish } = P; + function calcKanjub(data, boqRes, origKanjubRes = null, stageCfg = null) { const cfg = stageCfg || (data && data.kanjubConfig) || {}; const o = reader(cfg, data), diff --git a/tools/stmate-test/report_xlsx.py b/tools/stmate-test/report_xlsx.py new file mode 100644 index 000000000..0c5dcf921 --- /dev/null +++ b/tools/stmate-test/report_xlsx.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""보고서 시트 모형 → 엑셀(.xlsx) 쓰기 (report_xlsx.py). + +시트 모형 = report_*.js 가 만든 { name, title, rows:[{A:값,…,_k:종류,_link:시트}] } (rows[i] = i+1 행). +근거: 지식DB STmate/old/20_분석 19 §3(목차·안내 시트) · 20 §3(SET_REP 서식) · 06 §3(출력별 반올림). +쓰는 것: 값 · 글꼴(제목·머리·묶음 줄 굵게) · 테두리 · 숫자 서식(정수 #,##0 · 소수 자리 그대로) · +열 폭 · 인쇄 설정(A4 · 폭 맞춤 · 머리 행 반복 · 쪽 넘김) · 목차 바로가기 하이퍼링크. +""" + +from __future__ import annotations + +import re +from io import BytesIO + +import openpyxl +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import column_index_from_string, get_column_letter +from openpyxl.worksheet.pagebreak import Break + +COL_RE = re.compile(r"^[A-Z]{1,2}$") +BAD_TITLE = re.compile(r"[\[\]:*?/\\]") +THIN = Side(style="thin", color="808080") +BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) +HEAD_FILL = PatternFill("solid", start_color="E8EEF5", end_color="E8EEF5") +FONT = "맑은 고딕" +BOLD_KINDS = {"head", "T1", "T2", "T3", "T4", "T5", "sub", "total", "final", "hopyo", "parent"} + + +def _cells(row: dict) -> dict: + return {k: v for k, v in row.items() if COL_RE.match(k) and v not in (None, "")} + + +def _num_format(v: float) -> str: + if float(v).is_integer(): + return "#,##0" + digits = len(repr(float(v)).split(".")[1].rstrip("0")) + return "#,##0." + "0" * min(max(digits, 1), 6) + + +def sheet_title(name: str, used: set[str]) -> str: + """엑셀 시트 이름 — 금지 글자 걷고 31자 · 겹치면 번호.""" + base = BAD_TITLE.sub("", str(name or "Sheet"))[:31] or "Sheet" + title, i = base, 2 + while title in used: + title = f"{base[:28]}({i})" + i += 1 + used.add(title) + return title + + +def write_sheet(ws, sheet: dict) -> None: + rows = sheet.get("rows") or [] + width: dict[int, int] = {} + max_col = 1 + head_rows = 0 + for r_idx, row in enumerate(rows, start=1): + kind = row.get("_k", "") + if kind == "head": + head_rows = r_idx + if kind == "page" and r_idx > 1: + ws.row_breaks.append(Break(id=r_idx - 1)) + for col, val in _cells(row).items(): + 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) + bold = kind in BOLD_KINDS or kind == "title" + size = 16 if kind == "title" else 10 + cell.font = Font(name=FONT, size=size, bold=bold) + if isinstance(val, (int, float)) and not isinstance(val, bool): + cell.number_format = _num_format(val) + cell.alignment = Alignment(horizontal="right", vertical="center") + else: + text = str(val) + cell.alignment = Alignment( + horizontal="center" if kind == "head" else "left", + vertical="center", + wrap_text="\n" in text, + ) + if kind == "head": + cell.fill = HEAD_FILL + if kind not in ("title", "pname") and r_idx > 2: + cell.border = BOX + n = max(len(s) for s in str(val).split("\n")) if not isinstance(val, float) else 12 + width[c_idx] = max(width.get(c_idx, 0), n) + link = row.get("_link") + if link: + target = next((c for c in ("D", "A") if c in row), None) + if target: + cell = ws[f"{target}{r_idx}"] + cell.hyperlink = f"#'{link}'!A1" + cell.font = Font(name=FONT, size=10, color="1D4ED8", underline="single") + if rows and rows[0].get("_k") == "title" and max_col > 1: + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max_col) + ws.cell(row=1, column=1).alignment = Alignment(horizontal="center", vertical="center") + for c_idx in range(1, max_col + 1): + w = width.get(c_idx, 4) + ws.column_dimensions[get_column_letter(c_idx)].width = min(max(w * 1.6 + 2, 6), 48) + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.page_setup.orientation = "landscape" if max_col > 8 else "portrait" + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr.fitToPage = True + if head_rows: + ws.print_title_rows = f"1:{head_rows}" + ws.freeze_panes = ws.cell(row=head_rows + 1, column=1) if head_rows else None + + +def build_report_xlsx(sheets: list[dict]) -> 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) + if not wb.worksheets: + wb.create_sheet("빈 보고서") + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + +def build_csv(sheet: dict) -> str: + """시트 하나 → CSV(쉼표 · 큰따옴표 감쌈 · 엑셀 한글용 BOM 은 호출 쪽).""" + rows = sheet.get("rows") or [] + max_col = max( + (column_index_from_string(k) for r in rows for k in _cells(r)), + default=1, + ) + out = [] + for row in rows: + vals = [] + for c_idx in range(1, max_col + 1): + v = row.get(get_column_letter(c_idx)) + s = "" if v is None else str(v) + vals.append('"' + s.replace('"', '""') + '"' if re.search(r'[",\n]', s) else s) + out.append(",".join(vals)) + return "\r\n".join(out) + "\r\n" diff --git a/tools/stmate-test/scr_edit_eq.js b/tools/stmate-test/scr_edit_eq.js new file mode 100644 index 000000000..1fc5c0248 --- /dev/null +++ b/tools/stmate-test/scr_edit_eq.js @@ -0,0 +1,106 @@ +/* -*- coding: utf-8 -*- */ +/** + * 표준중기공식 편집 화면 (scr_edit_eq.js) — wM_Edit_EQ · 추가 · 삭제 · 저장 · 취소 · 적용(공식 / 공식+코드) + * 근거: 지식DB 28 §1.1(사용자 등록부 — 「수정된 중기공식을 저장하시겠습니까?」 · 「신규공식 추가」 · 「공식+코드」) · + * REA 30 TWM_EDIT_EQ(mBtn_Insert · mBtn_Delete · mBtn_Save · mBtn_Cancel · cBx_INS_X · MEMOC) · EQSYNC(NAME · SYNC · INS_X) + * 저장 = api_qty.py /api/qty/eq (work_dbf/EQSYNC.json) · 불러 쓰기 = 산출근거 편집기 「중기공식 불러오기」 + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const esc = (s) => + String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + + function load(ctx, app) { + if (ctx.uiState.eqDraft || ctx.uiState.eqDraftLoading) return; + ctx.uiState.eqDraftLoading = true; + fetch("/api/qty/eq") + .then((r) => r.json()) + .then((r) => { + ctx.uiState.eqItems = r.items || []; + ctx.uiState.eqDraft = JSON.parse(JSON.stringify(ctx.uiState.eqItems)); + ctx.uiState.eqDraftLoading = false; + app.renderWorkspace(); + }) + .catch(() => ((ctx.uiState.eqDraft = []), (ctx.uiState.eqDraftLoading = false))); + } + + function render(ctx, app) { + load(ctx, app); + const u = ctx.uiState; + const items = u.eqDraft || []; + const i = Math.min(u.eqSel || 0, Math.max(0, items.length - 1)); + const it = items[i]; + const ev = it ? global.STMATE_ENGINE.evaluateSmtFormulaText(it.text || "", ctx, "") : null; + const dirty = JSON.stringify(items) !== JSON.stringify(u.eqItems || []); + return ` +
+
표준중기공식 편집. [wM_Edit_EQ]${items.length}개 공식${dirty ? " · 고친 뒤 저장 안 함" : ""}
+
+ + + + + +
+
+
+ + ${items.map((e, k) => ``).join("")} +
No명칭적용
${k + 1}${esc(e.name)}${e.ins_x ? "공식+코드" : "공식"}
+
+ ${ + it + ? `
+
+
+
+
문법 검사 ${ev.errors.length}건 · 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")}
+ ${ev.errors.map((e) => `
${e.lineNo}번째줄: ${esc(e.msg)}
`).join("")} +
` + : "
등록된 중기공식 없음 — [추가]
" + } +
+
+
`; + } + + function bind(ctx, app) { + const u = ctx.uiState; + const $ = (id) => document.getElementById(id); + const items = u.eqDraft || []; + const i = Math.min(u.eqSel || 0, Math.max(0, items.length - 1)); + const redraw = () => app.renderWorkspace(); + document.querySelectorAll(".eq-row").forEach((tr) => (tr.onclick = () => ((u.eqSel = Number(tr.dataset.i)), redraw()))); + if ($("eq-name")) $("eq-name").onchange = (e) => ((items[i].name = e.target.value), redraw()); + if ($("eq-insx")) $("eq-insx").onchange = (e) => ((items[i].ins_x = Number(e.target.value)), redraw()); + if ($("eq-text")) $("eq-text").onchange = (e) => ((items[i].text = e.target.value), redraw()); + $("eq-add").onclick = () => { + if (!confirm("새로운 중기공식을 추가하시겠습니까?")) return; + items.push({ name: `신규공식 ${items.length + 1}`, text: "q = 0.7 , K = 0.9 , f = 1/1.25 , E = 0.6\r\nCm = 21\r\nQ = 3600*q*K*f*E/Cm =", ins_x: 0 }); + u.eqSel = items.length - 1; + redraw(); + }; + $("eq-del").onclick = () => { + if (!items.length || !confirm("선택된 중기공식을 삭제하시겠습니까?")) return; + items.splice(i, 1); + redraw(); + }; + $("eq-cancel").onclick = () => ((u.eqDraft = JSON.parse(JSON.stringify(u.eqItems || []))), redraw()); + $("eq-save").onclick = async () => { + if (!confirm("수정된 중기공식을 저장하시겠습니까?")) return; + try { + await app.postJson("/api/qty/eq", { items }); + u.eqItems = JSON.parse(JSON.stringify(items)); + redraw(); + } catch (err) { + alert("중기공식 저장 실패: " + err.message); + } + }; + $("eq-close").onclick = () => ((u.activeScreen = "wM_Edit_iLWi"), redraw()); + } + + P.screens.wM_Edit_EQ = { render, bind }; + P.actions.Act_Qty_EditEQ = (ctx, app) => ((ctx.uiState.activeScreen = "wM_Edit_EQ"), app.renderWorkspace()); + if (!P.menus.some((m) => m.act === "Act_Qty_EditEQ")) P.menus.push({ menu: "m2", label: "표준중기공식 편집", act: "Act_Qty_EditEQ" }); +})(window); diff --git a/tools/stmate-test/scr_ui_boq.js b/tools/stmate-test/scr_ui_boq.js index cb8b7cf82..ac2592fd9 100644 --- a/tools/stmate-test/scr_ui_boq.js +++ b/tools/stmate-test/scr_ui_boq.js @@ -101,6 +101,17 @@ u.boqPockets[n].push(clip); api.renderWorkspace(); }, + // 호표 코드 → 단가목록표(기초단가·견적) · 대가표(일위대가·산근·중기) + jump: (ctx, api) => { + const r = ctx.dbfData.boq_rows.find((x) => Number(x._recno) === sel(ctx)[0]); + const c = r && B.isLeaf(r) ? B.codeOf(r) : ""; + if (!c) return alert("단가코드 행을 고르세요"); + if (/^[MLSW]/.test(c)) { + ctx.uiState.dan = Object.assign(ctx.uiState.dan || {}, { kind: "ALL", kw: c, mode: "name", code: c }); + ctx.uiState.activeScreen = "wM_JabPum"; + } else Object.assign(ctx.uiState, { selectedIlwiCode: c, activeScreen: "wM_Edit_iLWi" }); + api.renderWorkspace(); + }, qtyList: (ctx, api) => { if (P.screens.wQtyList) return ((ctx.uiState.activeScreen = "wQtyList"), api.renderWorkspace()); api.openMappedModal("Act_QtyModal"); @@ -114,7 +125,7 @@ } const MENUS = [ - ["편집", [["insBlank", "행 삽입 (Ins)"], ["kongjong", "공종목록에서 코드 넣기"], ["del", "행 삭제 (Del)"], null, ["copy", "복사 (Ctrl+C)"], ["cut", "잘라내기 (Ctrl+X)"], ["paste", "붙여넣기 (Ctrl+V)"], null, ["up", "위로 이동 (Alt+↑)"], ["down", "아래로 이동 (Alt+↓)"]]], + ["편집", [["insBlank", "행 삽입 (Ins)"], ["kongjong", "공종목록에서 코드 넣기"], ["del", "행 삭제 (Del)"], ["jump", "코드의 단가·대가표로 이동"], null, ["copy", "복사 (Ctrl+C)"], ["cut", "잘라내기 (Ctrl+X)"], ["paste", "붙여넣기 (Ctrl+V)"], null, ["up", "위로 이동 (Alt+↑)"], ["down", "아래로 이동 (Alt+↓)"]]], ["계표시", [["wrap", "계표시 삽입(선택 행 묶음)"], ["lvUp", "계표시 레벨 상향"], ["lvDn", "계표시 레벨 하향"], ["note2grp", "주석 → 계시작 변환"]]], ["도구", [["itnum", "공종번호 자동입력 (wITNum)"], ["boqRate", "할증율 적용 (wBoqRate)"], ["mRate", "단수 일괄보정 (wM_Rate)"], ["expnd", "일위대가표 풀기 (wExpnd)"], null, ["mboq", "주공종 집계표 (wMBoq)"], ["qtyList", "수량산출서 (wQtyList)"]]], ["보관함", [["pocketPut", "선택 행 보관함에 담기"]]], @@ -198,7 +209,7 @@ - + @@ -231,6 +242,7 @@ $("boq-find").onchange = (e) => (u.boqFind = e.target.value); $("boq-grpto").onchange = (e) => (u.boqGrpTo = e.target.value); document.querySelectorAll("#boq-form td.rowhead").forEach((td) => { + td.ondblclick = () => run("jump"); td.onclick = (e) => { const rec = Number(td.getAttribute("data-recno")); const rows = ctx.dbfData.boq_rows;