Files
Aislo/tools/stmate-test/scr_qty_sheet.js
T

382 lines
15 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 수량산출서 계산 (scr_qty_sheet.js) — 시트 셈 · 셀별명(확장 수량변수) · 수량코드(기본 수량변수) ·
* 내역 가져가기 · 코드 종류별 수량 · 내역↔BDQTY 수량 연결(자원 역집계) · 순수 계산
* 근거: 지식DB STmate/old/20_분석 28 §2(기본수량변수 ↔ 확장수량변수(셀별명) · 전송 · 수량산출서 파일) ·
* 02(QCODE CODE·NAME·BQCODE·QTY0~9·IFORM·XCODE) · 10(ST_U_ConBD CON_QTY · BOQ↔BDQTY) ·
* 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 = () =>
(typeof window !== "undefined" && window.STMATE_SMT_EXPR) ||
(typeof require !== "undefined" ? require("./scr_edit_expr.js") : null);
const num = (v) => {
const n = parseFloat(String(v == null ? "" : v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const SHEET_SIG = "STMATE-QTY-SHEET/1";
const MAX_FILE = 0x1000000; // 수량산출서 파일 최대 16MB
// 열 이름 ↔ 번호(A=0)
const colName = (i) =>
i < 26 ? String.fromCharCode(65 + i) : colName(Math.floor(i / 26) - 1) + colName(i % 26);
const colIndex = (s) =>
[...String(s).toUpperCase()].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
const addr = (r, c) => `${colName(c)}${r + 1}`;
const parseAddr = (a) => {
const m = String(a)
.toUpperCase()
.match(/^([A-Z]{1,2})(\d+)$/);
return m ? { r: Number(m[2]) - 1, c: colIndex(m[1]) } : null;
};
function newSheet(rows = 30, cols = 8) {
return { sig: SHEET_SIG, rows, cols, cells: {}, aliases: {}, roles: {}, start: 0, end: 0 };
}
/**
* 시트 셈 — 「=식」 칸은 셀 주소(A1)·셀별명을 변수로 평가 · 순환·오류 칸은 errors
* 돌려줌: { values: {A1: 수 또는 글자}, errors: {A1: 글}, aliasValues: {별명: 수} }
*/
function evalSheet(sheet) {
const values = {},
errors = {},
state = {};
const aliasOf = {};
for (const [nm, a] of Object.entries(sheet.aliases || {}))
aliasOf[nm.toUpperCase()] = String(a).toUpperCase();
const cellOf = (name) => aliasOf[name] || (parseAddr(name) ? name : null);
function calc(a) {
if (state[a] === 2) return values[a];
if (state[a] === 1) {
errors[a] = "셀참조 순환";
return 0;
}
state[a] = 1;
const raw = String((sheet.cells || {})[a] ?? "").trim();
let v;
if (raw.startsWith("=")) {
const vars = {};
const ids = (raw.slice(1).match(/[A-Za-z_가-힣][A-Za-z_0-9가-힣]*/g) || []).map((s) =>
s.toUpperCase(),
);
for (const id of ids) {
const at = cellOf(id);
if (at && !(id in vars)) {
const sub = calc(at);
if (errors[at]) errors[a] = errors[a] || `셀참조 에러(${id})`;
vars[id] = typeof sub === "number" ? sub : num(sub);
}
}
const r = X().evaluate(raw.slice(1), vars);
if (r.error) errors[a] = r.error;
else if (r.missing.length) errors[a] = `없는 셀별명 「${r.missing[0]}」`;
v = r.value === null ? 0 : r.value;
} else if (raw !== "" && /^[-+]?[\d,]*\.?\d+$/.test(raw)) v = num(raw);
else v = raw;
values[a] = v;
state[a] = 2;
return v;
}
for (const a of Object.keys(sheet.cells || {})) calc(a.toUpperCase());
const aliasValues = {};
for (const [nm, a] of Object.entries(aliasOf)) {
const v = calc(a);
aliasValues[nm] = typeof v === "number" ? v : num(v);
if (errors[a]) errors[`@${nm}`] = errors[a];
}
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)));
}
/**
* 확장수량변수 읽기(ReadDefName · STmateQty8 0x41b200) — 수량산출서 저장·불러오기 직후 그 코드 한 행에
* · 셀별명마다(인쇄 영역·내역 전송 이름 빼고) XCODE += 「이름=FloatToStr(값)」+CRLF
* · 이름이 QTYn 꼴이면 같은 이름 칸(QTY0~9)에 값 · 없는 칸이면 「확장수량변수 읽어오기 실패」
* code 를 주면 그 행만, 없으면 모든 행(같은 시트를 쓰는 경우)
*/
function assignFromSheet(qrows, sheet, code) {
const ev = evalSheet(sheet);
const errors = [];
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} — 확장수량변수 읽어오기 실패(없는 칸)` });
}
}
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, 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 줄
function checkQcodes(qrows, costnCodes) {
const errs = [];
const seen = new Set();
const codes = costnCodes instanceof Set ? costnCodes : new Set(costnCodes || []);
qrows.forEach((r, i) => {
const c = String(r.CODE || "")
.trim()
.toUpperCase();
const at = `${i + 1}행 ${c || "(빈 코드)"}`;
if (!/^Q\d{5}$/.test(c)) errs.push(`${at}: 수량코드는 Q + 숫자 다섯 자리`);
if (seen.has(c)) errs.push(`${at}: 같은 수량코드가 이미 있음`);
seen.add(c);
const bq = String(r.BQCODE || "").trim();
if (bq && codes.size && !codes.has(bq)) errs.push(`${at}: 내역코드 ${bq} 는 단가목록에 없음`);
for (let k = 0; k < 10; k++) {
const v = String(r[`QTY${k}`] ?? "").trim();
if (v && !/^[-+]?[\d,]*\.?\d+$/.test(v))
errs.push(`${at}: QTY${k} 값 「${v}」 은 숫자가 아님`);
}
String(r.XCODE || "")
.split(/\r?\n/)
.filter((ln) => ln.trim())
.forEach((ln) => {
if (!/^\s*[A-Za-z가-힣_][^=]*=/.test(ln))
errs.push(`${at}: 확장수량변수 줄 「${ln}」 은 이름=값 꼴이 아님`);
});
// IFORM = 수량산출서 파일 바이트(VTS·XLS) — 16MB(0x1000000) 넘으면 저장 불가(0x41c03f)
if (num(r.IFORM_SIZE) > MAX_FILE) errs.push(`${at}: 수량산출서 파일이 너무 큼(최대 16MB)`);
});
return errs;
}
// 다음 수량코드 번호
function nextQcode(qrows) {
const max = qrows.reduce((m, r) => Math.max(m, num(String(r.CODE || "").slice(1))), 0);
return "Q" + String(max + 1).padStart(5, "0");
}
/**
* 내역서로 가져가기 — 시작~끝 줄(작은 쪽부터 양 끝 포함) · mode code = 내역코드 칸 · name = 명칭 칸
* 돌려줌 [{ row, code, name, size, unit, qty, M, L, S, T, itnum }] · 단가 T = M+L+S(수량 곱 없음)
*/
function sheetToBoq(sheet, mode = "code") {
const ev = evalSheet(sheet);
const roles = sheet.roles || {};
const cell = (role, r) =>
roles[role] === undefined || roles[role] === ""
? ""
: ev.values[addr(r, colIndex(roles[role]))];
const need = mode === "code" ? ["code", "qty"] : ["name", "size", "unit", "qty"];
const missing = need.filter((k) => roles[k] === undefined || roles[k] === "");
if (missing.length) return { items: [], error: `셀별명 설정 안 됨: ${missing.join(", ")}` };
const a = Math.min(sheet.start, sheet.end),
b = Math.max(sheet.start, sheet.end);
const items = [];
for (let r = a; r <= b; r++) {
const code = String(cell("code", r) ?? "").trim();
const name = String(cell("name", r) ?? "").trim();
if (mode === "code" ? !code : !name) continue;
const M = num(cell("mamt", r)),
L = num(cell("lamt", r)),
S = num(cell("samt", r));
items.push({
row: r + 1,
code,
name,
size: String(cell("size", r) ?? ""),
unit: String(cell("unit", r) ?? ""),
qty: num(cell("qty", r)),
M,
L,
S,
T: M + L + S,
itnum: String(cell("no", r) ?? ""),
});
}
return { items, error: null };
}
// 코드 종류(wQtyList · wM_JabPum cBoxCode) — 모든단가 · 노무 · 재료 · 경비 · 일위대가 · 중기 · 단가산출 · 견적 · 대가산근
const KINDS = [
["ALL", "모든단가", () => true],
["L", "노무비", (c) => c[0] === "L"],
["M", "재료비", (c) => c[0] === "M"],
["S", "경비", (c) => c[0] === "S"],
["B", "일위대가", (c) => c[0] === "B"],
["X", "중기비", (c) => c[0] === "X"],
["D", "단가산출", (c) => c[0] === "D"],
["W", "견적단가", (c) => c[0] === "W"],
["BD", "대가,산근", (c) => c[0] === "B" || c[0] === "D"],
];
const STRUCT = /^[TCE]\d*$/;
// 코드 종류별 내역 수량(F3-24) — 내역 단가코드 행의 수량을 코드별로 모음
function qtyByCode(boqRows, kind = "ALL", field = "QTY1") {
const test = (KINDS.find((k) => k[0] === kind) || KINDS[0])[2];
const by = {};
for (const r of boqRows || []) {
const c = String(r.BDCODE1 || "").trim();
if (!c || STRUCT.test(c) || !test(c)) continue;
const o = (by[c] = by[c] || {
code: c,
name: r.NAME1 || "",
size: r.SIZE1 || "",
unit: r.UNIT1 || "",
qty: 0,
rows: 0,
});
o.qty += num(r[field]);
o.rows += 1;
}
return Object.values(by).sort((a, b) => a.code.localeCompare(b.code));
}
/**
* 내역↔BDQTY 수량 연결(F3-28 · ST_U_ConBD CON_QTY) — 내역 수량 × 구성 수량을 하위로 펼쳐
* 기초 자원(L·M·S)과 중간 호표(B·D·X)의 소요 수량을 모음 · 비율(PR·RR)·구분(T·C) 줄은 건너뜀
* 돌려줌 { totals: {코드: 수량}, cycles: [경로] }
*/
function rollup(boqRows, bdqtyRows, field = "QTY1") {
const groups = {};
for (const r of bdqtyRows || [])
(groups[String(r.BDCODE || "").trim()] = groups[String(r.BDCODE || "").trim()] || []).push(r);
const totals = {},
cycles = [];
const qOf = (row) => {
const q = num(row.QTY1);
return ["T", "t", "1", true].includes(row.DEVD1) ? (q ? 1 / q : 0) : q;
};
const walk = (code, qty, path) => {
totals[code] = (totals[code] || 0) + qty;
const g = groups[code];
if (!g) return;
if (path.includes(code)) return cycles.push([...path, code].join(" → "));
for (const row of g) {
const c = String(row.CODE || "").trim();
if (!c || /^(PR|RR)$/.test(c) || STRUCT.test(c)) continue;
walk(c, qty * qOf(row), [...path, code]);
}
};
for (const r of boqRows || []) {
const c = String(r.BDCODE1 || "").trim();
if (!c || STRUCT.test(c)) continue;
const q = num(r[field]);
if (q) walk(c, q, []);
}
return { totals, cycles };
}
// 수량산출서 파일 — 서명 검사(호환되지 않는 파일형식 알림)
function serialize(sheet, qrows) {
return JSON.stringify({ sig: SHEET_SIG, sheet, qcode: qrows || [] });
}
function deserialize(text) {
let d;
try {
d = JSON.parse(text);
} catch (_e) {
return { error: "수량산출서로 저장하려는 파일이 손상되었거나 호환되지 않는 파일형식" };
}
if (!d || d.sig !== SHEET_SIG || !d.sheet || typeof d.sheet.cells !== "object")
return { error: "호환되지 않는 파일형식 — STMATE 수량산출서가 아님" };
return {
sheet: Object.assign(newSheet(), d.sheet),
qcode: Array.isArray(d.qcode) ? d.qcode : [],
};
}
return {
SHEET_SIG,
KINDS,
colName,
colIndex,
addr,
parseAddr,
newSheet,
evalSheet,
assignFromSheet,
floatToStr,
fromWorkbook,
reservedName,
MAX_FILE,
checkQcodes,
nextQcode,
sheetToBoq,
qtyByCode,
rollup,
serialize,
deserialize,
};
})();
if (typeof window !== "undefined") window.STMATE_QTY_SHEET = StmateQtySheet;
if (typeof module !== "undefined" && module.exports) module.exports = StmateQtySheet;