From a1fcdd311ba6f2243af0e8dd0b84928c35ee31cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 14 Nov 2026 13:24:32 +0900 Subject: [PATCH] auto: 2026-11-14 13:24 (EOMSANGDON-HOME) --- tools/stmate-test/api_qty.py | 46 +++++++++++ tools/stmate-test/qty_qcode.py | 38 ++++++--- tools/stmate-test/qty_sheetfile.py | 118 +++++++++++++++++++++++++++ tools/stmate-test/scr_qty_list.js | 67 +++++++++++++++- tools/stmate-test/scr_qty_sheet.js | 123 ++++++++++++++++++++++------- 5 files changed, 348 insertions(+), 44 deletions(-) create mode 100644 tools/stmate-test/qty_sheetfile.py diff --git a/tools/stmate-test/api_qty.py b/tools/stmate-test/api_qty.py index 639c53ffe..a6c02aad4 100644 --- a/tools/stmate-test/api_qty.py +++ b/tools/stmate-test/api_qty.py @@ -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): + """모든 수량산출서를 「수량산출서」 폴더에 .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 diff --git a/tools/stmate-test/qty_qcode.py b/tools/stmate-test/qty_qcode.py index afecb53c6..da5d3885f 100644 --- a/tools/stmate-test/qty_qcode.py +++ b/tools/stmate-test/qty_qcode.py @@ -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바이트 ). +SIxMemo_구조(메모 칸 10바이트 ) · +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("= 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 diff --git a/tools/stmate-test/qty_sheetfile.py b/tools/stmate-test/qty_sheetfile.py new file mode 100644 index 000000000..4d1e725c3 --- /dev/null +++ b/tools/stmate-test/qty_sheetfile.py @@ -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: <작업 폴더>\\수량산출서\\.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 있는 행마다 (.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"" diff --git a/tools/stmate-test/scr_qty_list.js b/tools/stmate-test/scr_qty_list.js index ca5243a56..d55cd8646 100644 --- a/tools/stmate-test/scr_qty_list.js +++ b/tools/stmate-test/scr_qty_list.js @@ -110,8 +110,12 @@ ${ sel >= 0 && qrows[sel] ? `
-
${esc(qrows[sel].CODE)} 연결식(IFORM · QTYn=셀별명 식)
- +
${esc(qrows[sel].CODE)} 수량산출파일(IFORM · 원본 VTS·XLS 그대로)
+
${qrows[sel].IFORM_SIZE ? `${esc(String(qrows[sel].IFORM_KIND || "").toUpperCase())} · ${Number(qrows[sel].IFORM_SIZE).toLocaleString()} 바이트` : "저장된 수량산출파일 없음"} + + + +
확장 수량변수(XCODE · 이름=값)
수량변수 산근에 전송 — 산출근거에 넣을 글
@@ -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 diff --git a/tools/stmate-test/scr_qty_sheet.js b/tools/stmate-test/scr_qty_sheet.js index 0b285dbf8..e7f4ffe81 100644 --- a/tools/stmate-test/scr_qty_sheet.js +++ b/tools/stmate-test/scr_qty_sheet.js @@ -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,