Files
Aislo/tools/stmate-test/ui_boq.js
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:12:04 +09:00

427 lines
16 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 는 그대로 · 새 행·옮긴 행만 앞뒤 사이 값
// 사이 간격이 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);