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

405 lines
18 KiB
JavaScript

/**
* STMATE w 8.0 — 설계내역서(wBoq) 도구 대화상자 (scr_ui_boq_dlg.js)
* wKongJong(공종 목록 넣기) · 계표시 삽입 · wITNum · wBoqRate · wM_Rate(수량) · wExpnd · wMBoq
* 선택지 원문 = 지식DB STmate/old/30_원자료/ui_form_catalog.txt · 계산 = ui_boq.js · ui_boq_tools.js
*/
(function (global) {
"use strict";
const B = global.STMATE_UI_BOQ;
const T = global.STMATE_UI_BOQ_TOOLS;
const esc = (s) =>
String(s == null ? "" : s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const $ = (id) => document.getElementById(id);
const val = (id) => ($(id) ? $(id).value : "");
const opt = (list, cur) =>
list
.map(
([v, t]) =>
`<option value="${esc(v)}" ${String(v) === String(cur) ? "selected" : ""}>${esc(t)}</option>`,
)
.join("");
const LEVELS = (ini) =>
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [n, `${n} 레벨 (${B.dispName(ini, n)})`]);
const DOTS = [4, 3, 2, 1, 0].map((d) => [d, `소수 ${d}위값을 적용`]);
const KINDS = [
["BD", "일위대가/산근전체"],
["ALL", "모든단가"],
["L", "노무비"],
["M", "재료비"],
["S", "경비"],
["B", "일위대가"],
["X", "중기비"],
["D", "단가산출"],
["W", "견적단가"],
];
// 공종 목록(wKongJong) — RdoIndex(코드번호 순/명칭 순/분류번호 순) · 코드 골라 수량과 함께 넣기
function kongjong(ctx, api, x) {
const st = (ctx.uiState.kongjong = ctx.uiState.kongjong || { kind: "BD", sort: "C", kw: "" });
const costn = ctx.dbfData.costn_rows || [];
const draw = () => {
const kw = st.kw.trim().toLowerCase();
let list = costn.filter((r) => {
const c = String(r.CODE || "").trim(),
p = c[0];
if (!c) return false;
if (st.kind === "BD" ? !/[BD]/.test(p) : st.kind !== "ALL" && p !== st.kind) return false;
return !kw || `${c} ${r.NAME || ""} ${r.SIZE || ""}`.toLowerCase().includes(kw);
});
const key = {
C: (r) => String(r.CODE),
N: (r) => `${r.NAME || ""} ${r.SIZE || ""}`,
I: (r) => String(r.BQCODE || ""),
}[st.sort];
list = list.slice().sort((a, b) => key(a).localeCompare(key(b), "ko"));
$("kj-list").innerHTML = list
.slice(0, 500)
.map(
(r) =>
`<tr><td class="center"><input type="checkbox" class="kj-pick" value="${esc(String(r.CODE).trim())}" /></td><td class="center">${esc(String(r.CODE).trim())}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td>${esc(r.BQCODE || "")}</td></tr>`,
)
.join("");
$("kj-count").textContent = `${list.length}건${list.length > 500 ? " (앞 500건 표시)" : ""}`;
};
api.openModal(
"공종 목록표 (wKongJong)",
`<div class="stm-form"><div class="stm-row"><select id="kj-kind" class="win-select">${opt(KINDS, st.kind)}</select>
<select id="kj-sort" class="win-select">${opt(
[
["C", "코드번호 순"],
["N", "명 칭 순"],
["I", "분류번호 순"],
],
st.sort,
)}</select>
<input id="kj-kw" class="win-input" placeholder="명칭·규격·코드" value="${esc(st.kw)}" /><span id="kj-count"></span></div>
<div class="grid-scroll" style="max-height:340px;"><table class="dbgrid"><thead><tr><th></th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th></tr></thead><tbody id="kj-list"></tbody></table></div>
<div class="stm-row"><label>수량</label><input id="kj-qty" class="win-input" type="number" value="0" style="width:90px;" />
<span class="stm-note">넣을 곳: ${ctx.uiState.boqGrpTo === "pocket" ? `열린 보관함 (${ctx.uiState.activePocket || 1})` : "내역서 선택 행 앞"}</span>
<button class="win-btn primary" id="kj-ok">고른 코드 넣기</button></div></div>`,
);
draw();
$("kj-kind").onchange = (e) => ((st.kind = e.target.value), draw());
$("kj-sort").onchange = (e) => ((st.sort = e.target.value), draw());
$("kj-kw").oninput = (e) => ((st.kw = e.target.value), draw());
$("kj-ok").onclick = () => {
const codes = [...document.querySelectorAll(".kj-pick:checked")].map((c) => c.value);
if (!codes.length) return alert("넣을 코드를 고르세요");
const m = x.model(ctx),
cbc = x.costnByCode(ctx),
qty = B.num(val("kj-qty"));
let rec = B.nextRecno(m);
const rows = codes.map((c) => {
const cr = cbc[c];
const r = B.makeRow(m, {
BDCODE1: c,
NAME1: cr.NAME || "",
SIZE1: cr.SIZE || "",
UNIT1: cr.UNIT || "",
QTY1: String(qty),
});
r._recno = rec--;
return r;
});
api.closeModal();
if (ctx.uiState.boqGrpTo === "pocket") {
const u = ctx.uiState,
n = u.activePocket || 1;
u.boqPockets = u.boqPockets || { 1: [], 2: [], 3: [] };
u.boqPockets[n].push({ title: rows[0].NAME1, rows, pairs: [] });
return api.renderWorkspace();
}
x.after(ctx, api, B.insertRows(m, x.insertAt(ctx), rows));
};
}
// 계표시 삽입 — 선택 행이 있으면 그 구간을 머리·계로 감쌈 · 없으면 빈 짝을 끼움
function wrap(ctx, api, x) {
const ini = ctx.dbfData.start_ini;
const ix = x.selIdx(ctx);
api.openModal(
"계표시 삽입",
`<div class="stm-form"><div class="stm-row"><label>레벨</label><select id="wr-lv" class="win-select">${opt(LEVELS(ini), 2)}</select></div>
<div class="stm-row"><label>머리 명칭</label><input id="wr-title" class="win-input" style="width:260px;" /></div>
<p class="stm-note">${ix.length ? `선택 ${ix.length}행(${ix[0] + 1}~${ix[ix.length - 1] + 1})을 감쌈` : "선택 없음 — 끝에 빈 머리·계 짝을 넣음"}</p>
<div class="stm-row"><button class="win-btn primary" id="wr-ok">넣기</button></div></div>`,
);
$("wr-ok").onclick = () => {
const n = Number(val("wr-lv")),
title = val("wr-title");
api.closeModal();
const m = x.model(ctx);
if (ix.length)
return x.after(ctx, api, B.wrapGroup(m, ix[0], ix[ix.length - 1], n, title, ini));
const head = B.makeRow(m, { BDCODE1: `T${n}`, NAME1: title });
const tail = B.makeRow(
{ rows: m.rows.concat(head), items: m.items },
{ BDCODE1: `E${n}`, ITNUM: B.dispName(ini, n) },
);
x.after(ctx, api, B.insertRows(m, m.rows.length, [head, tail], [[head, tail]]));
};
}
// 공종번호 자동입력(wITNum)
function itnum(ctx, api, x) {
const ini = ctx.dbfData.start_ini;
api.openModal(
"내역서의 공종번호 자동입력 (wITNum)",
`<div class="stm-form">
<div class="stm-row"><label>대상(cBx_OnlyIT)</label><select id="it-target" class="win-select">${opt(
[
["group", "공종(계표시)에만 구분적용."],
["leaf", "단가코드에만 구분적용."],
["both", "공종(계표시) / 단가코드 모두적용."],
],
"group",
)}</select>
<select id="it-level" class="win-select">${opt(LEVELS(ini), 2)}</select></div>
<div class="stm-row"><label>범위</label><select id="it-range" class="win-select">${opt(
[
["all", "내역서 전체"],
["sel", "선택된 항목만"],
],
"all",
)}</select></div>
<div class="stm-row"><label>첫 글자(RdoFirstChar)</label><select id="it-first" class="win-select">${opt(
[
["", "공란처리"],
[".", ". 입력"],
["(", "( 입력"],
["-", "- 입력"],
["custom", "추가입력"],
],
"",
)}</select><input id="it-first-text" class="win-input" style="width:60px;" placeholder="추가입력" /></div>
<div class="stm-row"><label>끝 글자(RdoLastChar)</label><select id="it-last" class="win-select">${opt(
[
["", "공란처리"],
[".", ". 입력"],
[")", ") 입력"],
],
".",
)}</select></div>
<div class="stm-row"><label>번호형식</label><select id="it-type" class="win-select">${opt(
[
["num", "숫자"],
["ko", "가..하,거..허 ~ 그..흐,기..히 (140개)"],
["en", "a .. z,a1..z1 ~ a9..z9 (260개)"],
],
"num",
)}</select>
<select id="it-pad" class="win-select">${opt(
[
[1, "0"],
[2, "00"],
[3, "000"],
],
1,
)}</select>
<select id="it-margin" class="win-select">${opt(
[0, 1, 2, 3, 4, 5, 6, 7].map((n) => [n, `여백 ${n} Byte`]),
0,
)}</select></div>
<div class="stm-row"><label>앞 번호(cBx_itNum_B)</label><select id="it-prefix" class="win-select">${opt(
[
["none", "붙이지 않음"],
["parent", "『직전공종(계표시)의 공종번호』"],
["space", "『직전공종길이 여백으로 채움』"],
],
"none",
)}</select></div>
<div class="stm-row"><label>번호 초기화(cBx_Bl_Lev)</label><select id="it-reset" class="win-select"><option value="">바로 윗 레벨 머리마다</option>${[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<option value="${n}">${n} 레벨(${esc(B.dispName(ini, n))}) 이상 머리마다</option>`).join("")}<option value="0">초기화 안 함(이어서)</option></select></div>
<p class="stm-note">예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 …</p>
<div class="stm-row"><button class="win-btn primary" id="it-ok">공종번호 입력</button></div></div>`,
);
$("it-ok").onclick = () => {
const rs = val("it-reset");
const o = {
target: val("it-target"),
level: Number(val("it-level")),
first: val("it-first"),
firstText: val("it-first-text"),
};
Object.assign(o, {
last: val("it-last"),
type: val("it-type"),
pad: Number(val("it-pad")),
margin: Number(val("it-margin")),
});
Object.assign(o, {
prefix: val("it-prefix"),
resetLevel: rs === "" ? undefined : Number(rs),
});
const ix = x.selIdx(ctx);
if (val("it-range") === "sel") {
if (!ix.length) return alert("선택된 항목 없음");
o.range = [ix[0], ix[ix.length - 1]];
}
const res = T.autoItnum(ctx.dbfData.boq_rows, o);
B.syncItems(x.model(ctx));
api.closeModal();
ctx.uiState.boqMsg = `공종번호 ${res.changed}행 입력`;
x.after(ctx, api, { ok: true }, true);
};
}
// 할증율 적용(wBoqRate) — 전체(재,노,경 같은 비율) / 비목별(재,노,경 각각)
function boqRate(ctx, api, x) {
api.openModal(
"할증율 적용 (wBoqRate)",
`<div class="stm-form"><div class="stm-row"><label>방식(Rdo)</label><select id="br-mode" class="win-select">${opt(
[
["all", "전체(재,노,경 같은 비율)"],
["each", "비목별(재,노,경 각각)"],
],
"all",
)}</select></div>
<div class="stm-row"><label>전체 할증율(%)</label><input id="br-all" type="number" class="win-input" value="0" /></div>
<div class="stm-row"><label>재료비 · 노무비 · 경비(%)</label><input id="br-m" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-l" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-s" type="number" class="win-input" value="0" style="width:70px;" /></div>
<div class="stm-row"><label>범위</label><select id="br-range" class="win-select">${opt(
[
["sel", "선택된 항목만"],
["all", "내역서 전체"],
],
"sel",
)}</select></div>
<p class="stm-warn">내역 행 RATE·RM·RL·RS 칸과 비고에 기록 — 금액 반영은 계산 엔진(calc.js calcBoq)의 할증 처리 대기</p>
<div class="stm-row"><button class="win-btn primary" id="br-ok">적용</button></div></div>`,
);
$("br-ok").onclick = () => {
const recs = val("br-range") === "sel" ? x.sel(ctx) : null;
if (recs && !recs.length) return alert("선택된 항목 없음");
const res = T.applyHalRate(
x.model(ctx),
recs,
{ all: val("br-all"), M: val("br-m"), L: val("br-l"), S: val("br-s") },
val("br-mode"),
);
api.closeModal();
ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록`;
x.after(ctx, api, { ok: true }, true);
};
}
// 단수 일괄보정(wM_Rate) — 수량(rd_Qty1 · wcBx_Pnt1 · wcBx_Pnt2)
function mRate(ctx, api, x) {
api.openModal(
"단수 일괄보정 (wM_Rate)",
`<div class="stm-form"><div class="stm-row"><label>수량 범위(rd_Qty1)</label><select id="mr-range" class="win-select">${opt(
[
["all", "내역서 전체"],
["sel", "선택된 항목만"],
],
"all",
)}</select></div>
<div class="stm-row"><label>처리(wcBx_Pnt1)</label><select id="mr-mode" class="win-select">${opt(
[
["trunc", "0 절사하여"],
["round", "1 반올림 하여"],
],
"trunc",
)}</select>
<select id="mr-dot" class="win-select">${opt(
[0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]),
2,
)}</select></div>
<p class="stm-warn">비목별 금액 보정(wcBx_Pnt1_M·_L·_X·_W, rd_Amt1·rd_Amt2)은 내역 행 단가 고정 칸이 계산 엔진에 없어 대기</p>
<div class="stm-row"><button class="win-btn primary" id="mr-ok">수량 보정</button></div></div>`,
);
$("mr-ok").onclick = () => {
const recs = val("mr-range") === "sel" ? x.sel(ctx) : null;
if (recs && !recs.length) return alert("선택된 항목 없음");
const res = T.roundQty(x.model(ctx), recs, val("mr-mode"), Number(val("mr-dot")));
api.closeModal();
ctx.uiState.boqMsg = `수량 ${res.changed}행 보정`;
x.after(ctx, api, { ok: true }, true);
};
}
// 일위대가표 풀기(wExpnd) — RGrpToT · CMBoxUpDn · CMBoxDot
function expnd(ctx, api, x) {
const r = ctx.dbfData.boq_rows.find((q) => Number(q._recno) === x.sel(ctx)[0]);
if (!r || B.codeOf(r)[0] !== "B") return alert("풀 일위대가(B 코드) 행 하나를 고르세요");
api.openModal(
"일위대가표 풀기 (wExpnd)",
`<div class="stm-form"><p>${esc(B.codeOf(r))} ${esc(r.NAME1)} ${esc(r.SIZE1)} — 수량 ${B.num(r.QTY1)} ${esc(r.UNIT1)}</p>
<div class="stm-row"><label>집계(RGrpToT)</label><select id="ex-lv" class="win-select">${opt(
[
[1, "소계(=)"],
[2, "계(==)"],
[3, "합계(===)"],
],
1,
)}</select></div>
<div class="stm-row"><label>수량 끝전</label><select id="ex-mode" class="win-select">${opt(
[
["trunc", "절삭하여"],
["round", "반올림하여"],
],
"trunc",
)}</select><select id="ex-dot" class="win-select">${opt(DOTS, 4)}</select></div>
<p class="stm-note">구성 수량 = 구성표(BDQTY) 수량 × 내역 수량 · 비율(PR)·%단위·구분 줄은 풀지 않음</p>
<div class="stm-row"><button class="win-btn primary" id="ex-ok">풀기</button></div></div>`,
);
$("ex-ok").onclick = () => {
const o = {
level: Number(val("ex-lv")),
mode: val("ex-mode"),
digits: Number(val("ex-dot")),
};
const res = T.expandIlwi(
x.model(ctx),
r._recno,
ctx.dbfData.bdqty_rows,
x.costnByCode(ctx),
o,
ctx.dbfData.start_ini,
);
api.closeModal();
if (res.ok)
ctx.uiState.boqMsg = `풀기 ${res.count}행${res.skipped ? ` · 풀지 않은 줄 ${res.skipped}개(비율·% 단위)` : ""}`;
x.after(ctx, api, res);
};
}
// 주공종 집계표(wMBoq) — CBox01 1레벨(소계)~6레벨(총대계) 이상
function mboq(ctx, api) {
const ini = ctx.dbfData.start_ini;
const draw = (lv) => {
const rows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx);
const list = B.groupTotals(rows, lv);
$("mb-list").innerHTML = list
.map(
(g) =>
`<tr class="mb-row" data-recno="${g.recno}"><td class="center">${g.level}</td><td>${esc(g.itnum)}</td><td>${esc(g.name)}</td><td class="num">${api.fmt(g.sum.M)}</td><td class="num">${api.fmt(g.sum.L)}</td><td class="num">${api.fmt(g.sum.S)}</td><td class="num"><strong>${api.fmt(g.sum.T)}</strong></td><td class="center">${esc(g.closeCode)}</td></tr>`,
)
.join("");
document.querySelectorAll(".mb-row").forEach(
(tr) =>
(tr.ondblclick = () => {
const rec = Number(tr.getAttribute("data-recno"));
Object.assign(ctx.uiState, { boqSel: [rec], boqScrollTo: rec, boqLevelMin: 0 });
api.closeModal();
api.renderWorkspace();
}),
);
};
api.openModal(
"주공종 집계표 (wMBoq · 계표시 확인)",
`<div class="stm-form"><div class="stm-row"><select id="mb-lv" class="win-select">${opt(
[1, 2, 3, 4, 5, 6].map((n) => [n, `${n}레벨(${B.dispName(ini, n)}) 이상`]),
1,
)}</select><span class="stm-note">줄을 두 번 누르면 내역서의 그 머리로 이동</span></div>
<div class="grid-scroll" style="max-height:420px;"><table class="dbgrid"><thead><tr><th>레벨</th><th>공종</th><th>명칭</th><th>재료비</th><th>노무비</th><th>경비</th><th>합계</th><th>닫는 행</th></tr></thead><tbody id="mb-list"></tbody></table></div></div>`,
);
draw(1);
$("mb-lv").onchange = (e) => draw(Number(e.target.value));
}
global.STMATE_UI_BOQ_DLG = { kongjong, wrap, itnum, boqRate, mRate, expnd, mboq };
})(window);