diff --git a/resources/tester/test_est_standalone.py b/resources/tester/test_est_standalone.py
new file mode 100644
index 000000000..7dcea2612
--- /dev/null
+++ b/resources/tester/test_est_standalone.py
@@ -0,0 +1,42 @@
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+CALC = ROOT / "tools" / "est-test" / "calc.js"
+
+
+def calculate(data):
+ script = 'const vm=require("vm"), fs=require("fs"); const ctx={}; vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.calc=EstCalc",ctx); console.log(JSON.stringify(ctx.calc.calculate(JSON.parse(process.argv[2]))));'
+ return json.loads(
+ subprocess.check_output(
+ ["node", "-e", script, str(CALC), json.dumps(data)], text=True, encoding="utf-8"
+ )
+ )
+
+
+def test_est_test_calculation_and_linked_price_change():
+ data = {
+ "sources": [{"id": "s", "kind": "재료비", "price": 10.129}],
+ "units": [{"id": "u", "lines": [{"sourceId": "s", "quantity": 2}]}],
+ "estimate": [{"unitId": "u", "quantity": 3}],
+ "costs": [{"base": "직접공사비", "rate": 10}, {"base": "누계", "rate": 5}],
+ }
+ result = calculate(data)
+ assert result["units"]["u"]["lines"][0]["amount"] == 20.25
+ assert result["directTotal"] == 60
+ assert [row["amount"] for row in result["costs"]] == [6, 3]
+ assert result["total"] == 69
+ data["sources"][0]["price"] = 20
+ assert calculate(data)["total"] == 138
+
+
+def test_est_test_unlinked_rows_contribute_zero():
+ data = {
+ "sources": [],
+ "units": [{"id": "u", "lines": [{"sourceId": "", "quantity": 2}]}],
+ "estimate": [{"unitId": "missing", "quantity": 4}],
+ "costs": [],
+ }
+ result = calculate(data)
+ assert result["units"]["u"]["total"] == result["total"] == 0
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
new file mode 100644
index 000000000..11def668f
--- /dev/null
+++ b/tools/est-test/README.md
@@ -0,0 +1,7 @@
+# EST 계산 흐름 테스트
+
+`index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다.
+
+기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
+
+EST Plus 2 REA 분석의 일부 계산 흐름을 참고한 도구이며 실제 EST와 동일한 결과를 보장하지 않습니다. 수식 단가산출, ESTX 가져오기, 기성·물가변동과 법정 요율은 구현하지 않았습니다.
diff --git a/tools/est-test/app.js b/tools/est-test/app.js
new file mode 100644
index 000000000..a6c439bfa
--- /dev/null
+++ b/tools/est-test/app.js
@@ -0,0 +1,101 @@
+const key = 'aislo-est-test-v1';
+const sample = () => ({
+ sources: [
+ { id: 's1', name: '시험 자재', spec: '예제', unit: '개', kind: '재료비', price: 12500 },
+ { id: 's2', name: '시험 노무', spec: '예제', unit: '인', kind: '노무비', price: 180000 },
+ { id: 's3', name: '시험 장비', spec: '예제', unit: '시간', kind: '경비', price: 45000 },
+ ],
+ units: [{ id: 'u1', name: '시험 공종', unit: '식', lines: [{ sourceId: 's1', quantity: 2 }, { sourceId: 's2', quantity: 0.5 }, { sourceId: 's3', quantity: 1 }] }],
+ estimate: [{ id: 'e1', name: '시험 공종', unitId: 'u1', quantity: 3 }],
+ costs: [{ id: 'c1', name: '시험 간접비', base: '직접공사비', rate: 5 }, { id: 'c2', name: '시험 추가비', base: '누계', rate: 10 }],
+});
+const copy = value => JSON.parse(JSON.stringify(value));
+function valid(d) {
+ const text = v => typeof v === 'string' && v.length <= 500;
+ const num = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e12;
+ const ids = arr => new Set(arr.map(x => x.id)).size === arr.length;
+ return d && ['sources', 'units', 'estimate', 'costs'].every(k => Array.isArray(d[k]) && d[k].length <= 1000) &&
+ d.sources.every(s => text(s.id) && text(s.name) && text(s.spec) && text(s.unit) && EstCalc.kinds.includes(s.kind) && num(s.price)) && ids(d.sources) &&
+ d.units.every(u => text(u.id) && text(u.name) && text(u.unit) && Array.isArray(u.lines) && u.lines.length <= 1000 && u.lines.every(l => text(l.sourceId) && num(l.quantity))) && ids(d.units) &&
+ d.estimate.every(e => text(e.id) && text(e.name) && text(e.unitId) && num(e.quantity)) && ids(d.estimate) &&
+ d.costs.every(c => text(c.id) && text(c.name) && [...EstCalc.kinds, '직접공사비', '누계'].includes(c.base) && num(c.rate)) && ids(d.costs);
+}
+let data;
+try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); }
+let tab = 'sources';
+let selected = data.units[0]?.id || '';
+const $ = id => document.getElementById(id);
+const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const money = n => new Intl.NumberFormat('ko-KR', {maximumFractionDigits: 2}).format(n);
+const id = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 9)}`;
+const field = (collection, index, name, value, type = 'text', extra = '') => ``;
+const options = (values, current) => values.map(([value, label]) => ``).join('');
+const select = (collection, index, name, value, values) => ``;
+const remove = (collection, index) => ``;
+const add = (collection, label) => ``;
+const table = (heads, rows) => `
${heads.map(h => `| ${h} | `).join('')}
${rows.join('')}
`;
+const card = (title, subtitle, button, body, note = '') => `${body}${note ? `
${note}
` : ''}
`;
+function render() {
+ const result = EstCalc.calculate(data);
+ $('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['원가 합계',result.total]].map(([label,n]) => `${label}${money(n)} 원
`).join('');
+ document.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
+ const sourceChoices = [['', '선택'], ...data.sources.map(s => [s.id, `${s.name} (${s.unit})`])];
+ const unitChoices = [['', '선택'], ...data.units.map(u => [u.id, `${u.name} (${u.unit})`])];
+ const kindChoices = EstCalc.kinds.map(k => [k,k]);
+ if (tab === 'sources') {
+ const rows = data.sources.map((s,i) => `| ${field('sources',i,'name',s.name)} | ${field('sources',i,'spec',s.spec)} | ${field('sources',i,'unit',s.unit)} | ${select('sources',i,'kind',s.kind,kindChoices)} | ${field('sources',i,'price',s.price,'number')} | ${remove('sources',i)} |
`);
+ $('panel').innerHTML = card('기초 단가', '자재·노무·장비 등의 시험 단가를 직접 입력합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','단가 (원)',''],rows), '이곳의 단가가 일위대가 구성 행에 연결됩니다. 연결 중인 단가는 삭제할 수 없습니다.');
+ } else if (tab === 'units') {
+ if (!data.units.some(u => u.id === selected)) selected = data.units[0]?.id || '';
+ const u = data.units.find(u => u.id === selected), ui = data.units.indexOf(u);
+ const chooser = ``;
+ let body = `${chooser}
${u ? remove('units',ui) : ''}
`;
+ if (u) {
+ body += table(['일위대가 이름','단위','합계 (원)'],[`| ${field('units',ui,'name',u.name)} | ${field('units',ui,'unit',u.unit)} | ${money(result.units[u.id].total)} |
`]);
+ body += `구성 항목
${add('lines','구성 추가')}`;
+ body += table(['기초 단가','소요량','단가 (원)','비목','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; return `| ${select('lines',i,'sourceId',l.sourceId,sourceChoices)} | ${field('lines',i,'quantity',l.quantity,'number')} | ${r.source ? money(r.source.price) : '—'} | ${esc(r.source?.kind || '미연결')} | ${money(r.amount)} | ${remove('lines',i)} |
`;}));
+ body += `${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}
행 금액은 소수 둘째 자리까지 절사하고 비목별 합계는 원 미만을 절사합니다.
`;
+ } else body += '일위대가를 추가하세요.
';
+ $('panel').innerHTML = card('일위대가', '기초 단가와 소요량을 조합해 단위당 금액을 계산합니다.', add('units','일위대가 추가'), body);
+ } else if (tab === 'estimate') {
+ const rows = data.estimate.map((e,i) => {const u=result.units[e.unitId], r=result.rows[i]; return `| ${field('estimate',i,'name',e.name)} | ${select('estimate',i,'unitId',e.unitId,unitChoices)} | ${field('estimate',i,'quantity',e.quantity,'number')} | ${u ? money(u.total) : '—'} | ${EstCalc.kinds.map(k=>`${money(r.parts[k])} | `).join('')}${money(r.total)} | ${remove('estimate',i)} |
`;});
+ $('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.');
+ } else {
+ const bases = [...EstCalc.kinds,'직접공사비','누계'].map(k=>[k,k]);
+ const rows = data.costs.map((c,i) => `| ${field('costs',i,'name',c.name)} | ${select('costs',i,'base',c.base,bases)} | ${money(result.costs[i].base)} | ${field('costs',i,'rate',c.rate,'number')} | ${money(result.costs[i].amount)} | ${money(result.costs[i].running)} | ${remove('costs',i)} |
`);
+ $('panel').innerHTML = card('원가계산서','기준 금액과 요율을 선택해 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','계산 금액','누계',''],[`| 직접공사비 | ${money(result.directTotal)} | ${money(result.directTotal)} | |
`,...rows]),'각 행은 기준 금액 × 요율 ÷ 100의 원 미만을 절사합니다. 누계는 앞선 항목까지의 합계입니다. 수식 편집 및 법정 요율은 제공하지 않습니다.');
+ }
+}
+function persist() { try { sessionStorage.setItem(key,JSON.stringify(data)); } catch { alert('임시 저장 공간을 사용할 수 없습니다. JSON 파일로 저장해 주세요.'); } }
+document.addEventListener('click', event => {
+ const t = event.target.closest('button'); if (!t) return;
+ if (t.dataset.tab) { tab=t.dataset.tab; render(); return; }
+ if (t.dataset.add) {
+ const k=t.dataset.add;
+ if (k==='sources') data.sources.push({id:id(),name:'새 단가',spec:'',unit:'식',kind:'재료비',price:0});
+ if (k==='units') { const u={id:id(),name:'새 일위대가',unit:'식',lines:[]}; data.units.push(u); selected=u.id; }
+ if (k==='lines') data.units.find(u=>u.id===selected).lines.push({sourceId:data.sources[0]?.id || '',quantity:1});
+ if (k==='estimate') data.estimate.push({id:id(),name:'새 공종',unitId:data.units[0]?.id || '',quantity:1});
+ if (k==='costs') data.costs.push({id:id(),name:'새 요율',base:'직접공사비',rate:0});
+ persist(); render();
+ }
+ if (t.dataset.remove) {
+ const k=t.dataset.remove, i=Number(t.dataset.index), arr=k==='lines' ? data.units.find(u=>u.id===selected).lines : data[k], item=arr[i];
+ if (k==='sources' && data.units.some(u=>u.lines.some(l=>l.sourceId===item.id))) { alert('일위대가에서 사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; }
+ if (k==='units' && data.estimate.some(e=>e.unitId===item.id)) { alert('내역에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; }
+ arr.splice(i,1); persist(); render();
+ }
+});
+document.addEventListener('change', event => {
+ const el=event.target;
+ if (el.id==='unitChoice') { selected=el.value; render(); return; }
+ if (!el.dataset.collection) return;
+ const arr=el.dataset.collection==='lines' ? data.units.find(u=>u.id===selected).lines : data[el.dataset.collection];
+ const value=el.type==='number' ? Number(el.value) : el.value;
+ if (el.type==='number' && (el.value.trim()==='' || !Number.isFinite(value) || value<0 || value>1e12)) { alert('0 이상 1조 이하의 숫자를 입력하세요.'); render(); return; }
+ arr[Number(el.dataset.index)][el.dataset.field]=value; persist(); render();
+});
+$('save').addEventListener('click', () => { const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'})); a.download='est-test.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); });
+$('open').addEventListener('change', async event => { const file=event.target.files[0]; if (!file) return; try { if(file.size>5_000_000) throw Error(); const imported=JSON.parse(await file.text()); if(!valid(imported)) throw Error(); if(!confirm('현재 탭의 작업을 파일 내용으로 바꾸시겠습니까? 저장하지 않은 작업은 사라집니다.')) return; data=copy(imported); selected=data.units[0]?.id || ''; persist(); render(); } catch { alert('올바른 테스트 앱 JSON 파일이 아닙니다.'); } finally { event.target.value=''; } });
+$('reset').addEventListener('click', () => { if(!confirm('현재 탭의 작업을 예제로 초기화하시겠습니까? 저장하지 않은 작업은 사라집니다.')) return; data=sample(); selected=data.units[0].id; persist(); render(); });
+render();
diff --git a/tools/est-test/calc.js b/tools/est-test/calc.js
new file mode 100644
index 000000000..73ff79fe0
--- /dev/null
+++ b/tools/est-test/calc.js
@@ -0,0 +1,38 @@
+// EST 보고서의 성분별 행 절사와 원 미만 합계 절사를 참고한 테스트 계산.
+// shortcut: 실제 EST의 모든 자릿수·비율 옵션은 미구현, 원본 대조 시 확장.
+const EstCalc = (() => {
+ const kinds = ['재료비', '노무비', '경비'];
+ const trunc = n => Math.trunc((Number(n) || 0) * 100) / 100;
+ function calculate(data) {
+ const prices = Object.fromEntries(data.sources.map(s => [s.id, s]));
+ const units = Object.fromEntries(data.units.map(u => {
+ const parts = Object.fromEntries(kinds.map(k => [k, 0]));
+ const lines = u.lines.map(line => {
+ const source = prices[line.sourceId];
+ const amount = source ? trunc(source.price * line.quantity) : 0;
+ if (source) parts[source.kind] += amount;
+ return { source, amount };
+ });
+ for (const k of kinds) parts[k] = Math.trunc(parts[k]);
+ return [u.id, { parts, lines, total: kinds.reduce((sum, k) => sum + parts[k], 0) }];
+ }));
+ const direct = Object.fromEntries(kinds.map(k => [k, 0]));
+ const rows = data.estimate.map(row => {
+ const unit = units[row.unitId];
+ const parts = Object.fromEntries(kinds.map(k => [k, unit ? Math.trunc(unit.parts[k] * row.quantity) : 0]));
+ for (const k of kinds) direct[k] += parts[k];
+ return { parts, total: kinds.reduce((sum, k) => sum + parts[k], 0) };
+ });
+ const directTotal = kinds.reduce((sum, k) => sum + direct[k], 0);
+ let running = directTotal;
+ const costs = data.costs.map(row => {
+ const base = row.base === '누계' ? running : row.base === '직접공사비' ? directTotal : direct[row.base] || 0;
+ const amount = Math.trunc(base * row.rate / 100);
+ running += amount;
+ return { base, amount, running };
+ });
+ return { units, rows, direct, directTotal, costs, total: running };
+ }
+ return { kinds, calculate };
+})();
+if (typeof module !== 'undefined') module.exports = EstCalc;
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
new file mode 100644
index 000000000..252c87063
--- /dev/null
+++ b/tools/est-test/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ EST 계산 흐름 테스트
+
+
+
+
+
+
+
+ 테스트 전용 예제 단가와 요율은 임의의 숫자입니다. 원본 EST와의 동등성 및 실무 적용 여부는 검증되지 않았습니다.
+
+
+
+
+
+
diff --git a/tools/est-test/style.css b/tools/est-test/style.css
new file mode 100644
index 000000000..3cabf48e0
--- /dev/null
+++ b/tools/est-test/style.css
@@ -0,0 +1,234 @@
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ background: #f4f6f8;
+ color: #172a3a;
+ font:
+ 15px system-ui,
+ "Malgun Gothic",
+ sans-serif;
+}
+.top {
+ background: #122c40;
+ color: white;
+ padding: 32px max(24px, calc((100% - 1320px) / 2));
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 24px;
+}
+.eyebrow {
+ font-size: 12px;
+ letter-spacing: 0.12em;
+ color: #8ed8cd;
+ font-weight: 700;
+}
+h1 {
+ font-size: 27px;
+ margin: 8px 0;
+}
+.top p {
+ margin: 0;
+ color: #bfd0da;
+}
+.actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+button,
+.button {
+ border: 0;
+ border-radius: 7px;
+ background: #087f73;
+ color: white;
+ padding: 10px 15px;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ white-space: nowrap;
+}
+button:hover,
+.button:hover {
+ filter: brightness(0.93);
+}
+.secondary {
+ background: #e9f0f1;
+ color: #234253;
+}
+main {
+ max-width: 1320px;
+ margin: auto;
+ padding: 24px;
+}
+.notice {
+ background: #fff6e1;
+ border: 1px solid #efdfb5;
+ border-radius: 8px;
+ padding: 13px 16px;
+ color: #705315;
+}
+.notice strong {
+ margin-right: 10px;
+}
+.summary {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+ margin: 20px 0;
+}
+.metric,
+.card {
+ background: white;
+ border: 1px solid #dfe7ea;
+ border-radius: 9px;
+}
+.metric {
+ padding: 16px;
+}
+.metric span {
+ display: block;
+ color: #6d818c;
+ font-size: 13px;
+}
+.metric strong {
+ display: block;
+ font-size: 21px;
+ margin-top: 6px;
+}
+.tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid #cbd8dc;
+ overflow: auto;
+}
+.tabs button {
+ background: transparent;
+ color: #6a7f8b;
+ border-radius: 0;
+ padding: 14px 18px;
+ border-bottom: 3px solid transparent;
+}
+.tabs button.active {
+ color: #087f73;
+ border-bottom-color: #087f73;
+}
+.card {
+ margin-top: 18px;
+ overflow: hidden;
+}
+.cardhead {
+ padding: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.cardhead h2 {
+ margin: 0;
+ font-size: 19px;
+}
+.cardhead p {
+ margin: 6px 0 0;
+ color: #71838c;
+ font-size: 13px;
+}
+.tablewrap {
+ overflow: auto;
+}
+table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 700px;
+}
+th,
+td {
+ padding: 10px 12px;
+ border-top: 1px solid #e8eef0;
+ text-align: left;
+ vertical-align: middle;
+}
+th {
+ background: #f8fafb;
+ color: #6a7d87;
+ font-size: 12px;
+}
+td.money {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ font-weight: 600;
+}
+input,
+select {
+ width: 100%;
+ min-width: 75px;
+ padding: 8px;
+ border: 1px solid #cfdbdf;
+ border-radius: 5px;
+ background: white;
+ color: #203746;
+ font: inherit;
+}
+input[type="number"] {
+ text-align: right;
+}
+.delete {
+ background: #f3f6f7;
+ color: #8a5151;
+ padding: 7px 10px;
+}
+.details {
+ padding: 16px 20px;
+ color: #59717d;
+ font-size: 13px;
+ border-top: 1px solid #e8eef0;
+ line-height: 1.7;
+}
+.total {
+ background: #f0f8f6;
+}
+.total td {
+ font-weight: 700;
+}
+.empty {
+ padding: 24px;
+ color: #738892;
+}
+.footnote {
+ font-size: 12px;
+ color: #72848d;
+ margin-top: 20px;
+}
+.tag {
+ display: inline-block;
+ padding: 4px 8px;
+ background: #e5f4f0;
+ color: #087f73;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 700;
+}
+.warning {
+ color: #a45228;
+}
+@media (max-width: 720px) {
+ .top {
+ display: block;
+ }
+ .actions {
+ margin-top: 20px;
+ }
+ .summary {
+ grid-template-columns: 1fr 1fr;
+ }
+ main {
+ padding: 16px;
+ }
+ .metric strong {
+ font-size: 17px;
+ }
+}