Files
Aislo/tools/stmate-test/ui_boq_tools.js
T
eomsangdonandClaude Opus 5.5 5847580670 feat(stmate-test): REA 보강 — 실행·계약·변경 차수·잔여 계산 원본 규칙 · 장부 91.1%
- 지식DB REA 분석 32 실행계약 · 34 변경차수 잔여계산 작업자료(정적분석 · 원본 실행·수정 없음)
- 실행: 중기 실행단가 합계·성분 비례·끝전·차액 성분 · 실행단가·수량 할증 · 계약 변환 옵션·적용제외 · 기성 공사 낙찰율 100%
- 변경: 차수 목록·이어 셈 · 보험료 3모드(변경 2차 이후) · 방법 A/B 5항목 · 퇴직공제·건설기계·표준시장단가·노임 환산 갈래·JCD
- 시험 693 통과 · 브라우저 3건 화면 전수 오류 0 · 총공사비 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 09:29:53 +09:00

360 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 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd(wM_Expnd·wM_Expnd10) · wM_Rate · wBoqRate
* 근거: 지식DB STmate/old/30_원자료/ui_form_catalog.txt
* wITNum RdoFirstChar(공란/./(/-/추가입력) · RdoLastChar(공란/./)) · CBoxType(가..하 / a..z) · CBoxLeft(여백 0~7)
* Fmt_Typ(0/00/000) · cBx_itNum_B(직전공종 번호 / 길이만큼 여백) · cBx_OnlyIT(대상) · cBx_Bl_Lev(초기화 레벨)
* wExpnd RGrpToT(소계/계/합계) · CMBoxUpDn(절삭/반올림) · CMBoxDot(소수 4~0위)
* wM_Rate rd_Qty1(내역서 전체/선택 항목) · wcBx_Pnt1(절사/반올림) · wcBx_Pnt2(소수 0~4위)
* wBoqRate Rdo(전체 같은 비율 / 비목별) — 내역 행 RATE·RM·RL·RS 칸
* 구성 자료 = BDQTY.DBF(bdqty.py 복호) · 가..하 순서는 14자 첫 묶음만 화면 근거, 그 뒤 모음 차례는 추정
*/
(function (global) {
"use strict";
const B =
global.STMATE_UI_BOQ || (typeof require !== "undefined" ? require("./ui_boq.js") : null);
const { num, rowKind, codeOf, isLeaf } = B;
const KO_VOWEL = [0, 4, 8, 13, 2, 6, 12, 17, 18, 20]; // ㅏ ㅓ ㅗ ㅜ ㅑ ㅕ ㅛ ㅠ ㅡ ㅣ (중성 번호)
const HEAD_IDX = [0, 2, 3, 5, 6, 7, 9, 11, 12, 14, 15, 16, 17, 18]; // 초성 번호
// 번호 글자 — num(0/00/000 자리 채움) · ko(가..하 140개) · en(a..z, a1..z9 260개)
function seqText(i, type, pad) {
if (type === "ko") {
if (i < 1 || i > 140) return "";
const k = i - 1;
return String.fromCharCode(
0xac00 + (HEAD_IDX[k % 14] * 21 + KO_VOWEL[Math.floor(k / 14)]) * 28,
);
}
if (type === "en") {
if (i < 1 || i > 260) return "";
const k = i - 1,
g = Math.floor(k / 26);
return String.fromCharCode(97 + (k % 26)) + (g ? String(g) : "");
}
return String(i).padStart(Math.max(1, Number(pad) || 1), "0");
}
/**
* 공종번호 자동입력(wITNum)
* opts = { level: 대상 계표시 레벨(1~9) · target: both|group|leaf · first · last · type · pad · margin
* prefix: parent|space|none · resetLevel: 0(이어서) 또는 레벨 n(그 이상 머리에서 처음부터)
* range: [시작 행, 끝 행] 선택 범위(없으면 전체) }
*/
function autoItnum(rows, opts) {
if (opts && opts.tree) return treeItnum(rows, opts);
const o = Object.assign(
{ level: 2, target: "group", first: "", last: ".", type: "num", pad: 1, margin: 0 },
{ prefix: "none" },
opts || {},
);
const lo = o.range ? o.range[0] : 0,
hi = o.range ? o.range[1] : rows.length - 1;
const links = B.linkPairs(rows);
const idx = new Map(rows.map((r, i) => [r, i]));
const open = []; // 열린 머리 행 묶음(바깥 → 안)
let cnt = 0,
changed = 0;
const wantGroup = o.target !== "leaf",
wantLeaf = o.target !== "group";
// 초기화 레벨 — 기본은 대상 바로 위 레벨 머리마다(단가코드 대상은 모든 머리마다) · 0 = 이어서
const resetAt =
o.resetLevel === 0
? 0
: Number(o.resetLevel) || (o.target === "leaf" ? 1 : Number(o.level) + 1);
for (let i = 0; i < rows.length; i++) {
const r = rows[i],
k = rowKind(r);
while (open.length && idx.get(links.get(open[open.length - 1])) < i) open.pop();
const parent = [...open].reverse().find((h) => rowKind(h).n > (k.kind === "T" ? k.n : 0));
if (k.kind === "T" && resetAt && k.n >= resetAt) cnt = 0;
const hit =
i >= lo &&
i <= hi &&
((wantGroup && k.kind === "T" && k.n === Number(o.level)) ||
(wantLeaf && k.kind === "LEAF"));
if (hit) {
cnt += 1;
let pre = "";
const pnum = parent ? String(parent.ITNUM || "").trim() : "";
if (o.prefix === "parent") pre = o.first === "." ? pnum.replace(/\.$/, "") : pnum;
else if (o.prefix === "space") pre = " ".repeat(pnum.length);
const firstCh = o.first === "custom" ? String(o.firstText || "") : o.first;
r.ITNUM =
" ".repeat(Number(o.margin) || 0) + pre + firstCh + seqText(cnt, o.type, o.pad) + o.last;
changed += 1;
}
if (k.kind === "T" && links.get(r)) open.push(r);
}
return { changed };
}
/**
* 하위 전체공종 반영(cBx_Bl_Lev 첫 선택) — 대상 레벨 머리(뿌리)부터 그 안의 공종(계표시)·단가코드를 묶음마다 한 차례로
* 뿌리 = 여백 + 첫 글자 + 번호 + 끝 글자 · 번호는 바로 위 머리마다 처음부터
* 하위 = 직전공종 번호(끝 「.」 뗌) + 「.」 + 번호(cBx_itNum_B 직전공종의 공종번호) · maxDepth = 번호 마디 수 한도
* o.range = [시작 행, 끝 행] 선택 범위 — 범위 밖 행은 그대로(번호 차례는 범위 안에서만 셈)
*/
function treeItnum(rows, opts) {
const o = Object.assign(
{ level: 3, target: "both", first: "", last: "", type: "num", pad: 1, margin: 0 },
opts || {},
);
const lo = o.range ? o.range[0] : 0,
hi = o.range ? o.range[1] : rows.length - 1;
const links = B.linkPairs(rows);
const idx = new Map(rows.map((r, i) => [r, i]));
const open = [];
const cnt = new Map(); // 부모 머리(또는 뿌리 위 머리) → 지금 번호
const depth = new Map(); // 번호 붙은 머리 → 마디 수
const wantGroup = o.target !== "leaf",
wantLeaf = o.target !== "group";
const lv = Number(o.level);
let changed = 0;
for (let i = 0; i < rows.length; i++) {
const r = rows[i],
k = rowKind(r);
while (open.length && idx.get(links.get(open[open.length - 1])) < i) open.pop();
const isT = k.kind === "T";
if (i >= lo && i <= hi && (isT || k.kind === "LEAF")) {
const root = [...open].reverse().find((h) => rowKind(h).n === lv);
const par = open.length ? open[open.length - 1] : null;
const isRoot = isT && k.n === lv;
const inTree = !isRoot && root && depth.has(par);
const want = isT ? wantGroup : wantLeaf;
if ((isRoot || inTree) && (want || isT)) {
const key = isRoot ? par || "top" : par;
const n = (cnt.get(key) || 0) + 1;
const d = isRoot ? 1 : depth.get(par) + 1;
if (!o.maxDepth || d <= Number(o.maxDepth)) {
cnt.set(key, n);
const seq = seqText(n, o.type, o.pad);
const text = isRoot
? " ".repeat(Number(o.margin) || 0) + (o.first || "") + seq + (o.last || "")
: String(par.ITNUM || "")
.trim()
.replace(/\.$/, "") +
"." +
seq;
if (want) {
r.ITNUM = text;
changed += 1;
}
if (isT) depth.set(r, d);
}
}
}
if (isT && links.get(r)) open.push(r);
}
return { changed };
}
// 반올림·절사 — 소수 d 자리
function roundTo(v, d, mode) {
const f = Math.pow(10, Number(d) || 0);
const x = Number((v * f).toFixed(6));
return (mode === "round" ? Math.round(x) : Math.trunc(x)) / f;
}
/**
* 일위대가 풀기(wExpnd · wM_Expnd · wM_Expnd10) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈
* 구성 수량 = 구성표 수량 × 할증(노무·재료) × 내역 수량 → 비목별(M·L·X·기타 W) 절삭/반올림 소수 d 위
* o = { level(0 = 입력안함 · 감싸지 않음), mode, digits, per: { M|L|X|W: { mode, digits } }, hal: { L, M },
* keepName(공종의 명칭/규격 입력), adds: [{ pct, prin, prout, name }](손료계산 추가입력 → PR 비율 줄) }
* 비율(PR)·% 단위·구분(T/C) 구성 줄은 풀지 않고 개수만 알림 · 추가 줄의 밑수 = 풀린 줄(PRPT 표시)
*/
function expandIlwi(model, recno, bdqtyRows, costnByCode, opts, ini) {
const o = Object.assign(
{ level: 1, mode: "trunc", digits: 4, per: {}, hal: {}, keepName: true, adds: [] },
opts || {},
);
const row = model.rows.find((x) => num(x._recno) === num(recno));
if (!row || !isLeaf(row)) return { ok: false, error: "풀 일위대가 행을 고르세요" };
const code = codeOf(row);
if (code[0] !== "B") return { ok: false, error: `일위대가(B) 코드만 풀 수 있음 — ${code}` };
const comps = (bdqtyRows || []).filter((r) => String(r.BDCODE || "").trim() === code);
if (!comps.length) return { ok: false, error: `${code} 구성표(BDQTY) 없음` };
const Q = num(row.QTY1) || 1; // 실행수량 0 이면 1(wM_Expnd 안내)
const adds = (o.adds || []).filter((a) => num(a.pct) && a.prin && a.prout);
const made = [];
let skipped = 0;
comps.forEach((c) => {
const cc = String(c.CODE || "").trim(),
cr = costnByCode[cc];
const unit = cr ? String(cr.UNIT || "").trim() : "";
if (!cr || cc === "PR" || /^[TC]\d$/.test(cc) || /%$/.test(unit)) return void (skipped += 1);
const k = "MLX".includes(cc[0]) ? cc[0] : "W";
const r = Object.assign({ mode: o.mode, digits: o.digits }, (o.per || {})[k] || {});
const h = num((o.hal || {})[cc[0]]) || 1;
made.push({
code: cc,
name: cr.NAME || "",
spec: cr.SIZE || "",
unit,
qty: roundTo(num(c.QTY1) * h * Q, r.digits, r.mode),
});
});
if (!made.length) return { ok: false, error: "풀 수 있는 구성 자원 없음" };
const at = model.rows.indexOf(row);
const wrap = Number(o.level) > 0;
const head = wrap
? B.makeRow(model, {
BDCODE1: `T${o.level}`,
NAME1: o.keepName ? row.NAME1 || "" : "",
SIZE1: o.keepName ? row.SIZE1 || "" : "",
})
: null;
if (head) head.ITNUM = row.ITNUM || "";
let rec = head ? num(head._recno) : B.nextRecno(model) + 1;
const body = made.map((m) => {
const r = B.makeRow(model, { BDCODE1: m.code, NAME1: m.name, SIZE1: m.spec, UNIT1: m.unit });
Object.assign(r, { QTY1: String(m.qty), _recno: (rec -= 1) });
if (adds.length) r.PRPT = "1";
return r;
});
const prs = adds.map((a) => {
const r = B.makeRow(model, { BDCODE1: "PR", NAME1: a.name || "손료", UNIT1: "%" });
Object.assign(r, {
QTY1: String(num(a.pct)),
PRIN: String(a.prin),
PROUT: String(a.prout),
_recno: (rec -= 1),
});
return r;
});
const tail = wrap
? B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) })
: null;
if (tail) tail._recno = rec - 1;
const res = B.edit(model, (rows, links) => {
if (wrap) links.set(head, tail).set(tail, head);
rows.splice(at, 1, ...(wrap ? [head] : []), ...body, ...prs, ...(wrap ? [tail] : []));
if (!row._new) model.deleted.push(num(row._recno));
return { count: body.length + prs.length, skipped };
});
// 계산 원천 — 비율 줄·밑수 표시(calc.js prRow · PRPT)
if (res.ok && adds.length) {
const byRec = new Map((model.items || []).map((it) => [num(it._recno), it]));
body.forEach((r) => byRec.get(num(r._recno)) && (byRec.get(num(r._recno)).PRPT = "1"));
prs.forEach((r) => {
const it = byRec.get(num(r._recno));
if (it)
Object.assign(it, {
PRIN: r.PRIN,
PROUT: r.PROUT,
PRPT: "0",
prQtys: [num(r.QTY1), 0, 0, 0],
});
});
}
return res;
}
// 수량 단수 보정(wM_Rate 수량) — 범위 행의 단가코드 수량을 절사/반올림
function roundQty(model, recnos, mode, digits) {
const set = recnos ? new Set(recnos.map(num)) : null;
let changed = 0;
model.rows.forEach((r) => {
if (!isLeaf(r) || (set && !set.has(num(r._recno)))) return;
const v = num(r.QTY1),
w = roundTo(v, digits, mode);
if (w === v) return;
B.setCell(model, r._recno, "QTY1", w);
changed += 1;
});
return { changed };
}
/**
* 비목별 단가 끝전 보정(wM_Rate wcBx_Pnt1_M·_L … · rd_Amt1/rd_Amt2 범위) — 지금 행 단가(재·노·경)를 비목마다 절사/반올림
* units = { recno: { M, L, S } }(계산 결과 행 단가) · 계산 원천 항목 fixedUnit · 저장 원천 MAMT1·LAMT1·SAMT1·TAMT1
*/
function roundUnits(model, recnos, units, per) {
const set = recnos ? new Set(recnos.map(num)) : null;
const byRec = new Map(model.items.map((it) => [num(it._recno), it]));
let changed = 0;
model.rows.forEach((r) => {
const u = (units || {})[num(r._recno)];
if (!isLeaf(r) || !u || (set && !set.has(num(r._recno)))) return;
const v = {};
["M", "L", "S"].forEach((c) => {
const o = (per || {})[c];
v[c] = o ? roundTo(num(u[c]), o.digits, o.mode) : num(u[c]);
});
if (["M", "L", "S"].every((c) => v[c] === num(u[c]))) return;
Object.assign(r, {
MAMT1: String(v.M),
LAMT1: String(v.L),
SAMT1: String(v.S),
TAMT1: String(v.M + v.L + v.S),
});
const it = byRec.get(num(r._recno));
if (it) it.fixedUnit = v;
changed += 1;
});
return { changed };
}
/**
* 할증율 적용(wBoqRate · ST_Boq.CPM TwBoqRate DFM) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별, N7.3)
* 할증율 = 기준단가(할증전 단가)에 곱할 백분율 · 「할증율이 0%인경우 100%로 적용됨」 → 0 = 그대로
* CBoxNotUC 「보호된 견적단가 수정안함」(기본 켬) — 보호(UC=T) 행은 비목 가리지 않고 건너뜀(ST_Boq 0x507cc8)
* 여러 행 적용 = 코드 첫 글자 B·D·L·M·S·W·X 행만 입력 율 · 그 밖 행은 율 0(=100%)(0x507cc8)
* 원본 계산(0x4e9ae6) = 칸 1 단가 = 원 미만 버림(칸 2 기준단가 × 율/100) · 율은 4바이트 실수(float32)로 보관 —
* 70%·115% 등에서 1원 적게 나올 수 있음(조건부 · 실물 표본 없음 · 여기서는 십진 계산 유지) — 지식DB REA 34
* 계산 원천 halRate = 할증율 − 100(calc.js 가산식 × (1 + p/100) = × 할증율/100)
* o.base(코드) = 기준단가 {M,L,S}(COSTN 1칸) — 주면 행 1칸 단가도 비목별 원 미만 버림으로 씀(저장 반영)
*/
function applyHalRate(model, recnos, rates, mode, o) {
const opt = Object.assign({ notUC: true }, o || {});
const set = recnos ? new Set(recnos.map(num)) : null;
const byRec = new Map(model.items.map((it) => [num(it._recno), it]));
const add = (v) => (num(v) > 0 ? num(v) - 100 : 0);
let changed = 0,
kept = 0;
model.rows.forEach((r) => {
if (!isLeaf(r) || (set && !set.has(num(r._recno)))) return;
if (opt.notUC && ["T", "1"].includes(String(r.UC || "").trim())) return void kept++;
const on = "BDLMSWX".includes(codeOf(r)[0] || "?") ? 1 : 0;
const all = on * num(rates.all),
m = on * (mode === "all" ? all : num(rates.M)),
l = on * (mode === "all" ? all : num(rates.L)),
s = on * (mode === "all" ? all : num(rates.S));
Object.assign(r, {
RATE: String(mode === "all" ? all : 0),
RM: String(m),
RL: String(l),
RS: String(s),
});
const it = byRec.get(num(r._recno));
if (it) it.halRate = { M: add(m), L: add(l), S: add(s) };
const b = opt.base && opt.base(codeOf(r));
if (b) {
const u = { M: m, L: l, S: s };
["M", "L", "S"].forEach(
(c) =>
(r[`${c}AMT1`] = String(
Math.trunc((num(b[c]) * (u[c] > 0 ? u[c] : 100)) / 100 + 1e-9),
)),
);
r.TAMT1 = String(num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1));
}
changed += 1;
});
return { changed, kept };
}
global.STMATE_UI_BOQ_TOOLS = {
seqText,
autoItnum,
treeItnum,
roundTo,
expandIlwi,
roundQty,
roundUnits,
applyHalRate,
};
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_BOQ_TOOLS;
})(typeof window !== "undefined" ? window : globalThis);