diff --git a/resources/tester/test_est_standalone.py b/resources/tester/test_est_standalone.py index 7dcea2612..99a8cc9de 100644 --- a/resources/tester/test_est_standalone.py +++ b/resources/tester/test_est_standalone.py @@ -40,3 +40,18 @@ def test_est_test_unlinked_rows_contribute_zero(): } result = calculate(data) assert result["units"]["u"]["total"] == result["total"] == 0 + + +def test_progress_uses_approved_quantity_only_when_selected(): + script = ( + 'const vm=require("vm"),fs=require("fs");const c={};' + 'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.calc=EstCalc",c);' + "console.log(JSON.stringify([c.calc.progress(100,2,3,1,false)," + "c.calc.progress(100,2,3,1,true)]));" + ) + ordinary, approved = json.loads( + subprocess.check_output(["node", "-e", script, str(CALC)], text=True, encoding="utf-8") + ) + assert ordinary["currentAmount"] == 300 + assert ordinary["cumulativeAmount"] == 500 + assert approved["cumulativeAmount"] == 300 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 11def668f..940def73e 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -2,6 +2,6 @@ `index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다. -기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다. +기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름, 간단한 수량산출 연결과 기성 수량 입력을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다. -EST Plus 2 REA 분석의 일부 계산 흐름을 참고한 도구이며 실제 EST와 동일한 결과를 보장하지 않습니다. 수식 단가산출, ESTX 가져오기, 기성·물가변동과 법정 요율은 구현하지 않았습니다. +EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 및 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다. diff --git a/tools/est-test/app.js b/tools/est-test/app.js index a6c439bfa..0fc3fa994 100644 --- a/tools/est-test/app.js +++ b/tools/est-test/app.js @@ -1,5 +1,9 @@ const key = 'aislo-est-test-v1'; const sample = () => ({ + projectName: '시험 내역서', + quantities: [], + progress: [], + progressApproved: false, sources: [ { id: 's1', name: '시험 자재', spec: '예제', unit: '개', kind: '재료비', price: 12500 }, { id: 's2', name: '시험 노무', spec: '예제', unit: '인', kind: '노무비', price: 180000 }, @@ -14,7 +18,7 @@ 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) && + return d && (d.projectName === undefined || text(d.projectName)) && (d.progressApproved === undefined || typeof d.progressApproved === 'boolean') && (d.progress === undefined || (Array.isArray(d.progress) && d.progress.length <= 1000 && d.progress.every(p => text(p.estimateId) && num(p.previous) && num(p.current) && num(p.approved)))) && (d.quantities === undefined || (Array.isArray(d.quantities) && d.quantities.length <= 1000 && d.quantities.every(q => text(q.id) && text(q.estimateId) && text(q.name) && num(q.length) && num(q.width) && num(q.count)))) && ['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) && @@ -22,7 +26,7 @@ function valid(d) { } let data; try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); } -let tab = 'sources'; +let tab = 'estimate'; let selected = data.units[0]?.id || ''; const $ = id => document.getElementById(id); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); @@ -37,6 +41,7 @@ const table = (heads, rows) => `
${heads const card = (title, subtitle, button, body, note = '') => `

${title}

${subtitle}

${button}
${body}${note ? `
${note}
` : ''}
`; function render() { const result = EstCalc.calculate(data); + $('projectName').value = data.projectName || ''; $('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})`])]; @@ -60,6 +65,17 @@ function render() { } else if (tab === 'estimate') { const rows = data.estimate.map((e,i) => {const u=result.units[e.unitId], r=result.rows[i]; return `${EstCalc.kinds.map(k=>``).join('')}`;}); $('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.'); + } else if (tab === 'quantities') { + const choices = [['', '선택'], ...data.estimate.map(e => [e.id, e.name])]; + const rows = (data.quantities || []).map((q,i) => ``); + $('panel').innerHTML = card('수량산출서','수량을 산출하고 선택한 내역에 반영합니다.',`${add('quantities','산출 행 추가')} `,table(['산출 항목','연결 공종','길이','폭','횟수','산출 수량',''],rows),'산출 수량 = 길이 × 폭 × 횟수. 같은 공종에 연결된 산출 수량을 합산해 내역 수량을 덮어씁니다.'); + } else if (tab === 'progress') { + const rows = (data.progress || []).map((p,i) => { + const e = data.estimate.find(x => x.id === p.estimateId), price = e ? result.units[e.unitId]?.total || 0 : 0; + const r = EstCalc.progress(price, p.previous, p.current, p.approved, data.progressApproved); + return ``; + }); + $('panel').innerHTML = card('기성고', '전회·금회·사정 수량을 입력해 금회와 누계를 확인합니다.', '', `
${table(['공종','계약 수량','단가','전회 수량','금회 수량','금회사정 수량','금회 금액','누계 수량','누계 금액',''],rows)}`, '내역 불러오기는 새 공종만 추가합니다. 이 화면은 수량 입력 방식만 지원하며 회차별 이력·기성 원가계산은 아직 구현되지 않았습니다.'); } else { const bases = [...EstCalc.kinds,'직접공사비','누계'].map(k=>[k,k]); const rows = data.costs.map((c,i) => ``); @@ -76,18 +92,36 @@ document.addEventListener('click', event => { 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==='quantities') { data.quantities ||= []; data.quantities.push({id:id(),name:'새 수량산출',estimateId:data.estimate[0]?.id || '',length:1,width:1,count:1}); } if (k==='costs') data.costs.push({id:id(),name:'새 요율',base:'직접공사비',rate:0}); persist(); render(); } + if (t.id==='syncProgress') { + data.progress ||= []; + for (const e of data.estimate) if (!data.progress.some(p => p.estimateId === e.id)) data.progress.push({estimateId:e.id,previous:0,current:0,approved:0}); + persist(); render(); + } + if (t.id==='applyQuantities') { + const rows = (data.quantities || []).filter(q => q.estimateId && data.estimate.some(e => e.id === q.estimateId)); + if (!rows.length) { alert('연결된 수량산출 행이 없습니다.'); return; } + if (!confirm('연결된 공종의 기존 수량을 산출 합계로 바꾸시겠습니까?')) return; + const sums = data.estimate.map(e => rows.filter(q => q.estimateId === e.id).reduce((sum,q) => sum + q.length*q.width*q.count,0)); + if (sums.some(n => !Number.isFinite(n) || n > 1e12)) { alert('산출 수량이 허용 범위(1조)를 넘습니다.'); return; } + data.estimate.forEach((e,i) => { if (rows.some(q => q.estimateId === e.id)) e.quantity = sums[i]; }); + 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==='estimate' && ((data.quantities || []).some(q=>q.estimateId===item.id) || (data.progress || []).some(p=>p.estimateId===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==='progressApproved') { data.progressApproved=el.checked; persist(); render(); return; } + if (el.id==='projectName') { data.projectName=el.value; persist(); return; } 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]; @@ -99,3 +133,6 @@ $('save').addEventListener('click', () => { const a=document.createElement('a'); $('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(); + +document.getElementById('recalculate').addEventListener('click', () => { render(); }); +document.getElementById('print').addEventListener('click', () => window.print()); diff --git a/tools/est-test/calc.js b/tools/est-test/calc.js index 73ff79fe0..d59bab468 100644 --- a/tools/est-test/calc.js +++ b/tools/est-test/calc.js @@ -33,6 +33,11 @@ const EstCalc = (() => { }); return { units, rows, direct, directTotal, costs, total: running }; } - return { kinds, calculate }; + function progress(unitPrice, previous, current, approved, useApproved) { + const accepted = useApproved ? approved : current; + const cumulative = previous + accepted; + return { currentAmount: Math.trunc(unitPrice * current), approvedAmount: Math.trunc(unitPrice * approved), cumulative, cumulativeAmount: Math.trunc(unitPrice * cumulative) }; + } + return { kinds, calculate, progress }; })(); if (typeof module !== 'undefined') module.exports = EstCalc; diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 252c87063..247d125c7 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -1,20 +1,19 @@ - + - - - EST 계산 흐름 테스트 - - - + +EST 업무 테스트 + -
독립 테스트 도구

EST 계산 흐름 테스트

기초 단가부터 원가계산까지 입력하고 계산 과정을 확인합니다.

-
-
테스트 전용 예제 단가와 요율은 임의의 숫자입니다. 원본 EST와의 동등성 및 실무 적용 여부는 검증되지 않았습니다.
-
- -
-

입력 내용은 현재 브라우저 탭에 임시 보관됩니다. 보존하려면 JSON 저장을 누르세요. 세부 계산은 각 단계에서 확인할 수 있습니다.

-
+
+
EST 업무 테스트독립 실행 · 시험자료
+ +
공사명
+
+
+ +
구현 중 · 상단 메뉴는 배치 참고용이며 왼쪽 작업화면과 도구 버튼만 동작합니다. 예제 수치는 임의 값이며 원본 EST와의 동등성은 검증되지 않았습니다.
+
+ diff --git a/tools/est-test/style.css b/tools/est-test/style.css index 3cabf48e0..252ec053d 100644 --- a/tools/est-test/style.css +++ b/tools/est-test/style.css @@ -3,138 +3,166 @@ } body { margin: 0; - background: #f4f6f8; - color: #172a3a; + background: #e9edf1; + color: #202a34; font: - 15px system-ui, - "Malgun Gothic", + 13px "Malgun Gothic", sans-serif; } -.top { - background: #122c40; - color: white; - padding: 32px max(24px, calc((100% - 1320px) / 2)); +header { + background: #f3f5f7; +} +.titlebar { + height: 38px; + background: #294b68; + color: #fff; display: flex; - justify-content: space-between; align-items: center; - gap: 24px; + gap: 20px; + padding: 0 15px; } -.eyebrow { - font-size: 12px; - letter-spacing: 0.12em; - color: #8ed8cd; - font-weight: 700; +.titlebar strong { + font-size: 15px; } -h1 { - font-size: 27px; - margin: 8px 0; +.titlebar span { + color: #c5d9e6; + font-size: 11px; } -.top p { - margin: 0; - color: #bfd0da; -} -.actions { +.menubar { + height: 32px; display: flex; - gap: 8px; + align-items: center; + gap: 27px; + padding: 0 17px; + background: #fafbfc; + border-bottom: 1px solid #c3ccd5; +} +.toolbar { + display: flex; + align-items: center; + gap: 5px; + padding: 8px 12px; + border-bottom: 1px solid #b9c5cf; 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; +.toolbar i { + height: 25px; + border-left: 1px solid #bdc7d1; + margin: 0 8px; } -button:hover, -.button:hover { - filter: brightness(0.93); +.toolbar input { + width: 210px; } -.secondary { - background: #e9f0f1; - color: #234253; +.workspace { + display: flex; + min-height: calc(100vh - 142px); } -main { - max-width: 1320px; - margin: auto; - padding: 24px; +aside { + width: 150px; + flex: none; + background: #e3e9ef; + border-right: 1px solid #aebcc8; } -.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; +.sidehead { + padding: 11px 12px; + background: #d0dbe5; + font-weight: bold; + border-bottom: 1px solid #bdc8d1; } .tabs { display: flex; - gap: 4px; - border-bottom: 1px solid #cbd8dc; - overflow: auto; + flex-direction: column; + padding: 8px; + gap: 3px; } .tabs button { + text-align: left; background: transparent; - color: #6a7f8b; - border-radius: 0; - padding: 14px 18px; - border-bottom: 3px solid transparent; + border: 1px solid transparent; + font-weight: normal; + color: #253847; + padding: 10px; } .tabs button.active { - color: #087f73; - border-bottom-color: #087f73; + background: #fff; + border-color: #9eb6c8; + color: #164f7b; + font-weight: bold; +} +.tabs button span { + margin-left: 8px; +} +.sideinfo { + border-top: 1px solid #c4ced6; + padding: 15px 12px; + line-height: 1.8; + color: #6a7b89; +} +.sideinfo b { + color: #33546b; +} +.sideinfo p { + font-size: 11px; +} +main { + padding: 12px; + min-width: 0; + flex: 1; +} +.notice { + background: #fff9e6; + border: 1px solid #e4d6a8; + padding: 8px 10px; + color: #735c24; + font-size: 12px; +} +.summary { + display: flex; + gap: 0; + background: #f7f9fb; + border: 1px solid #bdc9d1; + margin-top: 10px; +} +.metric { + padding: 8px 15px; + border-right: 1px solid #d6dfe5; + min-width: 150px; +} +.metric span { + color: #667c8b; + font-size: 11px; +} +.metric strong { + display: block; + font-size: 16px; + margin-top: 3px; + color: #244660; } .card { - margin-top: 18px; - overflow: hidden; + background: white; + border: 1px solid #b7c5cf; + margin-top: 10px; + min-height: 400px; } .cardhead { - padding: 20px; + background: #e5edf4; + border-bottom: 1px solid #bccbd6; + padding: 9px 12px; display: flex; - align-items: center; justify-content: space-between; + align-items: center; gap: 12px; flex-wrap: wrap; } .cardhead h2 { margin: 0; - font-size: 19px; + font-size: 14px; + color: #234962; } .cardhead p { - margin: 6px 0 0; - color: #71838c; - font-size: 13px; + margin: 4px 0 0; + color: #72818a; + font-size: 11px; } .tablewrap { overflow: auto; @@ -146,89 +174,111 @@ table { } th, td { - padding: 10px 12px; - border-top: 1px solid #e8eef0; + border: 1px solid #d6e0e6; + padding: 4px 5px; text-align: left; - vertical-align: middle; } th { - background: #f8fafb; - color: #6a7d87; + background: #eaf0f5; + color: #365269; font-size: 12px; + white-space: nowrap; + text-align: center; + height: 31px; } td.money { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; - font-weight: 600; +} +tr:nth-child(even) { + background: #f8fafb; +} +tr:hover { + background: #edf6ff; } input, select { + min-width: 70px; width: 100%; - min-width: 75px; - padding: 8px; - border: 1px solid #cfdbdf; - border-radius: 5px; + border: 1px solid #d1dce2; background: white; - color: #203746; + padding: 5px; + color: #273b4b; font: inherit; } +input:focus, +select:focus { + outline: 2px solid #74a8ca; + outline-offset: -1px; +} input[type="number"] { text-align: right; } +button, +.button { + background: #fff; + border: 1px solid #b6c5d0; + color: #29465a; + padding: 6px 10px; + font: inherit; + cursor: pointer; + white-space: nowrap; +} +button:hover, +.button:hover { + background: #ddecf7; + border-color: #8faec1; +} .delete { - background: #f3f6f7; - color: #8a5151; - padding: 7px 10px; + color: #87585b; + padding: 4px 7px; } .details { - padding: 16px 20px; - color: #59717d; - font-size: 13px; - border-top: 1px solid #e8eef0; - line-height: 1.7; + padding: 12px; + color: #667e8d; + font-size: 12px; + line-height: 1.8; + border-top: 1px solid #d6e0e6; } .total { - background: #f0f8f6; -} -.total td { - font-weight: 700; + background: #eaf3f8; + font-weight: bold; } .empty { - padding: 24px; - color: #738892; + padding: 25px; + color: #758995; } -.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; +footer { + height: 28px; + border-top: 1px solid #becbd4; + background: #e4eaf0; + color: #586f80; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 12px; + font-size: 11px; } @media (max-width: 720px) { - .top { - display: block; - } - .actions { - margin-top: 20px; + aside { + width: 110px; } .summary { - grid-template-columns: 1fr 1fr; + flex-wrap: wrap; + } + .metric { + min-width: 50%; + } + .menubar { + gap: 14px; + overflow: auto; } main { - padding: 16px; + padding: 6px; } - .metric strong { - font-size: 17px; + .titlebar span, + .sideinfo { + display: none; } }
${field('estimate',i,'name',e.name)}${select('estimate',i,'unitId',e.unitId,unitChoices)}${field('estimate',i,'quantity',e.quantity,'number')}${u ? money(u.total) : '—'}${money(r.parts[k])}${money(r.total)}${remove('estimate',i)}
${field('quantities',i,'name',q.name)}${select('quantities',i,'estimateId',q.estimateId,choices)}${field('quantities',i,'length',q.length,'number')}${field('quantities',i,'width',q.width,'number')}${field('quantities',i,'count',q.count,'number')}${money(q.length*q.width*q.count)}${remove('quantities',i)}
${esc(e?.name || '연결 없음')}${money(e?.quantity || 0)}${money(price)}${field('progress',i,'previous',p.previous,'number')}${field('progress',i,'current',p.current,'number')}${field('progress',i,'approved',p.approved,'number')}${money(r.currentAmount)}${money(r.cumulative)}${money(r.cumulativeAmount)}${remove('progress',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)}