feat(tools): STMATE 계산 사슬 및 제어 옵션 독립 테스트 웹앱 추가 (tools/stmate-test)

This commit is contained in:
2026-11-04 22:08:28 +09:00
parent 9f6b7de26f
commit a56da7bb2b
6 changed files with 2178 additions and 0 deletions
+684
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>&amp;X..L&amp;</code>), 재료비(<code>&amp;X..M&amp;</code>), 경비(<code>&amp;X..S&amp;</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>&amp;X..&amp;</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();