Files
Aislo/tools/stmate-test/app.js
T

501 lines
20 KiB
JavaScript

/**
* STMATE w 8.0 — 메인 컨트롤러 및 DBF/SMT/STC/JB_R 양방향 브리지 (app.js)
* TWM_STMATE_START_8X 5대 메뉴 · 9개 메인 화면 · 계산완료(Act_Calc_All) · 모달 제어
*/
(function (global) {
"use strict";
const Calc = global.STMATE_CALC;
const ViewPrjBoq = global.STMATE_VIEW_PRJ_BOQ;
const ViewDanIlwi = global.STMATE_VIEW_DAN_ILWI;
const ViewKanjubPrt = global.STMATE_VIEW_KANJUB_PRT;
const { fmt, num } = Calc;
const $ = (id) => document.getElementById(id),
closeModal = () => $("stmate-modal-backdrop").classList.add("hidden"),
onClick = (id, fn) => $(id) && ($(id).onclick = fn),
onChange = (id, fn) => $(id) && ($(id).onchange = fn),
bindEach = (sel, ev, fn) =>
document.querySelectorAll(sel).forEach((el) => (el[ev] = (e) => fn(el, e)));
const ctx = {
state: Calc.createDefaultState(),
computed: null,
sourcesData: { projects: [], jbr_files: [], work_root: "" },
catalogData: { codes: [], recipes: [], special_san: [] },
currentSourcePath: "",
dbfData: null,
uiState: {
activeScreen: "wPrjList",
showBoqPreview: true,
boqLevelFilter: "ALL",
boqSearch: "",
activePocket: 1,
boqPockets: { 1: [], 2: [], 3: [] },
danFilter: "ALL",
danSearch: "",
ilwiSubFilter: "ALL_BDX",
selectedIlwiCode: "",
editedSmtMap: {},
kanRateTab: "RATE_R.DBF",
stageSubTab: "CONTRACT",
bidRateM: 87.745,
bidRateL: 87.745,
bidRateS: 87.745,
prevPct: 30.0,
progPct: 65.0,
activeReportSheet: "0608",
},
};
const recompute = () => (ctx.computed = Calc.computeAll(ctx.state));
// 끼워 넣기 — scr_*.js 가 STMATE_PLUGINS 에 화면·메뉴 동작을 등록(plugins_manifest.js 가 불러옴)
const PLUG = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const API = (global.STMATE_APP = {
ctx,
Calc,
fmt,
num,
$,
onClick,
onChange,
bindEach,
closeModal,
});
function renderChromeBars() {
const prjBar = $("prj-context-bar"),
stBar = $("stmate-statusbar");
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
const dbfInfo = ctx.dbfData
? `COSTN ${ctx.dbfData.costn_count}건 · SMT산식 ${Object.keys(ctx.dbfData.costn_memos || {}).length}건 · ${ctx.dbfData.boq_table_name} ${ctx.dbfData.boq_count}행`
: "새 공사 (불러온 파일 없음)";
if (prjBar) {
prjBar.innerHTML = `<div class="prj-context-left"><span class="prj-tag">열린 공사</span><strong>${ctx.state.projectMeta.pname}</strong><span>(${ctx.state.projectMeta.pnum || "-"} · 코드 BOQ${ctx.state.projectMeta.pcode || "11"})</span></div><div class="prj-context-right"><span>원본 DBF/SMT: <strong>${dbfInfo}</strong></span><span>원가양식: <strong>${ctx.state.kanjubConfig.scheme}형식</strong></span></div>`;
}
if (stBar) {
stBar.innerHTML = `<div class="st-metrics"><span class="st-kv">순공사원가 재료비:<strong>${fmt(bt.directM)}</strong></span><span class="st-kv">노무비:<strong>${fmt(bt.directL)}</strong></span><span class="st-kv">경비:<strong>${fmt(bt.directS)}</strong></span><span class="st-kv">직접공사비 계:<strong>${fmt(bt.directT)}원</strong></span><span class="st-kv">간접공사비+일반+이윤:<strong>${fmt(kj.TOTAL_COST - kj.DIRECT_COST)}원</strong></span><span class="st-kv">도급액(공급가+부가세):<strong>${fmt(kj.FINAL_SUPPLY)}원</strong></span><span class="st-kv">관급자재대:<strong>${fmt(kj.GOV_MAT)}원</strong></span><span class="st-kv total">총공사비(COSTW1):<strong>${fmt(kj.GRAND_TOTAL)}원</strong></span></div><div>STMATE w 8.0 Full Engine</div>`;
}
document.querySelectorAll("#main-toolbar .tb-btn[data-screen]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-screen") === ctx.uiState.activeScreen);
});
}
const RENDERERS = {
wPrjList: ViewPrjBoq.renderWPrjList,
wBoq: ViewPrjBoq.renderWBoq,
wM_Set_Sys: ViewDanIlwi.renderWMSetSys,
wM_JabPum: ViewDanIlwi.renderWMJabPum,
wM_Edit_iLWi: ViewDanIlwi.renderWMEditIlwi,
wM_KanJub: ViewKanjubPrt.renderWMKanJub,
wM_KAN_RATE: ViewKanjubPrt.renderWMKanRate,
wStage: ViewKanjubPrt.renderWStage,
PM_Prt: ViewKanjubPrt.renderPMPrt,
};
function renderWorkspace() {
recompute();
renderChromeBars();
const ws = $("mdi-workspace");
if (!ws) return;
const scr = PLUG.screens[ctx.uiState.activeScreen];
ws.innerHTML = scr
? scr.render(ctx, API)
: (RENDERERS[ctx.uiState.activeScreen] || ViewPrjBoq.renderWPrjList)(ctx);
bindWorkspaceEvents();
if (scr && scr.bind) scr.bind(ctx, API);
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function loadProjectFromServer(sourcePath, jumpScreen) {
const badge = $("conn-badge");
try {
if (badge) badge.textContent = "DBF/SMT 로딩중...";
const data = await postJson("/api/load", { source_path: sourcePath });
ctx.currentSourcePath = sourcePath;
ctx.dbfData = data;
ctx.state = data.sim_state;
ctx.uiState.editedSmtMap = {};
if (jumpScreen) ctx.uiState.activeScreen = jumpScreen;
const sel = $("top-project-select");
if (sel) sel.value = sourcePath;
if (badge) badge.textContent = `DBF/SMT 직결 (${data.costn_count}단가·${data.boq_count}내역)`;
Object.assign(ctx.uiState, { boqSel: [], sysDraft: null });
if (API.noteRecent) API.noteRecent(sourcePath);
renderWorkspace();
} catch (err) {
if (badge) badge.textContent = "로드 오류";
alert("프로젝트 DBF 로드 실패: " + err.message);
}
}
function cascadeAndRender() {
if (global.STMATE_ENGINE && global.STMATE_ENGINE.cascadeRecalculateAll)
global.STMATE_ENGINE.cascadeRecalculateAll(ctx);
renderWorkspace();
}
async function saveAllToDbfServer() {
if (!ctx.currentSourcePath || !ctx.dbfData)
return alert("먼저 PC 또는 실무 STMATE 프로젝트를 로드하세요.");
const stat =
global.STMATE_ENGINE && global.STMATE_ENGINE.cascadeRecalculateAll
? global.STMATE_ENGINE.cascadeRecalculateAll(ctx)
: { xCount: 0, dCount: 0, bCount: 0 };
recompute();
renderChromeBars();
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
try {
const saved = await postSave(decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)));
renderWorkspace();
alert(
`[계산완료(Act_Calc_All) 및 DBF/SMT/STC 저장 성공]\n• 5단 연쇄재계산: 중기(X) ${stat.xCount}건 · 산근(D) ${stat.dCount}건 · 일위대가(B) ${stat.bCount}건\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`,
);
} catch (err) {
alert("DBF/STC 저장 실패: " + err.message);
}
}
// 저장 길 — 코드 관리(지움·코드 바꿈·다른 표 코드)가 있으면 /api/ui/save(구조 덧붙임 · api_ui_code.py) · 아니면 /api/save
function postSave(p) {
const UC = global.STMATE_UI_CODE;
if (UC && UC.needsUiSave(ctx.dbfData))
return postJson(
"/api/ui/save",
Object.assign(p, UC.structPayload(ctx.dbfData), {
rate_tables: ctx.dbfData.rate_dirty ? ctx.dbfData.rate_tables : {},
}),
);
return postJson("/api/save", p);
}
// 저장 묶음 덧붙임 — 초기값(start_ini) · 공사목록 전체 행(prjlst_rows) · 서버 반영은 파일 담당
function decorateSavePayload(p) {
const d = ctx.dbfData;
if (!d) return p;
// 산근 편집기 계약단가 표시(BCONT) — 고친 코드만 COSTN 칸 덧붙임
const extra = ctx.uiState.costnExtra || {};
(p.costn_updates || []).forEach((u) => Object.assign(u, extra[String(u.CODE).trim()] || {}));
return Object.assign(p, { start_ini: d.start_ini || {}, prjlst_rows: d.prjlst_all || [] });
}
function openModal(title, bodyHtml) {
$("modal-title-text").textContent = title;
$("modal-body-content").innerHTML = bodyHtml;
$("stmate-modal-backdrop").classList.remove("hidden");
bindModalEvents();
}
// 도구 창 바인딩·내역 행 끼워 넣기 — app_modals.js
const bindModalEvents = () => global.STMATE_APP_MODALS.bindModalEvents(API);
const insertBoqRow = (...args) => global.STMATE_APP_MODALS.insertBoqRow(API, ...args);
function bindWorkspaceEvents() {
const goScreen = (scr) => {
ctx.uiState.activeScreen = scr;
renderWorkspace();
};
bindEach(".prj-row", "ondblclick", (tr) => {
const p = tr.getAttribute("data-path");
p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wBoq");
});
bindEach(".btn-load-prj", "onclick", (btn, e) => {
e.stopPropagation();
const p = btn.getAttribute("data-path");
p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wPrjList");
});
"prj-btn-open-boq:wBoq,prj-btn-kanjub:wM_KanJub,kj-btn-open-rate:wM_KAN_RATE"
.split(",")
.forEach((p) => {
const [id, scr] = p.split(":");
onClick(id, () => goScreen(scr));
});
["boq-btn-save-dbf", "kj-btn-save-dbf"].forEach((id) => onClick(id, saveAllToDbfServer));
onClick("prj-btn-toggle-preview", () => {
ctx.uiState.showBoqPreview = !ctx.uiState.showBoqPreview;
renderWorkspace();
});
onClick("prj-btn-edit-meta", () => {
const nextName = prompt("공사명(PNAME) 수정:", ctx.state.projectMeta.pname);
if (nextName) {
ctx.state.projectMeta.pname = nextName.trim();
if (ctx.dbfData && ctx.dbfData.prjlst) ctx.dbfData.prjlst.PNAME = nextName.trim();
renderWorkspace();
}
});
// 내역서·초기값·단가목록표 화면 묶음은 scr_ui_boq.js · scr_ui_sys.js · scr_ui_dan.js
bindModalOpener("ilwi-btn-load-recipe", "Act_CatalogModal");
bindEach(".ilwi-sub-btn", "onclick", (btn) => {
ctx.uiState.ilwiSubFilter = btn.getAttribute("data-sub");
renderWorkspace();
});
bindEach(".ilwi-master-row", "onclick", (tr) => {
ctx.uiState.selectedIlwiCode = tr.getAttribute("data-code");
renderWorkspace();
});
const smtArea = $("smt-formula-textarea");
if (smtArea)
smtArea.oninput = () =>
(ctx.uiState.editedSmtMap[smtArea.getAttribute("data-code")] = smtArea.value);
onClick("ilwi-btn-apply-f9", () => {
const code = $("ilwi-btn-apply-f9").getAttribute("data-code");
const txt = ($("smt-formula-textarea") || {}).value || "";
ctx.uiState.editedSmtMap[code] = txt;
if (ctx.dbfData && ctx.dbfData.costn_memos) {
const prev = ctx.dbfData.costn_memos[code] || { code, blockNo: 1 };
ctx.dbfData.costn_memos[code] = Object.assign({}, prev, { text: txt, byteLen: txt.length });
}
const evalRes = ViewDanIlwi.evaluateSmtFormulaText(txt, ctx, code);
if (evalRes.sumT > 0)
ViewPrjBoq.syncCostnUnitPrice(ctx, code, evalRes.sumM, evalRes.sumL, evalRes.sumS);
cascadeAndRender();
});
bindEach(".kj-scheme-tab", "onclick", (btn) => {
ctx.state.kanjubConfig.scheme = btn.getAttribute("data-scheme");
renderWorkspace();
});
bindEach(".kj-rate-input", "onchange", (inp) => {
ctx.state.kanjubConfig.rates[inp.getAttribute("data-key")] = num(inp.value);
renderWorkspace();
});
[
["kj-opt-anjun-gov", "anjunGovVatExcluded", (v) => v === "1"],
["kj-anjun-base", "anjunBaseAdd", (v) => num(v)],
["kj-anjun-cap", "anjunCap12", (v) => v === "1"],
["kj-ind-trunc", "indirectTruncDigits", (v) => parseInt(v, 10)],
["kj-vat-mode", "vatMode", (v) => v],
["kj-tot-jeol", "totJeolDigits", (v) => parseInt(v, 10)],
["kj-profit-adj", "enableProfitEndAdj", (v) => v === "1"],
["kj-gov-round", "govRoundMode", (v) => v],
].forEach(([id, key, fn]) =>
onChange(id, (e) => {
ctx.state.kanjubConfig[key] = fn(e.target.value);
renderWorkspace();
}),
);
onClick("kj-btn-apply-jbr", async () => {
const p = $("kj-jbr-select").value;
if (!p) return;
const d = await postJson("/api/load-jbr", { jbr_path: p });
Object.assign(ctx.state.kanjubConfig.rates, d.extracted_rates);
if (d.extracted_rates.anjunBaseAdd)
ctx.state.kanjubConfig.anjunBaseAdd = d.extracted_rates.anjunBaseAdd;
renderWorkspace();
});
bindEach(".kr-tab-btn", "onclick", (btn) => {
ctx.uiState.kanRateTab = btn.getAttribute("data-tab");
renderWorkspace();
});
onClick("kr-btn-load-jbr", async () => {
const p = $("kr-jbr-select").value;
if (!p) return;
const d = await postJson("/api/load-jbr", { jbr_path: p });
if (ctx.dbfData && ctx.dbfData.rate_tables) {
ctx.dbfData.rate_tables["RATE_R.DBF"] = d.rows;
ctx.uiState.kanRateTab = "RATE_R.DBF";
renderWorkspace();
}
});
bindEach(".stage-tab-btn", "onclick", (btn) => {
ctx.uiState.stageSubTab = btn.getAttribute("data-st");
renderWorkspace();
});
onClick("st-btn-apply-bid", () => {
["M:m", "L:l", "S:s"].forEach((p) => {
const [k, id] = p.split(":");
ctx.uiState["bidRate" + k] = num($("st-bid-" + id).value, 87.745);
});
renderWorkspace();
});
onClick("st-btn-apply-gisung", () => {
ctx.uiState.prevPct = num($("st-prev-pct").value, 30);
ctx.uiState.progPct = num($("st-prog-pct").value, 65);
renderWorkspace();
});
bindEach(".rev-edit-qty2", "onchange", (inp) => {
const item = ctx.state.boqItems.find((b) => b.id === inp.getAttribute("data-id"));
if (item) item.changedQty = num(inp.value);
renderWorkspace();
});
onChange("prt-sheet-select", (e) => {
ctx.uiState.activeReportSheet = e.target.value;
renderWorkspace();
});
onClick("prt-btn-xls", () => exportFullXlsx());
onClick("prt-btn-csv", () => {
const tbl = document.querySelector("#mdi-workspace table.dbgrid");
if (!tbl) return;
const rows = Array.from(tbl.querySelectorAll("tr")).map((tr) =>
Array.from(tr.querySelectorAll("th,td"))
.map((td) => `"${td.innerText.replace(/"/g, '""').trim()}"`)
.join(","),
);
downloadBlob(
"\uFEFF" + rows.join("\r\n"),
`STMATE_Report_${ctx.uiState.activeReportSheet}.csv`,
"text/csv;charset=utf-8",
);
});
onClick("prt-btn-xml", () => {
const xmlStr = global.STMATE_ENGINE
? global.STMATE_ENGINE.buildFullPpsXml(ctx)
: "<T3_C3B />";
downloadBlob(xmlStr, "PPS_CostExchange_V105.xml", "application/xml;charset=utf-8");
});
onClick("prt-btn-jdl", () => {
const lines = (ctx.computed.boqComputed.rows || []).map(
(r) => `JDL|${r.code}|${r.name}|${r.spec}|${r.unit}|${r.qty}|${r.tUnit}|${r.tAmt}`,
);
downloadBlob(lines.join("\r\n"), "STMATE_Export.JDL", "text/plain;charset=utf-8");
});
}
async function exportFullXlsx() {
try {
const fullBoq = ViewPrjBoq.evaluateFullBoqRows(ctx);
const res = await fetch("/api/export-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prj_meta: ctx.state.projectMeta,
costn_rows: (ctx.dbfData && ctx.dbfData.costn_rows) || [],
boq_rows: fullBoq,
kanjub_data: ctx.computed.kanjubComputed,
costn_memos: (ctx.dbfData && ctx.dbfData.costn_memos) || {},
recipes: (ctx.catalogData && ctx.catalogData.recipes) || [],
}),
});
if (!res.ok) throw new Error(await res.text());
downloadBlob(
await res.blob(),
`STMATE_Workbook_${ctx.state.projectMeta.pcode || "11"}.xlsx`,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
} catch (err) {
if (!global.STMATE_ENGINE) return alert("엑셀 내보내기 실패: " + err.message);
downloadBlob(
global.STMATE_ENGINE.buildMultiSheetExcelXml(ctx),
`STMATE_Workbook_${ctx.state.projectMeta.pcode || "11"}.xls`,
"application/vnd.ms-excel;charset=utf-8",
);
}
}
function downloadBlob(content, filename, mime) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
const MENU_SCREEN_MAP = {
Act_PrjList: "wPrjList",
Act_Init: "wM_Set_Sys",
Act_BoqEdit: "wBoq",
Act_KanJub: "wM_KanJub",
Act_KanRate: "wM_KAN_RATE",
Act_StageEdit: "wStage",
Act_Print: "PM_Prt",
};
const MENU_MODAL_MAP = {
Act_BoxaModal: ["5개 가격지 비교 (wM_Boxa)", "dan:wM_Boxa"],
Act_EdtHalModal: ["기준단가 비율 일괄수정 (wM_Edt_Hal)", "dan:wM_Edt_Hal"],
Act_CatalogModal: ["STMATE 표준 카탈로그 브라우저", "dan:CATALOG"],
Act_ITNumModal: ["내역서 공종번호 자동부여 (wITNum)", "boq:wITNum"],
Act_BoqRateModal: ["내역 할증율 적용 (wBoqRate)", "boq:wBoqRate"],
Act_MRateModal: ["비목 단수 일괄보정 (wM_Rate)", "boq:wM_Rate"],
Act_ExpndModal: ["일위대가 풀어넣기 (wExpnd)", "boq:wExpnd"],
Act_QtyModal: ["수량산출서 연결 집계 (wQtyList)", "boq:wQtyList"],
Act_ReaInfoModal: ["STMATE w 8.0 REA 정적분석 아키텍처 & DBF/SMT 명세", "rea:info"],
};
function openMappedModal(key) {
const [title, spec] = MENU_MODAL_MAP[key];
const [grp, kind] = spec.split(":");
openModal(
title,
grp === "dan"
? ViewDanIlwi.buildDanModalHtml(kind, ctx)
: grp === "boq"
? ViewPrjBoq.buildBoqToolModalHtml(kind, ctx)
: `<div style="line-height:1.6;"><p><strong>1. 실행 모듈:</strong> <code>Start8x.exe</code> · <code>ST_Sub1.dll</code> · <code>ST_Prt.CPM</code></p><p><strong>2. DBF/SMT 직결:</strong> <code>COSTN.DBF</code> · <code>COSTN.SMT</code> · <code>BOQ11~14.DBF</code> · <code>PRJLST.DBF/.SMT</code> · <code>RATE_R/RA/M.DBF</code></p></div>`,
);
}
function bindModalOpener(id, key) {
onClick(id, () => openMappedModal(key));
}
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
Object.assign(API, {
saveAll: saveAllToDbfServer,
decorateSavePayload,
openMappedModal,
postSave,
});
async function initStmateApp() {
onClick("modal-close-btn", closeModal);
// 원본 메뉴·툴바·단축키 — ui_app_menu.js(끼워 넣은 메뉴·동작 포함) · 옛 동작 이름은 아래 표로
global.STMATE_UI_APP_MENU.install(API);
$("main-menubar").addEventListener("click", (e) => {
const btn = e.target.closest("[data-act]");
const act = btn && btn.getAttribute("data-act");
if (!act || btn.disabled || PLUG.actions[act] || global.STMATE_UI_APP_MENU.H[act]) return;
if (MENU_SCREEN_MAP[act]) ctx.uiState.activeScreen = MENU_SCREEN_MAP[act];
else if (MENU_MODAL_MAP[act]) return openMappedModal(act);
renderWorkspace();
});
try {
const [srcRes, catRes] = await Promise.all([fetch("/api/sources"), fetch("/api/catalog")]);
if (srcRes.ok) ctx.sourcesData = await srcRes.json();
if (catRes.ok) ctx.catalogData = await catRes.json();
const sel = $("top-project-select");
if (sel && ctx.sourcesData.projects.length) {
sel.innerHTML = ctx.sourcesData.projects
.map((p) => `<option value="${p.path}">${p.name}</option>`)
.join("");
sel.onchange = () => loadProjectFromServer(sel.value);
const defaultPrj =
ctx.sourcesData.projects.find(
(p) => p.name.includes("[실무 STC]") && p.name.includes("기번41"),
) ||
ctx.sourcesData.projects.find((p) => p.name.includes("기번41")) ||
ctx.sourcesData.projects[0];
if (defaultPrj) return await loadProjectFromServer(defaultPrj.path, "wPrjList");
}
} catch (_e) {
// 오프라인 기본 폴백
}
renderWorkspace();
}
document.readyState === "loading"
? document.addEventListener("DOMContentLoaded", initStmateApp)
: initStmateApp();
})(window);