auto: 2026-11-14 13:24 (EOMSANGDON-HOME)

This commit is contained in:
2026-11-14 13:24:32 +09:00
parent c5fc1fd7aa
commit a1fcdd311b
5 changed files with 348 additions and 44 deletions
+46
View File
@@ -86,6 +86,52 @@ def api_qty_qcode_save(req: QcodeSaveReq):
return {"ok": True, "saved_dir": str(out_dir), "saved_stc": str(stc), "count": len(req.rows)}
# 수량산출서 파일(IFORM) — 원본 형식(VTS·XLS) 바이트 그대로 · REA 35 STmateQty8 불러오기·저장·모두 내보내기
class SheetFileReq(BaseModel):
filename: str
data_b64: str
@router.post("/api/qty/sheet-file")
def api_qty_sheet_file(req: SheetFileReq):
"""수량산출파일 불러오기 — 확장자·크기 검사(원본 알림 글) · 엑셀이면 셀·이름 해독 · IFORM_B64 돌려줌."""
import base64
from qty_sheetfile import check_upload, describe
data = base64.b64decode(req.data_b64 or "")
msg = check_upload(req.filename, data)
if msg:
raise HTTPException(status_code=400, detail=msg)
out = describe(data)
out["IFORM_B64"] = req.data_b64
return out
class SheetExportReq(BaseModel):
source_path: str
target_folder_name: str = "stmate_edited"
@router.post("/api/qty/sheet-export")
def api_qty_sheet_export(req: SheetExportReq):
"""모든 수량산출서를 「수량산출서」 폴더에 <CODE>.VTS 로 — 내용은 IFORM 그대로(확장자는 늘 .VTS)."""
from qty_sheetfile import export_names
rows = read_qcode(_read_files(req.source_path))["rows"]
safe = (
re.sub(r"[^\w\-가-힣]", "_", req.target_folder_name or "stmate_edited").strip("_")
or "stmate_edited"
)
out_dir = _work_root() / safe / "수량산출서"
files = export_names(rows)
if files:
out_dir.mkdir(parents=True, exist_ok=True)
for name, data in files:
(out_dir / name).write_bytes(data)
return {"ok": True, "saved_dir": str(out_dir), "files": [n for n, _ in files]}
# 표준중기공식(EQSYNC) — 설치 폴더 공용 등록부 · 여기서는 work_dbf/EQSYNC.json
class EqItem(BaseModel):
name: str
+27 -11
View File
@@ -2,13 +2,16 @@
근거: 지식DB STmate/old/20_분석 02(QCODE 15필드 CODE·NAME·BQCODE·QTY0~9·IFORM·XCODE) ·
28 §2(기본수량변수 ↔ 확장수량변수) · REA 30 quantity_variable_resolution(QTY0~9 직접 · XCODE 이름=값) ·
SIxMemo_구조(메모 칸 10바이트 <HII: 태그 · 길이 · 블록번호>).
SIxMemo_구조(메모 칸 10바이트 <HII: 태그 · 길이 · 블록번호>) ·
REA 35(IFORM = 수량산출서 파일 바이트 VTS·XLS 그대로 · XCODE = 「이름=값」CRLF 글).
"""
from __future__ import annotations
import base64
import struct
from qty_sheetfile import kind_of
from smt_parser import build_dbf_bytes, parse_dbf_bytes
MEMO_FIELDS = ("IFORM", "XCODE")
@@ -24,7 +27,7 @@ DEFAULT_FIELDS = [
]
def _memo_text(hex_ptr: str, smt: bytes) -> str:
def _memo_bytes(hex_ptr: str, smt: bytes) -> bytes:
raw = bytes.fromhex(hex_ptr or "")
if (
len(raw) < 10
@@ -32,17 +35,21 @@ def _memo_text(hex_ptr: str, smt: bytes) -> str:
or smt[8:15] != b"SIxMemo"
or not any(b not in (0, 0x20) for b in raw)
):
return ""
return b""
_tag, length, block = struct.unpack("<HII", raw[:10])
size = struct.unpack("<I", smt[4:8])[0] or 64
start = block * size
if length <= 0 or start >= len(smt):
return ""
return smt[start : start + length].rstrip(b"\x00").decode("cp949", "replace")
return b""
return smt[start : start + length]
def _memo_text(hex_ptr: str, smt: bytes) -> str:
return _memo_bytes(hex_ptr, smt).rstrip(b"\x00").decode("cp949", "replace")
def read_qcode(files: dict[str, bytes]) -> dict:
"""STC 파일 묶음 → {fields, rows} · 메모 칸(IFORM · XCODE)은 본문 글자로."""
"""STC 파일 묶음 → {fields, rows} · XCODE 는 글자 · IFORM 은 파일 바이트(IFORM_B64 · 종류 · 크기)."""
dbf = files.get("QCODE.DBF", b"")
if len(dbf) < 32:
return {"fields": DEFAULT_FIELDS, "rows": [], "present": False}
@@ -51,9 +58,15 @@ def read_qcode(files: dict[str, bytes]) -> dict:
out = []
for r in rows:
row = {f["name"]: r.get(f["name"], "") for f in fields}
for m in MEMO_FIELDS:
if m in row:
row[m] = _memo_text(r.get(f"{m}_HEX", ""), smt)
if "XCODE" in row:
row["XCODE"] = _memo_text(r.get("XCODE_HEX", ""), smt)
if "IFORM" in row:
# IFORM = 수량산출서 파일(VTS·XLS) 바이트 그대로 — 글자로 풀지 않음
data = _memo_bytes(r.get("IFORM_HEX", ""), smt)
row["IFORM"] = ""
row["IFORM_B64"] = base64.b64encode(data).decode("ascii") if data else ""
row["IFORM_KIND"] = kind_of(data)
row["IFORM_SIZE"] = len(data)
out.append(row)
return {
"fields": [{k: f[k] for k in ("name", "type", "len", "dec")} for f in fields],
@@ -76,8 +89,11 @@ def build_qcode(rows: list[dict], files: dict[str, bytes] | None = None) -> tupl
for f in fields:
name, val = f["name"], r.get(f["name"], "")
if f.get("type") == "M" or name in MEMO_FIELDS:
text = str(val or "").replace("\r\n", "\n").replace("\n", "\r\n")
data = text.encode("cp949", "replace")
if name == "IFORM" and r.get("IFORM_B64"):
data = base64.b64decode(r["IFORM_B64"]) # 수량산출서 파일 바이트 그대로
else:
text = str(val or "").replace("\r\n", "\n").replace("\n", "\r\n")
data = text.encode("cp949", "replace")
if not data:
rec[f"{name}_HEX"] = (b" " * int(f["len"])).hex()
continue
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""STMATE 수량산출서 파일(IFORM) — 원본 형식 그대로 받기·내보내기 · 엑셀(BIFF) 셀·이름 읽기.
근거: 지식DB STmate/REA 분석 35(보고서양식_수량산출서) — STmateQty8.CPM TwQtyList
· 불러오기 GrdQEditButtonClick 0x41bcc8: 확장자 .XLS·.VTS 만 · 16MB(0x1000000) 초과 거절 ·
Formula One Read(DISPID 0xCA) 로 열어 보고 · 원래 파일 바이트를 QCODE.IFORM 메모에 그대로(Replace 0x2000)
· 저장 SaveSheet 0x4211c4: Write(DISPID 0xF0 · 읽은 형식 그대로) → 크기 검사 → IFORM
· 모두 내보내기 sBtnSaveToFileClick 0x420ee0: <작업 폴더>\\수량산출서\\<CODE>.VTS (IFORM 있는 행만 · 내용 그대로)
· 엑셀은 5.0/95(BIFF5) 만 지원한다는 알림(0x1ba80 · 0x1bc55)
VTS(Formula One 고유 형식)는 내용 해독 근거 없음 — 바이트 보존만.
"""
from __future__ import annotations
import base64
import re
MAX_FILE = 0x1000000 # 16MB
OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
EXT_MSG = '수량산출서 파일은 확장가가 ".VTS" 또는 ".XLS"인 파일만 가능 합니다.'
SIZE_MSG = "파일이 너무 커서 저장 할수 없습니다. 저장 가능한 파일의 최대크기는 16MB 입니다."
XLS_MSG = "엑셀파일은 엑셀버젼 5.0,95 버젼만 지원 됩니다."
def kind_of(data: bytes) -> str:
"""IFORM 바이트 종류 — xls(OLE 복합 문서 · BIFF) · vts(그 밖 · Formula One) · 빈 것."""
if not data:
return ""
return "xls" if data[:8] == OLE_MAGIC else "vts"
def check_upload(filename: str, data: bytes) -> str | None:
"""불러오기 검사 — 원본 알림 글 · 통과면 None."""
ext = re.search(r"\.[^.\\/]*$", filename or "")
if not ext or ext.group(0).upper() not in (".XLS", ".VTS"):
return EXT_MSG
if len(data) > MAX_FILE:
return SIZE_MSG
return None
def _col(i: int) -> str:
s = ""
i += 1
while i:
i, r = divmod(i - 1, 26)
s = chr(65 + r) + s
return s
def parse_xls(data: bytes) -> dict:
"""엑셀(BIFF2~8) → {biff, compatible, sheets:[{name, rows, cols, cells{A1:값}}], names:[{name, sheet, addr}]}.
compatible = BIFF5 이하(원본 Formula One 이 읽는 엑셀 5.0/95) · 이름은 한 칸을 가리키는 것만 addr.
"""
import xlrd
bk = xlrd.open_workbook(file_contents=data, formatting_info=False)
sheets = []
for sh in bk.sheets():
cells = {}
for r in range(sh.nrows):
for c in range(sh.ncols):
cell = sh.cell(r, c)
if cell.ctype in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK):
continue
v = cell.value
if cell.ctype == xlrd.XL_CELL_NUMBER and float(v).is_integer():
v = int(v)
cells[f"{_col(c)}{r + 1}"] = v if not isinstance(v, str) else v.rstrip()
sheets.append({"name": sh.name, "rows": sh.nrows, "cols": sh.ncols, "cells": cells})
names = []
for nm in bk.name_obj_list:
item = {"name": nm.name, "sheet": None, "addr": None}
res = getattr(nm, "result", None)
refs = getattr(res, "value", None) if res is not None else None
# 한 영역 참조만(여러 영역·식 이름은 칸 없이) · Ref3D coords = 시트lo,hi,행lo,hi,열lo,hi
if isinstance(refs, list) and len(refs) == 1 and hasattr(refs[0], "coords"):
s0, _s1, r0, r1, c0, c1 = refs[0].coords
item["sheet"] = s0
if r1 - r0 == 1 and c1 - c0 == 1:
item["addr"] = f"{_col(c0)}{r0 + 1}"
names.append(item)
return {
"biff": bk.biff_version,
"compatible": bk.biff_version <= 70,
"sheets": sheets,
"names": names,
}
def describe(data: bytes) -> dict:
"""IFORM 바이트 설명 — 종류·크기 · 엑셀이면 시트 해독(실패면 오류 글)."""
out = {"kind": kind_of(data), "size": len(data)}
if out["kind"] == "xls":
try:
out["workbook"] = parse_xls(data)
if not out["workbook"]["compatible"]:
out["warning"] = XLS_MSG
except Exception as e: # noqa: BLE001
out["error"] = (
f"수량산출서로 저장된 파일이 손상되었거나 파일형식이 잘못 되었습니다. ({e})"
)
return out
def export_names(rows: list[dict]) -> list[tuple[str, bytes]]:
"""모두 내보내기 — IFORM 있는 행마다 (<CODE>.VTS, 바이트) · 확장자는 내용과 무관하게 .VTS."""
out = []
for r in rows:
data = b64(r.get("IFORM_B64"))
if data:
out.append((f"{str(r.get('CODE', '')).strip()}.VTS", data))
return out
def b64(text: str | None) -> bytes:
return base64.b64decode(text) if text else b""
+64 -3
View File
@@ -110,8 +110,12 @@
${
sel >= 0 && qrows[sel]
? `<div class="pane-box" style="width:300px;">
<div class="pane-title"><span>${esc(qrows[sel].CODE)} 연결식(IFORM · QTYn=셀별명 식)</span></div>
<textarea class="smt-editor-area qty-q-memo" data-k="IFORM" style="height:110px">${esc(qrows[sel].IFORM || "")}</textarea>
<div class="pane-title"><span>${esc(qrows[sel].CODE)} 수량산출파일(IFORM · 원본 VTS·XLS 그대로)</span></div>
<div style="font-size:12px;padding:4px;">${qrows[sel].IFORM_SIZE ? `${esc(String(qrows[sel].IFORM_KIND || "").toUpperCase())} · ${Number(qrows[sel].IFORM_SIZE).toLocaleString()} 바이트` : "저장된 수량산출파일 없음"}
<button class="btn" id="qty-iform-load">수량산출파일 불러오기</button>
<button class="btn" id="qty-iform-clear">수량산출파일 초기화</button>
<button class="btn" id="qty-iform-export">모두 파일로 저장(.VTS)</button>
<input type="file" id="qty-iform-input" accept=".xls,.vts,.XLS,.VTS" style="display:none"></div>
<div class="pane-title"><span>확장 수량변수(XCODE · 이름=값)</span></div>
<textarea class="smt-editor-area qty-q-memo" data-k="XCODE" style="height:150px">${esc(qrows[sel].XCODE || "")}</textarea>
<div class="pane-title"><span>수량변수 산근에 전송 — 산출근거에 넣을 글</span></div>
@@ -316,6 +320,57 @@
}
}
// 수량산출파일(IFORM) — 불러오기(.XLS·.VTS · 16MB) · 초기화 · 모두 내보내기(REA 35 STmateQty8)
function bindIform(ctx, app, qrows) {
const $ = (id) => document.getElementById(id);
const u = ctx.uiState;
const cur = qrows[u.qtyQSel];
if (!cur || !$("qty-iform-load")) return;
const redraw = () => app.renderWorkspace();
$("qty-iform-load").onclick = () => $("qty-iform-input").click();
$("qty-iform-input").onchange = (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const rd = new FileReader();
rd.onload = async () => {
const b64 = String(rd.result).split(",")[1] || "";
try {
const res = await app.postJson("/api/qty/sheet-file", {
filename: f.name,
data_b64: b64,
});
Object.assign(cur, {
IFORM_B64: res.IFORM_B64,
IFORM_KIND: res.kind,
IFORM_SIZE: res.size,
});
if (res.warning) alert(res.warning);
if (res.workbook) {
const { sheet: ws } = S().fromWorkbook(res.workbook, 0);
u.qtySheets[ctx.currentSourcePath || "-"] = ws;
const asg = S().assignFromSheet(qrows, ws, String(cur.CODE).trim());
ctx.dbfData.qcode_rows = asg.rows;
}
redraw();
} catch (err) {
alert(String((err && err.message) || err));
}
};
rd.readAsDataURL(f);
};
$("qty-iform-clear").onclick = () =>
confirm(
"현재 코드에 저장된 수량산출파일이 삭제 됩니다. 그러나 확장수량변수에 저장된 내용이 삭제 되지는 않습니다.",
) && (Object.assign(cur, { IFORM_B64: "", IFORM_KIND: "", IFORM_SIZE: 0 }), redraw());
$("qty-iform-export").onclick = async () => {
if (!ctx.currentSourcePath) return;
const res = await app.postJson("/api/qty/sheet-export", {
source_path: ctx.currentSourcePath,
});
alert(`모든 수량산출서를 "수량산출서"폴더에 파일로 저장하였습니다. (${res.files.length}개)`);
};
}
function bindList(ctx, app, qrows, sheet) {
const $ = (id) => document.getElementById(id);
const u = ctx.uiState;
@@ -356,8 +411,14 @@
)),
redraw()
);
bindIform(ctx, app, qrows);
$("qty-q-assign").onclick = () => {
const res = S().assignFromSheet(qrows, sheet);
const cur = qrows[u.qtyQSel];
const res = S().assignFromSheet(
qrows,
sheet,
cur ? String(cur.CODE || "").trim() : undefined,
);
d.qcode_rows = res.rows;
const se = Object.entries(res.sheetErrors);
u.qtyCheck = res.errors
+93 -30
View File
@@ -7,6 +7,8 @@
* REA 28 TWQTYLIST(내역명칭·규격·단위·수량·시작줄·끝줄·단가·공종번호·내역코드 필드 · 명칭/코드 기준 가져가기)
* · 수량모듈 중간메모(시작~끝 작은 행부터 양 끝 포함 · Tamt1 = Mamt1+Lamt1+Samt1 · 수량 곱셈 없음)
* · REA 30 quantity_variable_resolution(QTY0~9 직접 · XCODE 「이름=값」 · 빈 값 0 · 없는 이름 오류)
* · REA 35(보고서양식_수량산출서) — IFORM = 수량산출서 파일 바이트(Formula One VTS · 엑셀 5.0/95 XLS) ·
* ReadDefName(셀별명 → XCODE 「이름=값」CRLF · QTYn 이름 → QTYn 칸 · 인쇄 영역·내역 전송 이름 제외)
*/
const StmateQtySheet = (() => {
const X = () =>
@@ -17,6 +19,7 @@ const StmateQtySheet = (() => {
return Number.isFinite(n) ? n : 0;
};
const SHEET_SIG = "STMATE-QTY-SHEET/1";
const MAX_FILE = 0x1000000; // 수량산출서 파일 최대 16MB
// 열 이름 ↔ 번호(A=0)
const colName = (i) =>
@@ -89,35 +92,96 @@ const StmateQtySheet = (() => {
return { values, errors, aliasValues };
}
// 셀별명 중 ReadDefName 이 건너뛰는 이름(REA 35 · STmateQty8 0x41b2d0~0x41b380) — 인쇄 영역 둘 + 내역 전송 일곱
const RESERVED = ["PRINT_TITLES", "PRINT_AREA"];
const BOQ_NAMES = [
"내역명칭",
"내역규격",
"내역단위",
"내역수량",
"내역코드",
"내역시작",
"내역__끝",
];
const reservedName = (n) =>
RESERVED.includes(String(n).toUpperCase()) || BOQ_NAMES.includes(String(n));
// QTY 이름 — 대문자 이름에 「QTY」가 있고 넷째 글자가 숫자(0x41b2a1~0x41b2bf)
const qtyName = (n) => String(n).toUpperCase().includes("QTY") && /\d/.test(String(n)[3] || "");
// Delphi FloatToStr(ffGeneral · 유효 15자리) 꼴
function floatToStr(v) {
if (!Number.isFinite(v) || v === 0) return "0";
const a = Math.abs(v);
if (a >= 1e15 || a < 1e-5) {
const [m, e] = v.toExponential(14).split("e");
const mant = m.replace(/\.?0+$/, "");
return `${mant}E${Number(e) < 0 ? "-" : ""}${Math.abs(Number(e))}`;
}
return String(Number(v.toPrecision(15)));
}
/**
* 기본 수량변수 ← 확장 수량변수 — 수량코드 행 IFORM 의 「QTYn=식」 줄을 셀별명으로 셈해 QTYn 에 넣고
* XCODE 에 셀별명 값 전부를 「이름=값」 줄로 남김(방금 저장된 수량산출서의 확장수량변수 값)
* 확장수량변수 읽기(ReadDefName · STmateQty8 0x41b200) — 수량산출서 저장·불러오기 직후 그 코드 한 행에
* · 셀별명마다(인쇄 영역·내역 전송 이름 빼고) XCODE += 「이름=FloatToStr(값)」+CRLF
* · 이름이 QTYn 꼴이면 같은 이름 칸(QTY0~9)에 값 · 없는 칸이면 「확장수량변수 읽어오기 실패」
* code 를 주면 그 행만, 없으면 모든 행(같은 시트를 쓰는 경우)
*/
function assignFromSheet(qrows, sheet) {
function assignFromSheet(qrows, sheet, code) {
const ev = evalSheet(sheet);
const errors = [];
const xcode = Object.entries(ev.aliasValues)
.map(([k, v]) => `${k}=${v}`)
.join("\r\n");
const out = qrows.map((r) => {
const row = { ...r };
for (const ln of String(r.IFORM || "").split(/\r?\n/)) {
const m = ln.match(/^\s*(QTY\d)\s*=\s*(.+)$/i);
if (!m) continue;
const res = X().evaluate(m[2], { ...ev.aliasValues });
if (res.error || res.missing.length) {
errors.push({
code: r.CODE,
msg: `${m[1]} — ${res.error || `없는 셀별명 「${res.missing[0]}」`}`,
});
continue;
}
row[m[1].toUpperCase()] = String(res.value);
let xcode = "";
const qty = {};
for (const nm of Object.keys(sheet.aliases || {})) {
if (reservedName(nm)) continue;
const v = ev.aliasValues[nm.toUpperCase()] || 0;
xcode += `${nm}=${floatToStr(v)}\r\n`;
if (qtyName(nm)) {
const f = nm.toUpperCase();
if (/^QTY\d$/.test(f)) qty[f] = floatToStr(v);
else errors.push({ code: code || "", msg: `${nm} — 확장수량변수 읽어오기 실패(없는 칸)` });
}
row.XCODE = xcode;
return row;
}
const out = qrows.map((r) => {
if (code && String(r.CODE || "").trim() !== code) return { ...r };
return { ...r, ...qty, XCODE: xcode };
});
return { rows: out, errors, sheetErrors: ev.errors };
return { rows: out, errors, sheetErrors: ev.errors, qty, xcode };
}
/**
* 원본 수량산출서(엑셀 BIFF · 서버 qty_sheetfile 해독) → 시트 모형 — 셀별명 = 이름 정의(한 칸 가리키는 것)
* 내역 전송 이름(내역명칭·규격·단위·수량·코드 · 자재·노무·경비단가 · 공종번호) → 열 · 내역시작·내역__끝 → 줄
* 원본 조건: 내역시작·내역__끝·내역코드 세 이름이 한 워크시트에 있어야 전송(0x1e531 알림)
*/
const ROLE_OF = {
내역명칭: "name",
내역규격: "size",
내역단위: "unit",
내역수량: "qty",
내역코드: "code",
자재단가: "mamt",
노무단가: "lamt",
경비단가: "samt",
공종번호: "no",
};
function fromWorkbook(wb, sheetIndex = 0) {
const ws = (wb.sheets || [])[sheetIndex] || { cells: {}, rows: 0, cols: 0 };
const sh = newSheet(Math.max(ws.rows || 0, 1), Math.max(ws.cols || 0, 1));
sh.cells = { ...(ws.cells || {}) };
const where = {};
for (const n of wb.names || []) {
if (n.sheet !== sheetIndex || !n.addr) continue;
where[n.name] = n.addr;
if (!RESERVED.includes(String(n.name).toUpperCase())) sh.aliases[n.name] = n.addr;
const p = parseAddr(n.addr);
if (ROLE_OF[n.name] && p) sh.roles[ROLE_OF[n.name]] = colName(p.c);
}
const s = parseAddr(where["내역시작"] || ""),
e = parseAddr(where["내역__끝"] || "");
if (s) sh.start = s.r;
if (e) sh.end = e.r;
const sendable = !!(s && e && where["내역코드"]);
return { sheet: sh, sendable };
}
// 수량코드 검사(F3-29) — 형식 · 중복 · 내역코드 · 수치 · XCODE 줄 · IFORM 줄
@@ -147,13 +211,8 @@ const StmateQtySheet = (() => {
if (!/^\s*[A-Za-z가-힣_][^=]*=/.test(ln))
errs.push(`${at}: 확장수량변수 줄 「${ln}」 은 이름=값 꼴이 아님`);
});
String(r.IFORM || "")
.split(/\r?\n/)
.filter((ln) => ln.trim())
.forEach((ln) => {
if (!/^\s*QTY\d\s*=/.test(ln))
errs.push(`${at}: 연결식 줄 「${ln}」 은 QTYn=식 꼴이 아님`);
});
// IFORM = 수량산출서 파일 바이트(VTS·XLS) — 16MB(0x1000000) 넘으면 저장 불가(0x41c03f)
if (num(r.IFORM_SIZE) > MAX_FILE) errs.push(`${at}: 수량산출서 파일이 너무 큼(최대 16MB)`);
});
return errs;
}
@@ -304,6 +363,10 @@ const StmateQtySheet = (() => {
newSheet,
evalSheet,
assignFromSheet,
floatToStr,
fromWorkbook,
reservedName,
MAX_FILE,
checkQcodes,
nextQcode,
sheetToBoq,