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

374 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = (i+1)×256 · 짝 행 ENDCODE 갱신
function renumber(rows, links) {
const present = new Set(rows);
rows.forEach((r, i) => (r.SORTCODE = fmtSort((i + 1) * 256)));
rows.forEach((r) => {
const p = links.get(r);
r.ENDCODE = p && present.has(p) && isStruct(r) ? p.SORTCODE : "0.0000";
});
}
// 짝 겹침 검사 — 머리가 계보다 앞 · 짝끼리 엇갈리지 않음
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);