auto: 2026-11-13 18:43 (EOMSANGDON-HOME)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
@@ -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 `
|
||||
<div class="win-form">
|
||||
<div class="win-form-header"><span>표준중기공식 편집. [wM_Edit_EQ]</span><span>${items.length}개 공식${dirty ? " · 고친 뒤 저장 안 함" : ""}</span></div>
|
||||
<div class="win-subbar">
|
||||
<button class="win-btn" id="eq-add">추 가</button>
|
||||
<button class="win-btn" id="eq-del">삭 제</button>
|
||||
<button class="win-btn primary" id="eq-save">저 장</button>
|
||||
<button class="win-btn" id="eq-cancel">취 소</button>
|
||||
<button class="win-btn" id="eq-close" style="margin-left:auto;">종 료</button>
|
||||
</div>
|
||||
<div class="split-horiz">
|
||||
<div class="pane-box" style="width:300px;"><div class="grid-scroll"><table class="dbgrid">
|
||||
<thead><tr><th style="width:30px">No</th><th>명칭</th><th style="width:70px">적용</th></tr></thead>
|
||||
<tbody>${items.map((e, k) => `<tr class="eq-row ${k === i ? "selected" : ""}" data-i="${k}" style="cursor:pointer;"><td class="center">${k + 1}</td><td>${esc(e.name)}</td><td class="center">${e.ins_x ? "공식+코드" : "공식"}</td></tr>`).join("")}</tbody>
|
||||
</table></div></div>
|
||||
<div class="split-vert" style="flex:1;padding:0;">
|
||||
${
|
||||
it
|
||||
? `<div class="win-subbar"><label>명칭 <input id="eq-name" value="${esc(it.name)}" style="width:260px"></label>
|
||||
<label>적용 <select id="eq-insx"><option value="0" ${!it.ins_x ? "selected" : ""}>공 식</option><option value="1" ${it.ins_x ? "selected" : ""}>공식+코드</option></select></label></div>
|
||||
<div class="pane-box" style="flex:1;"><textarea id="eq-text" class="smt-editor-area" spellcheck="false">${esc(it.text)}</textarea></div>
|
||||
<div class="pane-box" style="max-height:120px;overflow:auto;font-size:12px;">
|
||||
<div class="pane-title"><span>문법 검사 ${ev.errors.length}건 · 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")}</span></div>
|
||||
${ev.errors.map((e) => `<div style="color:#b91c1c">${e.lineNo}번째줄: ${esc(e.msg)}</div>`).join("")}
|
||||
</div>`
|
||||
: "<div class='pane-box'>등록된 중기공식 없음 — [추가]</div>"
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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 @@
|
||||
<label>수준(cBox01)</label><select id="boq-lv" class="win-select"><option value="0">내역 전체</option>${LV.map((t, i) => `<option value="${i + 1}" ${lv === i + 1 ? "selected" : ""}>${t}</option>`).join("")}</select>
|
||||
<input id="boq-search" class="win-input" placeholder="거르기(공종·코드·명칭·규격)" value="${esc(u.boqSearch || "")}" style="width:150px;" />
|
||||
<input id="boq-find" class="win-input" placeholder="찾기" value="${esc(u.boqFind || "")}" style="width:110px;" /><button class="win-btn" data-boq-act="find">다음 찾기</button>
|
||||
<button class="win-btn" data-boq-act="insBlank">행 삽입</button><button class="win-btn" data-boq-act="kongjong">코드 넣기</button><button class="win-btn" data-boq-act="wrap">계표시 삽입</button><button class="win-btn" data-boq-act="del">삭제</button>
|
||||
<button class="win-btn" data-boq-act="insBlank">행 삽입</button><button class="win-btn" data-boq-act="kongjong">코드 넣기</button><button class="win-btn" data-boq-act="wrap">계표시 삽입</button><button class="win-btn" data-boq-act="del">삭제</button><button class="win-btn" data-boq-act="jump" title="선택한 단가코드의 단가목록표·대가표로">코드 이동</button>
|
||||
<button class="win-btn" data-boq-act="up">▲</button><button class="win-btn" data-boq-act="down">▼</button>
|
||||
<button class="win-btn" data-boq-act="copy">복사</button><button class="win-btn" data-boq-act="paste" ${u.boqClip ? "" : "disabled"}>붙여넣기</button>
|
||||
<label>넣을 곳(RGrpTo)</label><select id="boq-grpto" class="win-select"><option value="boq">내역서</option><option value="pocket" ${u.boqGrpTo === "pocket" ? "selected" : ""}>열린 보관함</option></select>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user