auto: 2026-11-14 13:24 (EOMSANGDON-HOME)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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""
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user