/* -*- 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 · 없는 이름 오류) */ 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"; // 열 이름 ↔ 번호(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 }; } /** * 기본 수량변수 ← 확장 수량변수 — 수량코드 행 IFORM 의 「QTYn=식」 줄을 셀별명으로 셈해 QTYn 에 넣고 * XCODE 에 셀별명 값 전부를 「이름=값」 줄로 남김(방금 저장된 수량산출서의 확장수량변수 값) */ function assignFromSheet(qrows, sheet) { 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); } row.XCODE = xcode; return row; }); return { rows: out, errors, sheetErrors: ev.errors }; } // 수량코드 검사(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}」 은 이름=값 꼴이 아님`); }); String(r.IFORM || "") .split(/\r?\n/) .filter((ln) => ln.trim()) .forEach((ln) => { if (!/^\s*QTY\d\s*=/.test(ln)) errs.push(`${at}: 연결식 줄 「${ln}」 은 QTYn=식 꼴이 아님`); }); }); 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, 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;