- 보고서: 원본 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>
194 lines
8.4 KiB
JavaScript
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);
|