- 보고서: 원본 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>
427 lines
16 KiB
JavaScript
427 lines
16 KiB
JavaScript
/**
|
||
* 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);
|