346 lines
15 KiB
JavaScript
346 lines
15 KiB
JavaScript
/* -*- coding: utf-8 -*- */
|
|
/**
|
|
* STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 컨트롤러 (db_bridge.js)
|
|
*/
|
|
const StmateDbBridge = (() => {
|
|
const API_BASE = "http://127.0.0.1:8095";
|
|
const esc = (s) =>
|
|
String(s ?? "").replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
|
);
|
|
const fmt = (n, maxDec = 2) =>
|
|
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0);
|
|
|
|
const dbCtx = {
|
|
connected: false,
|
|
sources: { projects: [], jbr_files: [], work_root: "" },
|
|
selectedProjectPath: "",
|
|
selectedJbrPath: "",
|
|
targetFolderName: "stmate_edited",
|
|
loadedDbf: null,
|
|
loadedJbr: null,
|
|
lastSaveResult: null,
|
|
statusMsg: "로컬 DB 브리지(127.0.0.1:8095) 연결 확인 중...",
|
|
};
|
|
|
|
async function checkAndLoadSources(onUpdate) {
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/api/sources`);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
dbCtx.connected = true;
|
|
dbCtx.sources = data;
|
|
if (!dbCtx.selectedProjectPath && data.projects.length > 0) {
|
|
dbCtx.selectedProjectPath = data.projects[0].path;
|
|
}
|
|
const onlyJbr = data.jbr_files.filter((f) => f.ext === ".JB_R");
|
|
if (!dbCtx.selectedJbrPath && onlyJbr.length > 0) {
|
|
dbCtx.selectedJbrPath = onlyJbr[onlyJbr.length - 1].path;
|
|
}
|
|
dbCtx.statusMsg = `연결됨 — PC 프로젝트 DB ${data.projects.length}건 · ST_BSC 제비율 DB ${data.jbr_files.length}건 감지`;
|
|
} catch (_) {
|
|
dbCtx.connected = false;
|
|
dbCtx.statusMsg =
|
|
"오프라인 — 터미널에서 .\\venv\\Scripts\\python.exe tools\\stmate-test\\server.py 실행 시 DBF 직접 읽기/쓰기 활성화";
|
|
}
|
|
if (onUpdate) onUpdate();
|
|
}
|
|
|
|
async function loadProjectDbf(applyStateCallback, onUpdate) {
|
|
if (!dbCtx.selectedProjectPath) return;
|
|
try {
|
|
dbCtx.statusMsg = `DBF/STC 읽는 중: ${dbCtx.selectedProjectPath} ...`;
|
|
if (onUpdate) onUpdate();
|
|
const resp = await fetch(`${API_BASE}/api/load`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ source_path: dbCtx.selectedProjectPath }),
|
|
});
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const data = await resp.json();
|
|
dbCtx.loadedDbf = data;
|
|
dbCtx.lastSaveResult = null;
|
|
dbCtx.statusMsg = `로드 완료: ${data.prjlst?.PNAME || data.source_path} (COSTN ${data.costn_count}건, ${data.boq_table_name} ${data.boq_count}건)`;
|
|
if (data.sim_state && applyStateCallback) {
|
|
applyStateCallback(data.sim_state);
|
|
}
|
|
} catch (err) {
|
|
dbCtx.statusMsg = `DBF 로드 실패: ${err.message || err}`;
|
|
if (onUpdate) onUpdate();
|
|
}
|
|
}
|
|
|
|
async function loadAndApplyJbr(state, saveAndRender) {
|
|
if (!dbCtx.selectedJbrPath) return;
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/api/load-jbr`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ jbr_path: dbCtx.selectedJbrPath }),
|
|
});
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const data = await resp.json();
|
|
dbCtx.loadedJbr = data;
|
|
const r = data.extracted_rates;
|
|
Object.assign(state.kanjubConfig.rates, {
|
|
KANNO: r.KANNO || state.kanjubConfig.rates.KANNO,
|
|
KITA: r.KITA || state.kanjubConfig.rates.KITA,
|
|
ILBAN: r.ILBAN || state.kanjubConfig.rates.ILBAN,
|
|
SANBO: r.SANBO,
|
|
KOBO: r.KOBO,
|
|
KUNBO: r.KUNBO,
|
|
YUNBO: r.YUNBO,
|
|
SLVBO: r.SLVBO,
|
|
IYUN: r.IYUN,
|
|
ANJUN: r.ANJUN,
|
|
});
|
|
state.kanjubConfig.anjunBaseAdd = r.anjunBaseAdd;
|
|
dbCtx.statusMsg = `ST_BSC 제비율 DB(${data.name}) 요율 적용 완료`;
|
|
if (saveAndRender) saveAndRender();
|
|
} catch (err) {
|
|
dbCtx.statusMsg = `JB_R 로드 실패: ${err.message || err}`;
|
|
if (saveAndRender) saveAndRender();
|
|
}
|
|
}
|
|
|
|
async function saveToDbf(state, calcRes, onUpdate) {
|
|
const srcPath = dbCtx.loadedDbf?.source_path || dbCtx.selectedProjectPath;
|
|
if (!srcPath) return;
|
|
try {
|
|
const k = calcRes.kanjubOrig;
|
|
const costnUpdates = (dbCtx.loadedDbf?.costn_rows || []).slice(0, 80).map((r) => ({
|
|
CODE: r.CODE,
|
|
NAME: r.NAME,
|
|
TAMT1: r.TAMT1,
|
|
LAMT1: r.LAMT1,
|
|
MAMT1: r.MAMT1,
|
|
SAMT1: r.SAMT1,
|
|
}));
|
|
// 시뮬레이터 기초자원 단가 변경분도 COSTN 업데이트에 병합
|
|
for (const resItem of calcRes.resources.list) {
|
|
costnUpdates.push({
|
|
CODE: resItem.code,
|
|
NAME: resItem.name,
|
|
TAMT1: resItem.adoptedPrice,
|
|
LAMT1: resItem.kind === "노무비" ? resItem.adoptedPrice : 0,
|
|
MAMT1: resItem.kind === "재료비" ? resItem.adoptedPrice : 0,
|
|
SAMT1: resItem.kind === "경비" ? resItem.adoptedPrice : 0,
|
|
});
|
|
}
|
|
|
|
const boqUpdates = calcRes.boqOrig.rows
|
|
.filter((r) => r._recno !== undefined)
|
|
.map((r) => ({
|
|
_recno: r._recno,
|
|
NAME1: r.name,
|
|
QTY1: r.qty,
|
|
MAMT1: r.unitInfo.M,
|
|
LAMT1: r.unitInfo.L,
|
|
SAMT1: r.unitInfo.S,
|
|
TAMT1: r.unitInfo.T,
|
|
}));
|
|
|
|
const prjlstUpdates = {
|
|
MCOST1: k.dM,
|
|
LCOST1: k.dL,
|
|
SCOST1: k.dS,
|
|
TCOST1: k.dT,
|
|
COSTW1: k.grandTotal,
|
|
};
|
|
const rates = state.kanjubConfig.rates;
|
|
const smtUpdates = {
|
|
"1_R_KANNO": rates.KANNO,
|
|
"1_R_SANBO": rates.SANBO,
|
|
"1_R_KOBO": rates.KOBO,
|
|
"1_R_KUNBO": rates.KUNBO,
|
|
"1_R_SLVBO": rates.SLVBO,
|
|
"1_R_YUNBO": rates.YUNBO,
|
|
"1_R_ANJUN": rates.ANJUN,
|
|
"1_R_T_KITA": rates.KITA,
|
|
"1_R_HWAN": rates.HWAN,
|
|
"1_R_EQIPSU": rates.EQIP,
|
|
"1_R_ILBAN": rates.ILBAN,
|
|
"1_R_IYUN": rates.IYUN,
|
|
"1_C_KANNO": k.kanno,
|
|
"1_C_ILBAN": k.ilbanAmt,
|
|
"1_C_IYUN_M": k.pretaxProfitAdj,
|
|
"1_C_KONGSA_J": k.contractBeforeAdj,
|
|
"1_C_UPDNKWN": k.govFinal,
|
|
};
|
|
|
|
const resp = await fetch(`${API_BASE}/api/save`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
source_path: srcPath,
|
|
target_folder_name: dbCtx.targetFolderName || "stmate_edited",
|
|
costn_updates: costnUpdates,
|
|
boq_updates: boqUpdates,
|
|
prjlst_updates: prjlstUpdates,
|
|
smt_updates: smtUpdates,
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const saved = await resp.json();
|
|
dbCtx.lastSaveResult = saved;
|
|
dbCtx.statusMsg = `DBF·STC 저장 완료: ${saved.saved_stc} (${fmt(saved.stc_size, 0)} bytes)`;
|
|
await checkAndLoadSources();
|
|
} catch (err) {
|
|
dbCtx.statusMsg = `DBF 저장 실패: ${err.message || err}`;
|
|
}
|
|
if (onUpdate) onUpdate();
|
|
}
|
|
|
|
function renderDbTab(state, calcRes) {
|
|
const projOpts = dbCtx.sources.projects
|
|
.map(
|
|
(p) =>
|
|
`<option value="${esc(p.path)}" ${dbCtx.selectedProjectPath === p.path ? "selected" : ""}>${esc(p.name)} — ${esc(p.path)}</option>`,
|
|
)
|
|
.join("");
|
|
const jbrOpts = dbCtx.sources.jbr_files
|
|
.map(
|
|
(f) =>
|
|
`<option value="${esc(f.path)}" ${dbCtx.selectedJbrPath === f.path ? "selected" : ""}>${esc(f.name)} (${fmt(f.size, 0)} B)</option>`,
|
|
)
|
|
.join("");
|
|
|
|
let dbfPreviewHtml =
|
|
'<div class="empty-box">상단에서 PC 내 STMATE 폴더 또는 <code>.STC</code> 파일을 선택하고 <strong>[원본 DBF/STC 불러오기]</strong>를 실행하세요.</div>';
|
|
if (dbCtx.loadedDbf) {
|
|
const d = dbCtx.loadedDbf;
|
|
const costnRows = (d.costn_rows || [])
|
|
.slice(0, 25)
|
|
.map(
|
|
(r, i) => `
|
|
<tr>
|
|
<td><code>${esc(r.CODE)}</code></td>
|
|
<td><input type="text" data-dbf="costn" data-idx="${i}" data-field="NAME" value="${esc(r.NAME)}"></td>
|
|
<td>${esc(r.SIZE)}</td>
|
|
<td class="center-col">${esc(r.UNIT)}</td>
|
|
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="MAMT1" value="${Number(r.MAMT1) || 0}" step="any"></td>
|
|
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="LAMT1" value="${Number(r.LAMT1) || 0}" step="any"></td>
|
|
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="SAMT1" value="${Number(r.SAMT1) || 0}" step="any"></td>
|
|
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="TAMT1" value="${Number(r.TAMT1) || 0}" step="any"></td>
|
|
<td class="money-col">${fmt(r.AMT_61, 0)}</td>
|
|
</tr>`,
|
|
)
|
|
.join("");
|
|
|
|
dbfPreviewHtml = `
|
|
<div class="sub-toolbar">
|
|
<span>현재 연결 원본: <strong>${esc(d.prjlst?.PNAME || d.source_path)}</strong> · 수록 테이블: <code>${esc(d.file_list.join(", "))}</code></span>
|
|
<span>원장 <code>COSTN.DBF</code> <strong>${d.costn_count}건</strong> · 내역 <code>${esc(d.boq_table_name)}</code> <strong>${d.boq_count}건</strong> · <code>PRJLST.SMT</code> 설정키 <strong>${Object.keys(d.prjlst_smt || {}).length}개</strong></span>
|
|
</div>
|
|
<div class="table-scroll">
|
|
<table class="data-table">
|
|
<thead><tr><th>CODE</th><th>품명(<code>NAME</code>)</th><th>규격(<code>SIZE</code>)</th><th>단위</th><th>재료단가(<code>MAMT1</code>)</th><th>노무단가(<code>LAMT1</code>)</th><th>경비단가(<code>SAMT1</code>)</th><th>합계단가(<code>TAMT1</code>)</th><th>채택6칸(<code>AMT_61</code>)</th></tr></thead>
|
|
<tbody>${costnRows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
const saveAlert = dbCtx.lastSaveResult
|
|
? `<div class="sub-toolbar"><span>최근 쓰기 완료 폴더: <code>${esc(dbCtx.lastSaveResult.saved_dir)}</code></span><span>생성된 패키지: <code>${esc(dbCtx.lastSaveResult.saved_stc)}</code> (${fmt(dbCtx.lastSaveResult.stc_size, 0)} bytes, 파일 ${dbCtx.lastSaveResult.written_count}종)</span></div>`
|
|
: "";
|
|
|
|
return `
|
|
<div class="panel-card">
|
|
<div class="card-header">
|
|
<div>
|
|
<h2>PC 내 STMATE 원본 DBF · SMT · STC · JB_R 직접 읽기/쓰기 연동</h2>
|
|
<p><code>C:\\경영정보(주)</code> 설치 폴더의 프로젝트 DBF(<code>COSTN.DBF</code>, <code>BOQ11.DBF</code>, <code>PRJLST.DBF/.SMT</code>), <code>System\\ST_BSC\\*.JB_R</code> 제비율 기준 DB, 그리고 실무 <code>.STC</code> 패키지를 직접 읽고 CP949 고정폭 바이너리 DBF로 다시 기록합니다.</p>
|
|
</div>
|
|
<div>
|
|
<button type="button" class="btn secondary" data-db-action="refresh-sources">연결/목록 새로고침</button>
|
|
</div>
|
|
</div>
|
|
<div class="sub-toolbar">
|
|
<span>상태: <strong>${esc(dbCtx.statusMsg)}</strong></span>
|
|
<span>브리지 주소: <code>${API_BASE}</code></span>
|
|
</div>
|
|
<div class="control-grid">
|
|
<label class="ctrl-item">
|
|
<span>1. 프로젝트 원본 DB 폴더 또는 <code>.STC</code> 선택</span>
|
|
<select id="selDbProject">${projOpts || '<option value="">감지된 프로젝트 없음</option>'}</select>
|
|
</label>
|
|
<div class="ctrl-item" style="justify-content:flex-end;">
|
|
<button type="button" class="btn primary" data-db-action="load-project" ${!dbCtx.connected ? "disabled" : ""}>원본 DBF/STC 불러오기 (시뮬레이터 동기화)</button>
|
|
</div>
|
|
<label class="ctrl-item">
|
|
<span>2. 시스템 제비율 DB (<code>C:\\경영정보(주)\\System\\ST_BSC\\*.JB_R</code>) 선택</span>
|
|
<select id="selDbJbr">${jbrOpts || '<option value="">감지된 JB_R 없음</option>'}</select>
|
|
</label>
|
|
<div class="ctrl-item" style="justify-content:flex-end;">
|
|
<button type="button" class="btn secondary" data-db-action="load-jbr" ${!dbCtx.connected ? "disabled" : ""}>선택한 .JB_R 제비율 읽어서 원가계산 적용</button>
|
|
</div>
|
|
<label class="ctrl-item">
|
|
<span>3. DBF/STC 쓰기 대상 폴더명 (<code>tools/stmate-test/work_dbf/</code> 하위)</span>
|
|
<input type="text" id="inpTargetFolder" value="${esc(dbCtx.targetFolderName)}">
|
|
</label>
|
|
<div class="ctrl-item" style="justify-content:flex-end;">
|
|
<button type="button" class="btn primary" data-db-action="save-dbf" ${!dbCtx.connected ? "disabled" : ""}>현재 단가·내역·원가 결과를 DBF 및 .STC로 직접 쓰기</button>
|
|
</div>
|
|
</div>
|
|
${saveAlert}
|
|
</div>
|
|
<div class="panel-card">
|
|
<div class="card-header">
|
|
<div>
|
|
<h2>원본 <code>COSTN.DBF</code> 레코드 직접 조회 및 편집 (상위 25건 미리보기)</h2>
|
|
<p>여기서 수정한 단가와 01~06 탭에서 조작한 수량·요율·원가 결과가 <strong>[DBF 및 .STC로 직접 쓰기]</strong> 실행 시 <code>COSTN.DBF</code>, <code>BOQ11/12.DBF</code>, <code>PRJLST.DBF/.SMT</code>에 기록됩니다.</p>
|
|
</div>
|
|
</div>
|
|
${dbfPreviewHtml}
|
|
</div>`;
|
|
}
|
|
|
|
function initEvents(getState, setState, saveAndRender) {
|
|
document.addEventListener("click", (e) => {
|
|
const btn = e.target.closest("[data-db-action]");
|
|
if (!btn) return;
|
|
const act = btn.dataset.dbAction;
|
|
if (act === "refresh-sources") {
|
|
checkAndLoadSources(saveAndRender);
|
|
} else if (act === "load-project") {
|
|
loadProjectDbf((newState) => {
|
|
setState(newState);
|
|
saveAndRender();
|
|
}, saveAndRender);
|
|
} else if (act === "load-jbr") {
|
|
loadAndApplyJbr(getState(), saveAndRender);
|
|
} else if (act === "save-dbf") {
|
|
const st = getState();
|
|
saveToDbf(st, StmateCalc.calculateAll(st), saveAndRender);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("change", (e) => {
|
|
const el = e.target;
|
|
if (el.id === "selDbProject") dbCtx.selectedProjectPath = el.value;
|
|
else if (el.id === "selDbJbr") dbCtx.selectedJbrPath = el.value;
|
|
else if (el.id === "inpTargetFolder") dbCtx.targetFolderName = el.value;
|
|
else if (el.dataset.dbf === "costn" && dbCtx.loadedDbf?.costn_rows) {
|
|
const idx = Number(el.dataset.idx);
|
|
const fld = el.dataset.field;
|
|
if (dbCtx.loadedDbf.costn_rows[idx]) {
|
|
dbCtx.loadedDbf.costn_rows[idx][fld] = el.value;
|
|
}
|
|
}
|
|
});
|
|
|
|
checkAndLoadSources(saveAndRender);
|
|
}
|
|
|
|
return {
|
|
dbCtx,
|
|
initEvents,
|
|
checkAndLoadSources,
|
|
loadProjectDbf,
|
|
loadAndApplyJbr,
|
|
saveToDbf,
|
|
renderDbTab,
|
|
};
|
|
})();
|