/**
* STMATE w 8.0 — 공사목록표(wPrjList) · 계층 설계내역서(wBoq) · 내역도구 모달(wITNum/wBoqRate/wM_Rate/wExpnd/wQtyList)
* 근거: REA 09_보조분석 resource-TWPRJLIST.dfm.json, 16번 §4.2, 22번 §4, 28번 §2.2, 34번 §2.1
*/
(function (global) {
"use strict";
const Calc = global.STMATE_CALC;
const fmt = Calc.fmt;
const num = Calc.num;
const PCLASS_NAMES = {
0: "일반토목/산림토목",
1: "건축공사",
2: "조경공사",
3: "산업설비",
4: "전기/통신",
7: "전문/설계변경",
};
const KORD_BADGES = {
0: "설계내역(0)",
1: "계약내역(1)",
2: "1차변경(2)",
3: "실행예산(3)",
4: "기성내역(4)",
};
function isStructCode(bd) {
return /^(T\d|E\d|C\d|:)$/.test(String(bd || "").trim());
}
/**
* BOQ11~14.DBF 전수 행(T1~T6 제목, E1~E6 소계/계/총계/총합계, C* 제외금액, 말단 공종행)을
* 실시간 단가·수량과 동기화하여 원단위까지 재집계
*/
function evaluateFullBoqRows(ctx) {
const rawRows = (ctx.dbfData && ctx.dbfData.boq_rows) || [];
const compRes = ctx.computed;
const leafByRecno = {};
const leafByCode = {};
(compRes.boqComputed.rows || []).forEach((r) => {
if (r._recno !== undefined) leafByRecno[r._recno] = r;
if (r.code) leafByCode[r.code] = r;
});
// C* 제외구역 판별 (ENDCODE == SORTCODE)
const exclSet = new Set();
rawRows.forEach((r, idx) => {
const bd = String(r.BDCODE1 || "").trim();
if (bd.startsWith("C")) {
const endCode = r.ENDCODE;
const startIdx = rawRows.findIndex((q) => q.SORTCODE === endCode);
if (startIdx >= 0) {
for (let k = Math.min(startIdx, idx); k <= Math.max(startIdx, idx); k++) {
exclSet.add(k);
}
}
}
});
// 1차: 말단 행 단가·금액 동기화
const rows = rawRows.map((r, idx) => {
const bd = String(r.BDCODE1 || "").trim();
const recno = r._recno !== undefined ? r._recno : idx;
const leaf = leafByRecno[recno] || leafByCode[bd];
const isStruct = isStructCode(bd) || !bd;
if (!isStruct && leaf) {
return {
...r,
_idx: idx,
_recno: recno,
_rowClass: exclSet.has(idx) ? "boq-row-excl" : "boq-row-leaf",
_isExcl: exclSet.has(idx),
QTY1: leaf.qty,
MAMT1: leaf.mUnit,
LAMT1: leaf.lUnit,
SAMT1: leaf.sUnit,
TAMT1: leaf.tUnit,
M_AMT1: leaf.mAmt,
L_AMT1: leaf.lAmt,
S_AMT1: leaf.sAmt,
T_AMT1: leaf.tAmt,
};
}
let rowClass = "";
if (bd.startsWith("T")) rowClass = "boq-row-T";
else if (bd.startsWith("E")) rowClass = "boq-row-E";
else if (bd.startsWith("C")) rowClass = "boq-row-C";
return {
...r,
_idx: idx,
_recno: recno,
_rowClass: rowClass,
_isExcl: exclSet.has(idx),
QTY1: num(r.QTY1),
MAMT1: num(r.MAMT1),
LAMT1: num(r.LAMT1),
SAMT1: num(r.SAMT1),
TAMT1: num(r.TAMT1),
M_AMT1: num(r.M_AMT1),
L_AMT1: num(r.L_AMT1),
S_AMT1: num(r.S_AMT1),
T_AMT1: num(r.T_AMT1),
};
});
// 2차: T*(공종제목) 및 E*(소계/계/총계/총합계), C*(제외금액 총계) 구간 합산 동기화
rows.forEach((r, idx) => {
const bd = String(r.BDCODE1 || "").trim();
if (!bd.startsWith("T") && !bd.startsWith("E") && !bd.startsWith("C")) return;
const endCode = r.ENDCODE;
const partnerIdx = rows.findIndex((q, j) => j !== idx && q.SORTCODE === endCode);
if (partnerIdx < 0 && bd !== "E5" && bd !== "T5") return;
const sIdx = partnerIdx >= 0 ? Math.min(idx, partnerIdx) : 0;
const eIdx = partnerIdx >= 0 ? Math.max(idx, partnerIdx) : rows.length - 1;
const isExclSummary = bd.startsWith("C") || exclSet.has(idx);
let sumM = 0,
sumL = 0,
sumS = 0,
sumT = 0;
for (let k = sIdx; k <= eIdx; k++) {
const sub = rows[k];
const sbd = String(sub.BDCODE1 || "").trim();
if (!sbd || isStructCode(sbd)) continue;
if (!isExclSummary && sub._isExcl) continue;
sumM += num(sub.M_AMT1);
sumL += num(sub.L_AMT1);
sumS += num(sub.S_AMT1);
sumT += num(sub.T_AMT1);
}
if (sumT > 0 || partnerIdx >= 0) {
r.M_AMT1 = sumM;
r.L_AMT1 = sumL;
r.S_AMT1 = sumS;
r.T_AMT1 = sumT;
}
});
return rows;
}
/**
* 1. TWPRJLIST (wPrjList 공사목록표 화면)
* 상단: 등록정보 수정 | 제잡비 계산 | 해당분 수정(wBoq) | 복사등록 | 해당분만 압축(.STC) | 내역서 미리보기 | 정렬
* 중단: RGrdPrjList (PC 내 9개 프로젝트 + 현재 열린 PRJLST.DBF)
* 하단: RDbGrid_Boq (선택 프로젝트의 실시간 내역서 미리보기)
*/
function renderWPrjList(ctx) {
const sources = (ctx.sourcesData && ctx.sourcesData.projects) || [];
const curPath = ctx.currentSourcePath || "";
const prj = (ctx.dbfData && ctx.dbfData.prjlst) || {};
const comp = ctx.computed;
const directCost = comp.boqComputed.totals.directT;
const totalCost = comp.kanjubComputed.GRAND_TOTAL;
const boqRows = evaluateFullBoqRows(ctx);
const showPreview = ctx.uiState.showBoqPreview !== false;
return `
`;
}
// 열린 프로젝트의 공사(PRJLST 행 · PCODE 1n ↔ BOQ1n) — 복사등록·해당분 압축 대상 고름(scr_file.js)
function renderPrjRows(ctx) {
const rows = (ctx.dbfData && ctx.dbfData.prjlst_all) || [];
if (!rows.length) return "";
const pick = ctx.uiState.filePcode || String(rows[0].PCODE || "11").trim();
const tr = (r) => {
const pc = String(r.PCODE || "").trim();
return ` | BOQ${pc} | ${r.PNAME || ""} | ${KORD_BADGES[num(r.K_ORD)] || r.K_ORD || ""} | ${fmt(num(r.TCOST1))} | ${fmt(num(r.TCOST2))} | ${fmt(num(r.COSTW1))} |
`;
};
return `공사 (PRJLST ${rows.length}행) — 고른 공사로 복사등록·해당분 압축
`;
}
/**
* 2. wBoq (계층 설계내역서 편집 화면)
* - BOQ11~14.DBF의 모든 행(T1~T6 공종제목, E1~E6 소계/계/총계/총합계, C* 제외구역, 말단 공종행) 100% 표시
* - 레벨 필터(전체 / = 소계 이상 / == 계 이상 / ==== 총계 이상 / 제외구역만)
* - 공종번호 자동부여(wITNum), 할증율(wBoqRate), 단수보정(wM_Rate), 일위대가 풀기(wExpnd), 보관함(1)(2)(3)
*/
function renderWBoq(ctx) {
const allRows = evaluateFullBoqRows(ctx);
const lvFilter = ctx.uiState.boqLevelFilter || "ALL";
const kw = (ctx.uiState.boqSearch || "").trim().toLowerCase();
const pocketIdx = ctx.uiState.activePocket || 1;
const pockets = ctx.uiState.boqPockets || { 1: [], 2: [], 3: [] };
const filtered = allRows.filter((r) => {
const bd = String(r.BDCODE1 || "").trim();
if (lvFilter === "STRUCT" && !isStructCode(bd)) return false;
if (lvFilter === "LEAF" && (isStructCode(bd) || !bd)) return false;
if (lvFilter === "EXCL" && !r._isExcl) return false;
if (kw) {
const txt = `${r.ITNUM || ""} ${r.NAME1 || ""} ${r.SIZE1 || ""} ${bd}`.toLowerCase();
if (!txt.includes(kw)) return false;
}
return true;
});
const totals = ctx.computed.boqComputed.totals;
return `
`;
}
/**
* 3. 내역 도구 모달 HTML 생성기 (wITNum / wBoqRate / wM_Rate / wExpnd / wQtyList)
*/
function buildBoqToolModalHtml(toolKind, ctx) {
if (toolKind === "wITNum") {
return `[wITNum] 내역서 공종번호 자동입력 — 대공종(T4)/중공종(T2)/말단 공종행에 번호체계를 일괄 부여합니다.
`;
}
if (toolKind === "wBoqRate") {
return `[wBoqRate] 내역 항목 할증율 일괄 적용 — 전체 또는 비목별(재·노·경) 할증 비율(%)을 적용합니다.
`;
}
if (toolKind === "wM_Rate") {
return `[wM_Rate] 선택 범위 수량·비목 단수 일괄 보정 — 절사/반올림 및 소수점 자리수(0~4위)를 일괄 보정합니다.
`;
}
if (toolKind === "wExpnd") {
const bdxList = (ctx.state.ilwiItems || []).slice(0, 30);
return `[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기 — 선택한 호표의 재료·노무·경비 구성행을 내역서에 직접 전개합니다.
`;
}
const leafRows = ctx.computed.boqComputed.rows || [];
return `[wQtyList] 수량산출서(QCODE.DBF) 코드별 수량 연결·집계 — 수량산출식 결과를 내역서 QTY1에 즉시 반영합니다.
`;
}
function syncCostnUnitPrice(ctx, code, mVal, lVal, sVal) {
const tVal = mVal + lVal + sVal;
if (ctx.dbfData && ctx.dbfData.costn_rows) {
const row = ctx.dbfData.costn_rows.find((r) => String(r.CODE || "").trim() === code);
if (row) {
Object.assign(row, {
MAMT1: String(mVal),
LAMT1: String(lVal),
SAMT1: String(sVal),
TAMT1: String(tVal),
});
// 적용단가 칸(AMT_61)은 기초단가(M·L·S)만 — 일위대가·산근·중기는 원본도 0
if (/^[MLS]/.test(code) || num(row.AMT_61)) row.AMT_61 = String(tVal);
}
}
const directRes = ctx.state.resources.find((r) => r.code === code);
if (directRes) Object.assign(directRes, { adoptMode: "manual", manualPrice: tVal });
[
["M", "재료비", mVal],
["L", "노무비", lVal],
["S", "경비", sVal],
].forEach(([tag, kind, v]) => {
const synId = `${code}_${tag}`;
const syn = ctx.state.resources.find((r) => r.id === synId);
if (syn) syn.manualPrice = v;
else if (v > 0) {
ctx.state.resources.push({
id: synId,
code: synId,
name: `${code} (${kind})`,
spec: "",
unit: "",
kind,
candidates: [v, 0, 0, 0, 0],
adoptMode: "manual",
manualPrice: v,
convertHourly: false,
isUsd: false,
isSynthetic: true,
});
const ilwi = ctx.state.ilwiItems.find((x) => x.code === code);
if (ilwi && !ilwi.lines.some((ln) => ln.refId === synId)) {
ilwi.lines.push({ refId: synId, qty: 1.0, halPercent: 0, devd: false });
}
}
});
}
/**
* 저장 요청(/api/save) — 지금 값을 그대로 보내고 서버(stc_save.py)가 원본과 뜻으로 견줘 바뀐 칸만 씀
* 보내지 않는 것: 1차 단가로 2차 칸 덮기 · 고정 낙찰율 3차 칸 · 소계·PRJLST 직접비(서버가 다시 셈)
* 근거: STC_왕복검증.md §3(알려진 칸만 갱신) · 16번 §4(행 금액·계층 집계·TCOSTn)
*/
const COSTN_SAVE_COL =
/^(NAME|SIZE|UNIT|AMT_\d1|PG_1|UC|SBMOK|S_ADJ|ADJ_[MLS]|RXX|NAGCD|[TMLS]AMT[1-5])$/;
const isPctRow = (r) =>
String(r.UNIT1 || "")
.trim()
.endsWith("%") && num(r.T_AMT1) > 0;
const plainFields = (r) =>
Object.fromEntries(
Object.entries(r).filter(
([k, v]) =>
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
),
);
function itemQtyFields(b, raw) {
const u = {};
if (!b) return u;
if (isPctRow(raw)) {
(b.pctQtys || []).forEach((q, k) => q != null && (u[`QTY${k + 1}`] = q));
return u;
}
if (b.qty !== undefined) u.QTY1 = b.qty;
const q = Array.isArray(b.qtys) ? b.qtys : null;
for (let n = 2; n <= 4; n++) if (q && q[n - 1] !== undefined) u[`QTY${n}`] = q[n - 1];
if (q && num(q[1]) > 0 && b.changedQty !== undefined && num(b.changedQty) !== num(q[1]))
u.QTY2 = b.changedQty; // 변경 수량 칸만 고친 화면(rev-edit-qty2)
Object.entries(b.units || {}).forEach(([n, mls]) => {
if (num(n) < 2 || !Array.isArray(mls)) return;
["M", "L", "S"].forEach((c, k) => (u[`${c}AMT${n}`] = mls[k]));
});
return u;
}
function newItemRow(b) {
const q = Array.isArray(b.qtys) ? b.qtys : [b.qty, b.changedQty, 0, 0];
const u = { _new: true, BDCODE1: b.code || b.refId, NAME1: b.name, SIZE1: b.spec };
Object.assign(u, { UNIT1: b.unit, ITNUM: b.itnum || "", ENDCODE: 0 });
q.forEach((v, k) => (u[`QTY${k + 1}`] = v || 0));
return Object.assign(u, itemQtyFields(b, u));
}
function buildSavePayload(ctx) {
const d = ctx.dbfData || {};
const comp = ctx.computed || {};
const items = (ctx.state.boqItems || []).filter((b) => b._recno !== undefined && !b.isNew);
const byRec = new Map(items.map((b) => [b._recno, b]));
const fresh = (ctx.state.boqItems || []).filter((b) => b.isNew && b._recno === undefined);
const boqUpdates = [];
(d.boq_rows || []).forEach((r) => {
const isNew = r._new || r.SORTCODE === undefined;
const u = Object.assign(plainFields(r), itemQtyFields(byRec.get(r._recno), r));
boqUpdates.push(
isNew ? Object.assign(u, { _new: true }) : Object.assign(u, { _recno: r._recno }),
);
fresh
.filter((b) => b.parentRecno === r._recno)
.forEach((b) => boqUpdates.push(newItemRow(b)));
});
fresh.filter((b) => b.parentRecno == null).forEach((b) => boqUpdates.push(newItemRow(b)));
const prj = d.prjlst || {};
const prjUpd = { PNAME: ctx.state.projectMeta.pname, PNUM: ctx.state.projectMeta.pnum };
Object.entries(comp.stages || {}).forEach(([n, st]) => {
const g = st && st.kanjub && st.kanjub.grandTotal;
if (
g !== undefined &&
(n === "1" || num(prj[`COSTW${n}`]) > 0 || num(prj.K_ORD) >= num(n) - 1)
)
prjUpd[`COSTW${n}`] = g;
});
if (!comp.stages && comp.kanjubComputed) prjUpd.COSTW1 = comp.kanjubComputed.GRAND_TOTAL;
const smt = d.prjlst_smt || {};
const rts = (ctx.state.kanjubConfig && ctx.state.kanjubConfig.rates) || {};
const smtCand = {
"1_R_KANNO": rts.KANNO,
"1_R_SANBO": rts.SANBO,
"1_R_KOBO": rts.KOBO,
"1_R_KUNBO": rts.KUNBO,
"1_R_SLVBO": rts.SLVBO,
"1_R_YUNBO": rts.YUNBO,
"1_R_ANJUN": rts.ANJUN,
"1_R_T_KITA": rts.KITA,
"1_R_HWAN": rts.HWAN,
"1_R_EQIPSU": rts.EQIP,
"1_R_ILBAN": rts.ILBAN,
"1_R_IYUN": rts.IYUN,
"1_C_ANJUN_G": ctx.state.kanjubConfig && ctx.state.kanjubConfig.anjunBaseAdd,
};
const smtUpd = Object.fromEntries(
Object.entries(smtCand).filter(([k, v]) => k in smt && v !== undefined && v !== null),
);
return {
source_path: ctx.currentSourcePath,
target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28),
boq_table: d.boq_table_name || "",
costn_updates: (d.costn_rows || []).map((r) =>
Object.fromEntries(
Object.entries(r).filter(([k]) => k === "CODE" || COSTN_SAVE_COL.test(k)),
),
),
costn_memo_updates: ctx.uiState.editedSmtMap || {},
boq_updates: boqUpdates,
boq_deleted: d.boq_deleted || ctx.uiState.boqDeleted || [],
prjlst_updates: prjUpd,
smt_updates: smtUpd,
};
}
global.STMATE_VIEW_PRJ_BOQ = {
evaluateFullBoqRows,
renderWPrjList,
renderWBoq,
buildBoqToolModalHtml,
syncCostnUnitPrice,
buildSavePayload,
};
})(window);