382 lines
15 KiB
JavaScript
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;
|