- 시험 615 통과 · 브라우저 화면 13개·메뉴 90개 오류 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
/**
|
||
* 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);
|