feat(tools): STMATE 계산 사슬 및 제어 옵션 독립 테스트 웹앱 추가 (tools/stmate-test)
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
/* -*- coding: utf-8 -*- */
|
||||
/**
|
||||
* STMATE 계산 사슬 및 제어 옵션 시뮬레이터 UI 컨트롤러 (app.js)
|
||||
*/
|
||||
const STORAGE_KEY = "aislo_stmate_test_v1";
|
||||
|
||||
function createDefaultSample() {
|
||||
const mkRes = (id, name, spec, unit, kind, candidates, manualPrice, convertHourly = false) => ({
|
||||
id,
|
||||
code: id,
|
||||
name,
|
||||
spec,
|
||||
unit,
|
||||
kind,
|
||||
candidates,
|
||||
adoptMode: "min",
|
||||
manualPrice,
|
||||
convertHourly,
|
||||
isUsd: false,
|
||||
});
|
||||
return {
|
||||
sysConfig: {
|
||||
rxFormula: "1/8*16/12*25/20",
|
||||
condCodes: "L00008, L00009, L00010",
|
||||
usdRate1: 1350,
|
||||
usdRate2: 1400,
|
||||
useChangedUsd: false,
|
||||
ecdDigits: 0,
|
||||
chnMode: "bimok",
|
||||
optOrder: "SML",
|
||||
kOrdSim: { pclass: 7, regMode: 7, flag: 0, currentK: 2 },
|
||||
},
|
||||
resources: [
|
||||
mkRes(
|
||||
"L00008",
|
||||
"건설기계운전사 (일노임→시간당)",
|
||||
"표준직종",
|
||||
"hr",
|
||||
"노무비",
|
||||
[0, 0, 0, 0, 267360],
|
||||
267360,
|
||||
true,
|
||||
),
|
||||
mkRes("L00001", "보통인부", "시중노임", "인", "노무비", [165545, 0, 0, 0, 165545], 165545),
|
||||
mkRes("L00002", "특별인부", "시중노임", "인", "노무비", [212410, 0, 0, 0, 212410], 212410),
|
||||
mkRes(
|
||||
"M00001",
|
||||
"경유 (중기연료)",
|
||||
"저유황",
|
||||
"L",
|
||||
"재료비",
|
||||
[1520, 1480, 1453, 1510, 1490],
|
||||
1453,
|
||||
),
|
||||
mkRes(
|
||||
"M00002",
|
||||
"레미콘 (사급자재)",
|
||||
"25-24-150",
|
||||
"㎥",
|
||||
"재료비",
|
||||
[92000, 89500, 91000, 88700, 90000],
|
||||
88700,
|
||||
),
|
||||
mkRes(
|
||||
"M00901",
|
||||
"관급 원심력콘크리트관(흄관)",
|
||||
"D1000mm",
|
||||
"본",
|
||||
"재료비",
|
||||
[285000, 279000, 281000, 0, 279000],
|
||||
279000,
|
||||
),
|
||||
mkRes(
|
||||
"S00902",
|
||||
"조달수수료 (관급부대비)",
|
||||
"0.54%",
|
||||
"식",
|
||||
"경비",
|
||||
[771029, 0, 0, 0, 771029],
|
||||
771029,
|
||||
),
|
||||
],
|
||||
machines: [
|
||||
{
|
||||
id: "X00001",
|
||||
code: "X00001",
|
||||
name: "굴삭기 (무한궤도)",
|
||||
spec: "0.2 ㎥",
|
||||
unit: "hr",
|
||||
operatorId: "L00008",
|
||||
operatorCount: 1,
|
||||
fuelId: "M00001",
|
||||
fuelConsumption: 5.0,
|
||||
miscRatePercent: 6.0,
|
||||
acquisitionPrice: 41800000,
|
||||
hourlyRateFactor: 0.000312,
|
||||
},
|
||||
],
|
||||
sanItems: [
|
||||
{
|
||||
id: "D00002",
|
||||
code: "D00002",
|
||||
name: "구조물 터파기(토사) 굴삭기 0.2㎥",
|
||||
spec: "기계 100%",
|
||||
unit: "㎥",
|
||||
machineId: "X00001",
|
||||
formulaType: "3600_qKfE_Cm",
|
||||
q: 0.2,
|
||||
K: 0.7,
|
||||
f: 0.85,
|
||||
E: 0.55,
|
||||
Cm: 15,
|
||||
},
|
||||
],
|
||||
ilwiItems: [
|
||||
{
|
||||
id: "B00001",
|
||||
code: "B00001",
|
||||
name: "소형 구조물 기초 콘크리트 타설 및 터파기 복합공",
|
||||
spec: "인력+기계복합",
|
||||
unit: "㎥",
|
||||
lines: [
|
||||
{ refId: "M00002", qty: 1.0, halPercent: 2.0, devd: false },
|
||||
{ refId: "L00001", qty: 0.35, halPercent: 0, devd: false },
|
||||
{ refId: "L00002", qty: 0.15, halPercent: 0, devd: false },
|
||||
{ refId: "D00002", qty: 1.2, halPercent: 0, devd: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
boqItems: [
|
||||
{
|
||||
id: "BOQ1",
|
||||
code: "D00002",
|
||||
name: "본선 구조물 터파기 (토사)",
|
||||
refId: "D00002",
|
||||
rowType: "normal",
|
||||
qty: 1250.5,
|
||||
changedQty: 1420.0,
|
||||
},
|
||||
{
|
||||
id: "BOQ2",
|
||||
code: "B00001",
|
||||
name: "배수 구조물 콘크리트 및 기초 복합시공",
|
||||
refId: "B00001",
|
||||
rowType: "normal",
|
||||
qty: 340.3,
|
||||
changedQty: 395.0,
|
||||
},
|
||||
{
|
||||
id: "BOQ3",
|
||||
code: "M00901",
|
||||
name: "[제외구역 C4] 관급 흄관 자재대 (원자재)",
|
||||
refId: "M00901",
|
||||
rowType: "gov_mat",
|
||||
qty: 120,
|
||||
changedQty: 120,
|
||||
},
|
||||
{
|
||||
id: "BOQ4",
|
||||
code: "S00902",
|
||||
name: "[제외구역 C4] 관급 조달수수료",
|
||||
refId: "S00902",
|
||||
rowType: "gov_fee",
|
||||
qty: 1,
|
||||
changedQty: 1,
|
||||
},
|
||||
],
|
||||
kanjubConfig: {
|
||||
scheme: "T",
|
||||
indirectTruncDigits: 0,
|
||||
anjunBaseAdd: 5349000,
|
||||
anjunGovVatExcluded: true,
|
||||
anjunCap12: true,
|
||||
vatMode: "supply_10",
|
||||
totJeolDigits: -3,
|
||||
enableProfitEndAdj: true,
|
||||
govRoundMode: "up",
|
||||
govRoundDigits: -3,
|
||||
rates: {
|
||||
KANNO: 14.5,
|
||||
SANBO: 3.56,
|
||||
KOBO: 1.01,
|
||||
KUNBO: 3.545,
|
||||
SLVBO: 12.95,
|
||||
YUNBO: 4.5,
|
||||
TBUGUM: 0.0,
|
||||
ANJUN: 1.86,
|
||||
KITA: 6.0,
|
||||
HWAN: 0.8,
|
||||
EQIP: 0.4,
|
||||
ILBAN: 6.0,
|
||||
IYUN: 15.0,
|
||||
S_SUDO: 0.45,
|
||||
S_BOKRI: 1.15,
|
||||
S_SOMO: 0.65,
|
||||
S_TONGSIN: 0.85,
|
||||
S_SEGUM: 0.55,
|
||||
S_PEGI: 0.35,
|
||||
S_DOSEO: 0.25,
|
||||
S_JIGUP: 0.45,
|
||||
},
|
||||
revModes: {
|
||||
KANNO: "recalculated",
|
||||
SANBO: "recalculated",
|
||||
KOBO: "recalculated",
|
||||
KUNBO: "carry_original",
|
||||
SLVBO: "carry_original",
|
||||
YUNBO: "carry_original",
|
||||
TBUGUM: "recalculated",
|
||||
ANJUN: "recalculated",
|
||||
KITA: "recalculated",
|
||||
HWAN: "recalculated",
|
||||
EQIP: "recalculated",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let state = loadState();
|
||||
let activeTab = "resources";
|
||||
let selectedIlwiId = state.ilwiItems[0]?.id || "";
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return createDefaultSample();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.resources) && parsed.kanjubConfig) return parsed;
|
||||
} catch (_) {}
|
||||
return createDefaultSample();
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
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 uid = (prefix) =>
|
||||
`${prefix}${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 5)}`;
|
||||
|
||||
function renderAll() {
|
||||
const res = StmateCalc.calculateAll(state);
|
||||
renderKpi(res);
|
||||
|
||||
document.querySelectorAll("#mainTabs button[data-tab]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.tab === activeTab);
|
||||
});
|
||||
|
||||
const panel = $("tabPanel");
|
||||
if (activeTab === "resources") panel.innerHTML = renderResourcesTab(res);
|
||||
else if (activeTab === "machines") panel.innerHTML = renderMachinesTab(res);
|
||||
else if (activeTab === "ilwi") panel.innerHTML = renderIlwiTab(res);
|
||||
else if (activeTab === "boq") panel.innerHTML = StmateCostViews.renderBoqTab(state, res);
|
||||
else if (activeTab === "kanjub") panel.innerHTML = StmateCostViews.renderKanjubTab(state, res);
|
||||
else if (activeTab === "revision")
|
||||
panel.innerHTML = StmateCostViews.renderRevisionTab(state, res);
|
||||
}
|
||||
|
||||
function renderKpi(res) {
|
||||
const k = res.kanjubOrig,
|
||||
kr = res.kanjubRev;
|
||||
const cards = [
|
||||
[
|
||||
"직접공사비 (재+직노+산출경비)",
|
||||
`${fmt(k.dT, 0)} 원`,
|
||||
`재료 ${fmt(k.dM, 0)} · 직노 ${fmt(k.dL, 0)} · 산출경비 ${fmt(k.dS, 0)}`,
|
||||
],
|
||||
[
|
||||
"순공사원가 (직접비+간접비)",
|
||||
`${fmt(k.pureConstructionCost, 0)} 원`,
|
||||
`간접노무비 ${fmt(k.kanno, 0)} · 제경비소계 ${fmt(k.indirectExpenseSum, 0)}`,
|
||||
],
|
||||
[
|
||||
"도급공사비 (끝전보정 후)",
|
||||
`${fmt(k.contractFinal, 0)} 원`,
|
||||
`이윤 세전조정: ${k.pretaxProfitAdj >= 0 ? "-" : "+"}${fmt(Math.abs(k.pretaxProfitAdj), 0)} 원`,
|
||||
],
|
||||
[
|
||||
"총공사비 (도급 + 관급자재)",
|
||||
`${fmt(k.grandTotal, 0)} 원`,
|
||||
`관급자재대 ${fmt(k.govFinal, 0)} · 변경차액 ${kr.grandTotal - k.grandTotal >= 0 ? "+" : ""}${fmt(kr.grandTotal - k.grandTotal, 0)}`,
|
||||
],
|
||||
];
|
||||
$("kpiSummary").innerHTML = cards
|
||||
.map(
|
||||
([title, val, sub]) =>
|
||||
`<div class="kpi-card"><span class="kpi-label">${esc(title)}</span><strong class="kpi-value">${esc(val)}</strong><span class="kpi-sub">${esc(sub)}</span></div>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderResourcesTab(res) {
|
||||
const sys = state.sysConfig;
|
||||
const rowsHtml = res.resources.list
|
||||
.map((r, i) => {
|
||||
const candInputs = [0, 1, 2, 3, 4]
|
||||
.map(
|
||||
(ci) =>
|
||||
`<td class="num-col"><input type="number" data-sec="cand" data-idx="${i}" data-cidx="${ci}" value="${r.cands[ci] || 0}" min="0" step="any"></td>`,
|
||||
)
|
||||
.join("");
|
||||
const modeOpts = [
|
||||
["min", "최저가 자동(0제외)"],
|
||||
["slot1", "후보1(조달) 고정"],
|
||||
["slot2", "후보2(물자) 고정"],
|
||||
["slot3", "후보3(물정) 고정"],
|
||||
["slot4", "후보4(거래) 고정"],
|
||||
["slot5", "후보5(견적) 고정"],
|
||||
["manual", "수동단가 직접적용"],
|
||||
]
|
||||
.map(
|
||||
([v, l]) => `<option value="${v}" ${r.adoptMode === v ? "selected" : ""}>${l}</option>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const wageBadge = r.wageInfo
|
||||
? `<div class="sub-hint">순차평가: <strong>${fmt(r.wageInfo.seqResult, 0)}원</strong> vs 선계수곱: ${fmt(r.wageInfo.preCalcResult, 0)}원 ${r.wageInfo.seqResult !== r.wageInfo.preCalcResult ? '<span class="diff-warn">(1원차이 발생!)</span>' : ""}</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><input type="text" data-sec="resources" data-idx="${i}" data-field="code" value="${esc(r.code)}" class="code-input"></td>
|
||||
<td><input type="text" data-sec="resources" data-idx="${i}" data-field="name" value="${esc(r.name)}">${wageBadge}</td>
|
||||
<td><input type="text" data-sec="resources" data-idx="${i}" data-field="unit" value="${esc(r.unit)}" class="short-input"></td>
|
||||
<td><select data-sec="resources" data-idx="${i}" data-field="kind">${StmateCalc.KINDS.map((k) => `<option value="${k}" ${r.kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></td>
|
||||
${candInputs}
|
||||
<td><select data-sec="resources" data-idx="${i}" data-field="adoptMode">${modeOpts}</select></td>
|
||||
<td class="center-col"><input type="checkbox" data-sec="resources" data-idx="${i}" data-field="convertHourly" ${r.convertHourly ? "checked" : ""} ${r.kind !== "노무비" ? "disabled" : ""}></td>
|
||||
<td class="money-col highlight-col"><strong>${fmt(r.adoptedPrice, 0)}</strong><div class="tiny-tag">${esc(r.chosenLabel)}</div></td>
|
||||
<td class="center-col"><button type="button" class="btn-del" data-action="del-res" data-idx="${i}">삭제</button></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>초기값 설정 및 노임 시간당 환산 (<code>ST_Sys.CPM</code> · <code>INITIAL</code>)</h2>
|
||||
<p>STMATE는 <code>RXNM_</code>에 저장된 식(<code>1/8*16/12*25/20</code>)을 <strong>왼쪽부터 차례로 평가</strong>합니다. 계수를 미리 곱하면 부동소수점 오차로 55,699원이 되어 1원 오차가 발생합니다(18번 문서 §2.1).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-grid">
|
||||
<label class="ctrl-item"><span>노임 시간당 환산식 (<code>RXNM_</code>)</span><input type="text" data-sec="sysConfig" data-field="rxFormula" value="${esc(sys.rxFormula)}"></label>
|
||||
<label class="ctrl-item"><span>시간당 환산 직종 코드 (<code>COND_</code>)</span><input type="text" data-sec="sysConfig" data-field="condCodes" value="${esc(sys.condCodes)}"></label>
|
||||
<label class="ctrl-item"><span>당초 USD 환율 (<code>EXLNM1</code>)</span><input type="number" data-sec="sysConfig" data-field="usdRate1" value="${sys.usdRate1}" step="any"></label>
|
||||
<label class="ctrl-item">
|
||||
<span>달러 장비 단가 소수점 (<code>ECDNM</code>)</span>
|
||||
<select data-sec="sysConfig" data-field="ecdDigits">
|
||||
<option value="0" ${Number(sys.ecdDigits) === 0 ? "selected" : ""}>0 자리 (원 단위)</option>
|
||||
<option value="1" ${Number(sys.ecdDigits) === 1 ? "selected" : ""}>1 자리</option>
|
||||
<option value="2" ${Number(sys.ecdDigits) === 2 ? "selected" : ""}>2 자리</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>기초자원 단가 원장 및 5개 가격지 후보 자동채택 (<code>COSTN.DBF</code> · <code>JMKN1..6</code>)</h2>
|
||||
<p>0이 아닌 후보 단가 중 <strong>최저가 자동채택</strong>(실무 76/76건 일치, 17번 문서 §5.5) 또는 특정 가격지 슬롯 수동고정을 실험합니다.</p>
|
||||
</div>
|
||||
<button type="button" class="btn primary" data-action="add-res">+ 기초자원 추가</button>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>코드</th><th>품명 / 규격</th><th>단위</th><th>비목</th><th>후보1(조달)</th><th>후보2(물자)</th><th>후보3(물정)</th><th>후보4(거래)</th><th>후보5(견적)</th><th>채택 규칙</th><th>시간환산</th><th>최종 채택단가(6칸)</th><th>관리</th></tr></thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMachinesTab(res) {
|
||||
const laborOpts = state.resources
|
||||
.filter((r) => r.kind === "노무비")
|
||||
.map((r) => [r.id, `${r.code} ${r.name}`]);
|
||||
const matOpts = state.resources
|
||||
.filter((r) => r.kind === "재료비")
|
||||
.map((r) => [r.id, `${r.code} ${r.name}`]);
|
||||
const machOpts = state.machines.map((m) => [m.id, `${m.code} ${m.name} (${m.spec})`]);
|
||||
|
||||
const machRows = res.machines.list
|
||||
.map(
|
||||
(m, i) => `
|
||||
<tr>
|
||||
<td><input type="text" data-sec="machines" data-idx="${i}" data-field="code" value="${esc(m.code)}" class="code-input"></td>
|
||||
<td><input type="text" data-sec="machines" data-idx="${i}" data-field="name" value="${esc(m.name)}"></td>
|
||||
<td><input type="text" data-sec="machines" data-idx="${i}" data-field="spec" value="${esc(m.spec)}" class="short-input"></td>
|
||||
<td><select data-sec="machines" data-idx="${i}" data-field="operatorId">${laborOpts.map(([v, l]) => `<option value="${esc(v)}" ${m.operatorId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
|
||||
<td><select data-sec="machines" data-idx="${i}" data-field="fuelId">${matOpts.map(([v, l]) => `<option value="${esc(v)}" ${m.fuelId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
|
||||
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="fuelConsumption" value="${m.fuelConsumption}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="miscRatePercent" value="${m.miscRatePercent}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="acquisitionPrice" value="${m.acquisitionPrice}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="hourlyRateFactor" value="${m.hourlyRateFactor}" step="any"></td>
|
||||
<td class="money-col">${fmt(m.M, 0)}</td>
|
||||
<td class="money-col">${fmt(m.L, 0)}</td>
|
||||
<td class="money-col">${fmt(m.S, 0)}</td>
|
||||
<td class="money-col highlight-col"><strong>${fmt(m.T, 0)}</strong></td>
|
||||
<td class="center-col"><button type="button" class="btn-del" data-action="del-mach" data-idx="${i}">삭제</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const sanRows = res.sanCal.list
|
||||
.map(
|
||||
(d, i) => `
|
||||
<tr>
|
||||
<td><input type="text" data-sec="sanItems" data-idx="${i}" data-field="code" value="${esc(d.code)}" class="code-input"></td>
|
||||
<td><input type="text" data-sec="sanItems" data-idx="${i}" data-field="name" value="${esc(d.name)}"></td>
|
||||
<td><select data-sec="sanItems" data-idx="${i}" data-field="machineId">${machOpts.map(([v, l]) => `<option value="${esc(v)}" ${d.machineId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
|
||||
<td>
|
||||
<select data-sec="sanItems" data-idx="${i}" data-field="formulaType">
|
||||
<option value="3600_qKfE_Cm" ${d.formulaType === "3600_qKfE_Cm" ? "selected" : ""}>3600*q*K*f*E/Cm (초단위)</option>
|
||||
<option value="60_qKfE_Cm" ${d.formulaType === "60_qKfE_Cm" ? "selected" : ""}>60*q*K*f*E/Cm (분단위)</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="q" value="${d.q}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="K" value="${d.K}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="f" value="${d.f}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="E" value="${d.E}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="Cm" value="${d.Cm}" step="any"></td>
|
||||
<td class="money-col"><strong>${fmt(d.Q, 2)}</strong><div class="tiny-tag">원값 ${fmt(d.rawQ, 3)}</div></td>
|
||||
<td class="money-col">${fmt(d.M, 1)}<div class="tiny-tag">내역 ${fmt(d.intM, 0)}</div></td>
|
||||
<td class="money-col">${fmt(d.L, 1)}<div class="tiny-tag">내역 ${fmt(d.intL, 0)}</div></td>
|
||||
<td class="money-col">${fmt(d.S, 1)}<div class="tiny-tag">내역 ${fmt(d.intS, 0)}</div></td>
|
||||
<td class="money-col highlight-col"><strong>${fmt(d.T, 1)}</strong><div class="tiny-tag">내역합 ${fmt(d.intT, 0)}</div></td>
|
||||
<td class="center-col"><button type="button" class="btn-del" data-action="del-san" data-idx="${i}">삭제</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>시간당 중기사용료 (<code>X</code> 코드 · 중기사용료 시트)</h2>
|
||||
<p>중기 한 대를 참조할 때 노무비(<code>&X..L&</code>), 재료비(<code>&X..M&</code>), 경비(<code>&X..S&</code>) 세 성분을 각각 원 단위 절사하여 산출합니다(17번 문서 §3).</p>
|
||||
</div>
|
||||
<button type="button" class="btn primary" data-action="add-mach">+ 중기 추가</button>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>코드</th><th>기계명</th><th>규격</th><th>운전원(L)</th><th>주연료(M)</th><th>연료량(L/hr)</th><th>잡품률(%)</th><th>취득가격(원)</th><th>시간손료계수</th><th>재료비(M)</th><th>노무비(L)</th><th>경비(S)</th><th>시간당합계(T)</th><th>관리</th></tr></thead>
|
||||
<tbody>${machRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>단가산출근거 (<code>D</code> 코드 · <code>ST_SanCal.CPM</code> · <code>COSTN.SMT</code>)</h2>
|
||||
<p>작업량 <code>Q</code>를 <strong>소수 2자리로 먼저 확정</strong>한 뒤, 중기 시간당 성분단가를 <code>Q</code>로 나누어 <strong>소수 1자리 절사</strong>합니다. 내역서 수록 시에는 정수 절사값이 전달됩니다(18번 문서 §2.2).</p>
|
||||
</div>
|
||||
<button type="button" class="btn primary" data-action="add-san">+ 단가산출(D) 추가</button>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>코드</th><th>단가산출 공종명</th><th>참조 중기(<code>&X..&</code>)</th><th>Q 산출식 유형</th><th>q(용량)</th><th>K(계수)</th><th>f(토량)</th><th>E(효율)</th><th>Cm(싸이클)</th><th>작업량 Q(㎥/hr)</th><th>재료비/Q</th><th>노무비/Q</th><th>경비/Q</th><th>호표소계(소수1)</th><th>관리</th></tr></thead>
|
||||
<tbody>${sanRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderIlwiTab(res) {
|
||||
if (!state.ilwiItems.some((x) => x.id === selectedIlwiId))
|
||||
selectedIlwiId = state.ilwiItems[0]?.id || "";
|
||||
const curIdx = state.ilwiItems.findIndex((x) => x.id === selectedIlwiId);
|
||||
const curIlwi = res.ilwi.list[curIdx];
|
||||
|
||||
const allRefChoices = [
|
||||
...state.resources.map((r) => [r.id, `[기초 ${r.kind}] ${r.code} ${r.name} (${r.unit})`]),
|
||||
...state.machines.map((m) => [m.id, `[중기 X] ${m.code} ${m.name} (${m.unit})`]),
|
||||
...state.sanItems.map((d) => [d.id, `[단산 D] ${d.code} ${d.name} (${d.unit})`]),
|
||||
];
|
||||
|
||||
let detailHtml = '<div class="empty-box">일위대가 항목을 추가해 주세요.</div>';
|
||||
if (curIlwi) {
|
||||
const lineRows = curIlwi.lines
|
||||
.map(
|
||||
(ln, li) => `
|
||||
<tr>
|
||||
<td><select data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="refId">${allRefChoices.map(([v, l]) => `<option value="${esc(v)}" ${ln.refId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
|
||||
<td class="num-col"><input type="number" data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="qty" value="${ln.qty}" step="any"></td>
|
||||
<td class="center-col"><input type="checkbox" data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="devd" ${ln.devd ? "checked" : ""}></td>
|
||||
<td class="num-col"><input type="number" data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="halPercent" value="${ln.halPercent}" step="any"></td>
|
||||
<td class="money-col">${fmt(ln.appliedQty, 4)}</td>
|
||||
<td class="money-col">${fmt(ln.lineM, 1)}</td>
|
||||
<td class="money-col">${fmt(ln.lineL, 1)}</td>
|
||||
<td class="money-col">${fmt(ln.lineS, 1)}</td>
|
||||
<td class="money-col"><strong>${fmt(ln.lineT, 1)}</strong></td>
|
||||
<td class="center-col"><button type="button" class="btn-del" data-action="del-ilwi-line" data-pidx="${curIdx}" data-idx="${li}">삭제</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
detailHtml = `
|
||||
<div class="control-grid">
|
||||
<label class="ctrl-item"><span>일위대가 선택</span><select id="selIlwiPicker">${state.ilwiItems.map((x) => `<option value="${esc(x.id)}" ${x.id === selectedIlwiId ? "selected" : ""}>${esc(x.code)} - ${esc(x.name)}</option>`).join("")}</select></label>
|
||||
<label class="ctrl-item"><span>일위대가 코드 (<code>BDCODE</code>)</span><input type="text" data-sec="ilwiItems" data-idx="${curIdx}" data-field="code" value="${esc(curIlwi.code)}"></label>
|
||||
<label class="ctrl-item"><span>공종명</span><input type="text" data-sec="ilwiItems" data-idx="${curIdx}" data-field="name" value="${esc(curIlwi.name)}"></label>
|
||||
<label class="ctrl-item"><span>단위</span><input type="text" data-sec="ilwiItems" data-idx="${curIdx}" data-field="unit" value="${esc(curIlwi.unit)}"></label>
|
||||
</div>
|
||||
<div class="sub-toolbar">
|
||||
<span>호표 성분 소계(원 단위 절사): 재료비 <strong>${fmt(curIlwi.M, 0)}원</strong> · 노무비 <strong>${fmt(curIlwi.L, 0)}원</strong> · 경비 <strong>${fmt(curIlwi.S, 0)}원</strong> → 합계 <strong>${fmt(curIlwi.T, 0)}원</strong></span>
|
||||
<div>
|
||||
<button type="button" class="btn secondary" data-action="add-ilwi-line" data-pidx="${curIdx}">+ 구성 행 추가</button>
|
||||
<button type="button" class="btn-del" data-action="del-ilwi" data-idx="${curIdx}">현재 호표 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>참조 자원 / 단가산출</th><th>원수량(<code>QTY1</code>)</th><th>역수(<code>DEVD1</code>)</th><th>할증(<code>X_HAL %</code>)</th><th>적용수량</th><th>재료비</th><th>노무비</th><th>경비</th><th>행 합계</th><th>관리</th></tr></thead>
|
||||
<tbody>${lineRows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>일위대가표 구성 및 수량 변환 (<code>ST_EdiLWi.CPM</code> · <code>BDQTY.DBF</code>)</h2>
|
||||
<p>구성 자원의 수량 할증(<code>X_HAL</code>) 및 나눗셈 수량 역수 변환(<code>DEVD1: 1/QTY1</code>, 16단계·30단계 REA 메모)을 적용하고, 호표 소계에서 각 성분을 원 단위로 절사하여 <code>TAMT = MAMT + LAMT + SAMT</code> 불변식을 유지합니다.</p>
|
||||
</div>
|
||||
<button type="button" class="btn primary" data-action="add-ilwi">+ 새 일위대가 추가</button>
|
||||
</div>
|
||||
${detailHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const tabBtn = e.target.closest("#mainTabs button[data-tab]");
|
||||
if (tabBtn) {
|
||||
activeTab = tabBtn.dataset.tab;
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const actBtn = e.target.closest("[data-action]");
|
||||
if (!actBtn) return;
|
||||
const act = actBtn.dataset.action,
|
||||
idx = Number(actBtn.dataset.idx),
|
||||
pidx = Number(actBtn.dataset.pidx);
|
||||
if (act === "add-res") {
|
||||
state.resources.push(
|
||||
Object.assign(createDefaultSample().resources[3], {
|
||||
id: uid("R"),
|
||||
code: `M00${state.resources.length + 10}`,
|
||||
name: "신규 시험 자재",
|
||||
}),
|
||||
);
|
||||
} else if (act === "del-res") state.resources.splice(idx, 1);
|
||||
else if (act === "add-mach") {
|
||||
state.machines.push(
|
||||
Object.assign(createDefaultSample().machines[0], {
|
||||
id: uid("X"),
|
||||
code: `X0000${state.machines.length + 1}`,
|
||||
name: "신규 중기장비",
|
||||
}),
|
||||
);
|
||||
} else if (act === "del-mach") state.machines.splice(idx, 1);
|
||||
else if (act === "add-san") {
|
||||
state.sanItems.push(
|
||||
Object.assign(createDefaultSample().sanItems[0], {
|
||||
id: uid("D"),
|
||||
code: `D0000${state.sanItems.length + 2}`,
|
||||
name: "신규 기계 시공 단가산출",
|
||||
}),
|
||||
);
|
||||
} else if (act === "del-san") state.sanItems.splice(idx, 1);
|
||||
else if (act === "add-ilwi") {
|
||||
const newIlwi = {
|
||||
id: uid("B"),
|
||||
code: `B0000${state.ilwiItems.length + 1}`,
|
||||
name: "신규 일위대가 호표",
|
||||
spec: "기본",
|
||||
unit: "㎥",
|
||||
lines: [],
|
||||
};
|
||||
state.ilwiItems.push(newIlwi);
|
||||
selectedIlwiId = newIlwi.id;
|
||||
} else if (act === "del-ilwi") {
|
||||
state.ilwiItems.splice(idx, 1);
|
||||
selectedIlwiId = state.ilwiItems[0]?.id || "";
|
||||
} else if (act === "add-ilwi-line") {
|
||||
state.ilwiItems[pidx].lines.push({
|
||||
refId: state.resources[0]?.id || "",
|
||||
qty: 1,
|
||||
halPercent: 0,
|
||||
devd: false,
|
||||
});
|
||||
} else if (act === "del-ilwi-line") state.ilwiItems[pidx].lines.splice(idx, 1);
|
||||
else if (act === "add-boq") {
|
||||
state.boqItems.push(
|
||||
Object.assign(createDefaultSample().boqItems[0], {
|
||||
id: uid("BOQ"),
|
||||
code: `B000${state.boqItems.length + 1}`,
|
||||
name: "신규 내역 공종",
|
||||
}),
|
||||
);
|
||||
} else if (act === "del-boq") state.boqItems.splice(idx, 1);
|
||||
|
||||
saveState();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
const el = e.target;
|
||||
if (el.id === "selIlwiPicker") {
|
||||
selectedIlwiId = el.value;
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const sec = el.dataset.sec;
|
||||
if (!sec) return;
|
||||
const idx = Number(el.dataset.idx),
|
||||
field = el.dataset.field;
|
||||
const val =
|
||||
el.type === "checkbox" ? el.checked : el.type === "number" ? Number(el.value) || 0 : el.value;
|
||||
|
||||
if (sec === "sysConfig") state.sysConfig[field] = val;
|
||||
else if (sec === "cand")
|
||||
state.resources[idx].candidates[Number(el.dataset.cidx)] = Number(el.value) || 0;
|
||||
else if (sec === "resources") state.resources[idx][field] = val;
|
||||
else if (sec === "machines") state.machines[idx][field] = val;
|
||||
else if (sec === "sanItems") state.sanItems[idx][field] = val;
|
||||
else if (sec === "ilwiItems") state.ilwiItems[idx][field] = val;
|
||||
else if (sec === "ilwiLines") state.ilwiItems[Number(el.dataset.pidx)].lines[idx][field] = val;
|
||||
else if (sec === "boqItems") state.boqItems[idx][field] = val;
|
||||
else if (sec === "kanjubConfig") state.kanjubConfig[field] = val;
|
||||
else if (sec === "kanjubRates") state.kanjubConfig.rates[field] = Number(el.value) || 0;
|
||||
else if (sec === "revModes") state.kanjubConfig.revModes[field] = val;
|
||||
else if (sec === "kOrdSim") state.sysConfig.kOrdSim[field] = Number(el.value) || 0;
|
||||
|
||||
saveState();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$("btnPresetBonghwa").addEventListener("click", () => {
|
||||
state = createDefaultSample();
|
||||
selectedIlwiId = state.ilwiItems[0]?.id || "";
|
||||
saveState();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$("btnSaveJson").addEventListener("click", () => {
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "stmate-test-state.json";
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
});
|
||||
|
||||
$("fileOpen").addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const imported = JSON.parse(await file.text());
|
||||
if (!imported || !Array.isArray(imported.resources) || !imported.kanjubConfig)
|
||||
throw new Error("Invalid format");
|
||||
state = imported;
|
||||
selectedIlwiId = state.ilwiItems[0]?.id || "";
|
||||
saveState();
|
||||
renderAll();
|
||||
} catch (_) {
|
||||
alert("유효한 STMATE 테스트 JSON 파일이 아닙니다.");
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
renderAll();
|
||||
Reference in New Issue
Block a user