/** * STMATE w 8.0 — 설계내역서(wBoq) 편집 순수 계산 (ui_boq.js) · 화면·DOM 없음(node 시험 가능) * 근거: 지식DB STmate/old/30_원자료/ui_form_catalog.txt(wBoq · wITNum · wExpnd · wM_Rate · wMBoq) * · BOQ11.DBF 실자료(SORTCODE 256 간격 · T 행 ENDCODE = 짝 E/C 행 SORTCODE · E/C 행은 그 역) * 모델 = { rows: dbfData.boq_rows(저장 원천), items: state.boqItems(계산 원천), deleted: [지운 원본 _recno] } * 모든 편집은 rows 와 items 를 함께 고침 — 행 차례·짝 코드는 편집 뒤 renumber 로 다시 매김 */ (function (global) { "use strict"; const STRUCT = /^(T\d|E\d|C\d|:)$/; const DISP_DEFAULT = [ "소계", "계", "합계", "총계", "총합계", "총대계", "광역계", "광역총계", "광역대계", ]; const num = (v) => { const n = parseFloat( String(v == null ? "" : v) .replace(/,/g, "") .trim(), ); return Number.isFinite(n) ? n : 0; }; const codeOf = (r) => String((r && r.BDCODE1) || "").trim(); const isStruct = (r) => STRUCT.test(codeOf(r)); const isLeaf = (r) => Boolean(codeOf(r)) && !isStruct(r); // 행 종류 — T(머리)·E(계)·C(제외금액 계)·:(쪽 넘김)·LEAF(단가코드)·NOTE(주석)·BLANK(빈 줄) function rowKind(r) { const c = codeOf(r); if (/^[TEC]\d$/.test(c)) return { kind: c[0], n: Number(c[1]) }; if (c === ":") return { kind: ":", n: 0 }; if (!c) return { kind: String((r && r.NAME1) || "").trim() ? "NOTE" : "BLANK", n: 0 }; return { kind: "LEAF", n: 0 }; } const dispName = (ini, n) => ((ini || {})[`DISP_N${n}`] || DISP_DEFAULT[n - 1] || "").trim(); const fmtSort = (v) => `${v}.0000`; // 짝 연결 — SORTCODE/ENDCODE 로 머리 ↔ 계 행을 객체끼리 잇기 function linkPairs(rows) { const bySort = new Map(rows.map((r) => [num(r.SORTCODE), r])); const links = new Map(); rows.forEach((r) => { const k = rowKind(r).kind; if (k !== "T" && k !== "E" && k !== "C") return; const p = bySort.get(num(r.ENDCODE)); if (p && p !== r && num(r.ENDCODE) > 0) links.set(r, p); }); return links; } // 차례 다시 매김 — 오름차순이 유지되는 원래 SORTCODE 는 그대로 · 새 행·옮긴 행만 앞뒤 사이 값 // 사이 간격이 0.0001 보다 좁으면 전체를 (i+1)×256 으로 · 짝 행 ENDCODE 갱신 function renumber(rows, links) { const present = new Set(rows); const keep = rows.map(() => false); let last = 0; rows.forEach((r, i) => { const v = num(r.SORTCODE); if (!r._new && v > last) ((keep[i] = true), (last = v)); }); let ok = true; for (let i = 0; i < rows.length && ok;) { if (keep[i]) { i += 1; continue; } let j = i; while (j < rows.length && !keep[j]) j += 1; const lo = i > 0 ? num(rows[i - 1].SORTCODE) : 0; const hi = j < rows.length ? num(rows[j].SORTCODE) : lo + 256 * (j - i + 1); const step = (hi - lo) / (j - i + 1); if (step < 0.0001) ok = false; for (let k = i; k < j && ok; k++) rows[k].SORTCODE = (lo + step * (k - i + 1)).toFixed(4); i = j; } if (!ok) rows.forEach((r, i) => (r.SORTCODE = fmtSort((i + 1) * 256))); rows.forEach((r) => { const p = links.get(r); const end = p && present.has(p) && isStruct(r) ? p.SORTCODE : "0.0000"; if (num(r.ENDCODE) !== num(end) || (p && r.ENDCODE !== end)) r.ENDCODE = end; }); } // 짝 겹침 검사 — 머리가 계보다 앞 · 짝끼리 엇갈리지 않음 function validNesting(rows, links) { const idx = new Map(rows.map((r, i) => [r, i])); const stack = []; for (let i = 0; i < rows.length; i++) { const p = links.get(rows[i]); if (!p || !idx.has(p)) continue; const j = idx.get(p); if (j > i) stack.push(rows[i]); else if (stack.pop() !== p) return false; } return stack.length === 0; } function nextRecno(model) { let min = 0; model.rows.forEach((r) => (min = Math.min(min, num(r._recno)))); (model.items || []).forEach((it) => (min = Math.min(min, num(it._recno)))); return Math.floor(min) - 1; } // 새 행 — 원본 행의 칸 이름을 그대로 두고 수치 칸은 0 function makeRow(model, fields) { const tmpl = model.rows[0] || {}; const row = {}; Object.keys(tmpl).forEach((k) => { if (k.startsWith("_")) return; row[k] = /QTY|AMT|SORTCODE|ENDCODE|^RATE$|^R[LMS]$/.test(k) ? "0" : ""; }); return Object.assign(row, { _recno: nextRecno(model), _new: true, _deleted: false }, fields); } // 계산 원천(items) 맞춤 — 단가코드 행마다 항목 하나 · 행 차례대로 · 제외금액 구역은 관급 갈래 function syncItems(model) { const rows = model.rows, items = model.items; const links = linkPairs(rows); const idx = new Map(rows.map((r, i) => [r, i])); const excl = new Set(); rows.forEach((r, i) => { if (rowKind(r).kind !== "C") return; const p = links.get(r); if (!p || !idx.has(p)) return; const a = Math.min(i, idx.get(p)), b = Math.max(i, idx.get(p)); for (let k = a; k <= b; k++) excl.add(k); }); const byRec = new Map(items.map((it) => [num(it._recno), it])); const out = []; rows.forEach((r, i) => { if (!isLeaf(r)) return; const c = codeOf(r); let it = byRec.get(num(r._recno)); if (!it) { const q = num(r.QTY1); it = { id: `BOQ_N${-num(r._recno)}`, qty: q, changedQty: q, qtys: [q, 0, 0, 0], units: {} }; it._recno = r._recno; it.rowType = "normal"; } Object.assign(it, { code: c, refId: c, itnum: r.ITNUM || "" }); Object.assign(it, { name: r.NAME1 || c, spec: r.SIZE1 || "", unit: r.UNIT1 || "" }); if (excl.has(i)) { if (!/^gov_/.test(it.rowType || "")) it.rowType = String(r.NAME1 || "").includes("수수료") ? "gov_fee" : "gov_mat"; } else it.rowType = "normal"; out.push(it); }); items.splice(0, items.length, ...out); return model; } // 편집 틀 — 짝 연결을 잡고 고친 뒤 겹침 검사 · 차례 다시 매김 · 계산 원천 맞춤 function edit(model, fn) { const before = model.rows.slice(); const marks = before.map((r) => [r, r.BDCODE1, r.ITNUM]); const links = linkPairs(model.rows); const res = fn(model.rows, links) || {}; if (res.error || !validNesting(model.rows, links)) { model.rows.splice(0, model.rows.length, ...before); marks.forEach(([r, c, it]) => ((r.BDCODE1 = c), (r.ITNUM = it))); return { ok: false, error: res.error || "계표시 짝이 엇갈림 — 머리와 계의 차례를 확인" }; } renumber(model.rows, links); syncItems(model); return Object.assign({ ok: true }, res); } // 선택 → 행 번호 목록(오름차순) const indexesOf = (rows, recnos) => { const set = new Set((recnos || []).map(num)); return rows.map((r, i) => (set.has(num(r._recno)) ? i : -1)).filter((i) => i >= 0); }; // 지움 — 머리·계는 짝을 함께 지움(안쪽 행은 남김) function deleteRows(model, recnos) { return edit(model, (rows, links) => { const kill = new Set(indexesOf(rows, recnos).map((i) => rows[i])); [...kill].forEach((r) => links.get(r) && kill.add(links.get(r))); if (!kill.size) return { error: "지울 행을 고르세요" }; kill.forEach((r) => !r._new && model.deleted.push(num(r._recno))); rows.splice(0, rows.length, ...rows.filter((r) => !kill.has(r))); return { count: kill.size }; }); } // 끼워 넣기 — idx 앞에 · pairs = [[머리, 계], …] function insertRows(model, idx, newRows, pairs) { return edit(model, (rows, links) => { (pairs || []).forEach(([a, b]) => (links.set(a, b), links.set(b, a))); const at = Math.max(0, Math.min(idx == null ? rows.length : idx, rows.length)); rows.splice(at, 0, ...newRows); return { at, count: newRows.length }; }); } // 위·아래 이동 — 이어진 선택 묶음을 한 칸씩 function moveRows(model, recnos, dir) { return edit(model, (rows) => { const ix = indexesOf(rows, recnos); if (!ix.length) return { error: "옮길 행을 고르세요" }; const a = ix[0], b = ix[ix.length - 1]; if (b - a + 1 !== ix.length) return { error: "이어진 행만 옮길 수 있음" }; if (dir < 0 ? a === 0 : b === rows.length - 1) return { error: "더 옮길 자리 없음" }; const block = rows.splice(a, ix.length); rows.splice(a + (dir < 0 ? -1 : 1), 0, ...block); return {}; }); } // 복사 묶음 — 머리·계가 들면 그 짝 구간 전체 · 결과는 원본 행 복제 + 짝 번호 function copyRows(model, recnos) { const rows = model.rows, links = linkPairs(rows), idx = new Map(rows.map((r, i) => [r, i])); const take = new Set(indexesOf(rows, recnos)); [...take].forEach((i) => { const p = links.get(rows[i]); if (!p) return; const j = idx.get(p); for (let k = Math.min(i, j); k <= Math.max(i, j); k++) take.add(k); }); const list = [...take].sort((x, y) => x - y); const pos = new Map(list.map((i, n) => [rows[i], n])); const clip = list.map((i) => JSON.parse(JSON.stringify(rows[i]))); const pairs = []; list.forEach((i) => { const p = links.get(rows[i]); if (p && pos.has(p) && pos.get(p) > pos.get(rows[i])) pairs.push([pos.get(rows[i]), pos.get(p)]); }); return { rows: clip, pairs }; } // 붙여넣기 — 복사 묶음을 새 번호로 idx 앞에 · 수량은 그대로 function pasteRows(model, idx, clip) { if (!clip || !clip.rows || !clip.rows.length) return { ok: false, error: "붙여넣을 내용 없음" }; const base = nextRecno(model); const fresh = clip.rows.map((r, k) => { const row = makeRow(model, {}); Object.keys(r).forEach((key) => !key.startsWith("_") && (row[key] = r[key])); row._recno = base - k; return row; }); const pairs = clip.pairs.map(([a, b]) => [fresh[a], fresh[b]]); return insertRows(model, idx, fresh, pairs); } // 계표시 감싸기 — a..b 행을 T{n} 머리와 E{n} 계로 묶음 function wrapGroup(model, a, b, n, title, ini) { const head = makeRow(model, { BDCODE1: `T${n}`, NAME1: title || "" }); const tail = makeRow({ rows: model.rows.concat(head), items: model.items }, {}); Object.assign(tail, { BDCODE1: `E${n}`, ITNUM: dispName(ini, n) }); return edit(model, (rows, links) => { const lo = Math.max(0, Math.min(a, b)), hi = Math.min(rows.length - 1, Math.max(a, b)); links.set(head, tail).set(tail, head); rows.splice(hi + 1, 0, tail); rows.splice(lo, 0, head); return {}; }); } // 계표시 레벨 상향·하향 — 머리·계 짝을 함께 · 계 이름(ITNUM)이 기본 이름이면 새 레벨 이름으로 function shiftLevel(model, recno, delta, ini) { return edit(model, (rows, links) => { const r = rows.find((x) => num(x._recno) === num(recno)); const p = r && links.get(r); if (!r || !p) return { error: "계표시(머리·계) 행을 고르세요" }; const n = rowKind(r).n, m = Math.max(1, Math.min(9, n + delta)); if (m === n) return { error: "더 바꿀 레벨 없음" }; [r, p].forEach((x) => { const k = rowKind(x); if ((k.kind === "E" || k.kind === "C") && String(x.ITNUM || "").trim() === dispName(ini, n)) x.ITNUM = dispName(ini, m); x.BDCODE1 = `${k.kind}${m}`; }); return { level: m }; }); } // 주석 → 계시작 — 주석 행을 T{n} 머리로 · 짝 계는 같은·높은 레벨 머리나 바깥 계 앞에 function noteToGroup(model, recno, n, ini) { const tail = makeRow(model, { BDCODE1: `E${n}`, ITNUM: dispName(ini, n) }); return edit(model, (rows, links) => { const i = rows.findIndex((x) => num(x._recno) === num(recno)); if (i < 0 || rowKind(rows[i]).kind !== "NOTE") return { error: "주석(코드 없는 글) 행을 고르세요" }; const idx = new Map(rows.map((r, k) => [r, k])); let j = i + 1; for (; j < rows.length; j++) { const k = rowKind(rows[j]), p = links.get(rows[j]); if (k.kind === "T" && k.n >= n) break; if ((k.kind === "E" || k.kind === "C") && p && idx.get(p) < i) break; } rows[i].BDCODE1 = `T${n}`; links.set(rows[i], tail).set(tail, rows[i]); rows.splice(j, 0, tail); return {}; }); } // 칸 고침 — 수량은 계산 원천(qty · qtys[0])까지 · 코드는 단가목록(COSTN)에 있는 것만 function setCell(model, recno, field, value, costnByCode) { const r = model.rows.find((x) => num(x._recno) === num(recno)); if (!r) return { ok: false, error: "행 없음" }; if (field === "QTY1") { if (!isLeaf(r)) return { ok: false, error: "수량은 단가코드 행만" }; const v = num(value); r.QTY1 = String(v); const it = model.items.find((x) => num(x._recno) === num(recno)); if (it) { it.qty = v; if (Array.isArray(it.qtys)) it.qtys[0] = v; } return { ok: true }; } if (field === "BDCODE1") { const c = String(value || "") .trim() .toUpperCase(); if (isStruct(r)) return { ok: false, error: "계표시 행 코드는 레벨 단추로 바꿈" }; if (STRUCT.test(c)) return { ok: false, error: "계표시는 [계표시 삽입]으로" }; if (c) { const cr = (costnByCode || {})[c]; if (!cr) return { ok: false, error: `단가목록에 없는 코드: ${c}` }; Object.assign(r, { BDCODE1: c, NAME1: cr.NAME || "", SIZE1: cr.SIZE || "", UNIT1: cr.UNIT || "", }); } else r.BDCODE1 = ""; syncItems(model); return { ok: true }; } if (!["ITNUM", "NAME1", "SIZE1", "UNIT1"].includes(field)) return { ok: false, error: "고칠 수 없는 칸" }; r[field] = String(value == null ? "" : value); syncItems(model); return { ok: true }; } // 레벨 거르기(cBox01 · wMBoq) — n 레벨 이상 계표시 행만 function filterLevel(rows, minLevel) { if (!minLevel) return rows.slice(); return rows.filter((r) => { const k = rowKind(r); return (k.kind === "T" || k.kind === "E" || k.kind === "C") && k.n >= minLevel; }); } // 주공종 집계표(wMBoq) — 머리 행마다 짝 구간 단가코드 금액 합 function groupTotals(evalRows, minLevel) { const links = linkPairs(evalRows), idx = new Map(evalRows.map((r, i) => [r, i])); const out = []; evalRows.forEach((r, i) => { const k = rowKind(r), p = links.get(r); if (k.kind !== "T" || k.n < (minLevel || 1) || !p) return; const j = idx.get(p); const sum = { M: 0, L: 0, S: 0, T: 0 }; for (let q = i + 1; q < j; q++) { if (!isLeaf(evalRows[q])) continue; sum.M += num(evalRows[q].M_AMT1); sum.L += num(evalRows[q].L_AMT1); sum.S += num(evalRows[q].S_AMT1); sum.T += num(evalRows[q].T_AMT1); } out.push({ recno: r._recno, level: k.n, itnum: r.ITNUM || "", name: r.NAME1 || "", closeCode: codeOf(p), sum, }); }); return out; } global.STMATE_UI_BOQ = { STRUCT, num, codeOf, isStruct, isLeaf, rowKind, dispName, linkPairs, renumber, validNesting, nextRecno, makeRow, syncItems, edit, indexesOf, deleteRows, insertRows, moveRows, copyRows, pasteRows, wrapGroup, shiftLevel, noteToGroup, setCell, filterLevel, groupTotals, }; if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_BOQ; })(typeof window !== "undefined" ? window : globalThis);