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

357 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 — 내역서 도구 대화상자 순수 계산 (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 「보호된 견적단가 수정안함」(기본 켬) — 견적단가(W) 행 중 보호(UC=T) 행은 건너뜀
* 계산 원천 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 && codeOf(r)[0] === "W" && String(r.UC || "").trim() === "T")
return void kept++;
const all = num(rates.all),
m = mode === "all" ? all : num(rates.M),
l = mode === "all" ? all : num(rates.L),
s = 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);