Files
Aislo/tools/stmate-test/ui_boq_tools.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

194 lines
8.4 KiB
JavaScript

/**
* STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd · 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) {
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 };
}
// 반올림·절사 — 소수 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) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈
* 구성 수량 = 구성표 수량 × 내역 수량 → 절삭/반올림 소수 d 위 · 비율(PR)·% 단위·구분(T/C) 줄은 풀지 않고 개수만 알림
*/
function expandIlwi(model, recno, bdqtyRows, costnByCode, opts, ini) {
const o = Object.assign({ level: 1, mode: "trunc", digits: 4 }, 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);
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);
made.push({
code: cc,
name: cr.NAME || "",
spec: cr.SIZE || "",
unit,
qty: roundTo(num(c.QTY1) * Q, o.digits, o.mode),
});
});
if (!made.length) return { ok: false, error: "풀 수 있는 구성 자원 없음" };
const at = model.rows.indexOf(row);
const head = B.makeRow(model, {
BDCODE1: `T${o.level}`,
NAME1: row.NAME1 || "",
SIZE1: row.SIZE1 || "",
});
head.ITNUM = row.ITNUM || "";
let rec = num(head._recno);
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) });
return r;
});
const tail = B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) });
tail._recno = rec - 1;
const res = B.edit(model, (rows, links) => {
links.set(head, tail).set(tail, head);
rows.splice(at, 1, head, ...body, tail);
if (!row._new) model.deleted.push(num(row._recno));
return { count: body.length, skipped };
});
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 };
}
// 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠
function applyHalRate(model, recnos, rates, mode) {
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) => {
if (!isLeaf(r) || (set && !set.has(num(r._recno)))) return;
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: m, L: l, S: s };
changed += 1;
});
return { changed };
}
global.STMATE_UI_BOQ_TOOLS = { seqText, autoItnum, roundTo, expandIlwi, roundQty, applyHalRate };
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_BOQ_TOOLS;
})(typeof window !== "undefined" ? window : globalThis);