const key = 'aislo-est-test-v1'; const sample = () => ({ projectName: '시험 내역서', quantities: [], machines: [], escalation: [], progress: [], progressApproved: false, 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 && (d.projectName === undefined || text(d.projectName)) && (d.escalation === undefined || (Array.isArray(d.escalation) && d.escalation.length <= 1000 && d.escalation.every(e => text(e.name) && ['bid','contract','current','quantity','planned','actual','progress'].every(k=>num(e[k]))))) && (d.machines === undefined || (Array.isArray(d.machines) && d.machines.length <= 1000 && d.machines.every(m => text(m.sourceId) && ['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].every(k => num(m[k]))))) && (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) && (s.prices === undefined || (Array.isArray(s.prices) && s.prices.length === 6 && s.prices.every(num))) && (s.priceMode === undefined || ['직접입력','최저','평균','최고','선택'].includes(s.priceMode)) && (s.priceIndex === undefined || (Number.isInteger(s.priceIndex) && s.priceIndex >= 0 && s.priceIndex < 6))) && 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) && (l.type === undefined || ['기초단가','일위대가','비율'].includes(l.type)) && (l.unitId === undefined || text(l.unitId)) && (l.digits === undefined || (Number.isInteger(l.digits) && l.digits >= 0 && l.digits <= 6)) && (l.base === undefined || [...EstCalc.kinds,'합계','비목별'].includes(l.base)) && (l.kind === undefined || EstCalc.kinds.includes(l.kind)))) && 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) && (c.formula === undefined || text(c.formula))) && ids(d.costs); } let data; try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); } let tab = 'estimate'; 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 => ``).join('')}${rows.join('')}
${h}
`; 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.direct['실적']],['원가 합계',result.total]].map(([label,n]) => `
${label}${money(n)} 원
`).join(''); if (result.errors.length) $('summary').innerHTML += ``; 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)}${select('sources',i,'priceMode',s.priceMode || '직접입력',['직접입력','최저','평균','최고','선택'].map(k=>[k,k]))}${Array.from({length:6},(_,n)=>`${field('sources',i,`price${n}`,s.prices?.[n] || 0,'number')}`).join('')}${field('sources',i,'priceIndex',(s.priceIndex || 0)+1,'number')}${field('sources',i,'price',s.price,'number')}${money(EstCalc.sourcePrice(s))}${remove('sources',i)}`); $('panel').innerHTML = card('기초 단가', '단가 출처 여섯 칸과 적용 방법을 선택합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','적용 방법','출처1','출처2','출처3','출처4','출처5','출처6','선택 번호','입력 단가','적용 단가',''],rows), '최저·평균·최고는 0이 아닌 출처를 사용하며 평균은 정수 절사합니다.'); } else if (tab === 'machines') { const rows = (data.machines || []).map((m,i) => { const p=EstCalc.machine(m); return `${select('machines',i,'sourceId',m.sourceId,sourceChoices)}${['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].map(k=>`${field('machines',i,k,m[k],'number')}`).join('')}${EstCalc.kinds.map(k=>`${money(p[k])}`).join('')}${remove('machines',i)}`; }); $('panel').innerHTML = card('기계경비', '기초단가 항목에 손료·연료·운전사 금액을 연결합니다.', add('machines','기계 추가'), table(['연결 기초단가','기계가격','손료계수','연료단가','연료량','잡품률 %','노임단가','환산계수','운전시간','재료비','노무비','경비','실적',''],rows), '손료 = 기계가격 × 손료계수 × 10⁻⁷, 잡품 = 연료 × 잡품률, 운전사 = 노임 × 환산계수 × 시간. 외화·다중 연료·낙찰률 등은 아직 미구현입니다.'); } 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]; const type=l.type || '기초단가'; const link=type==='일위대가' ? select('lines',i,'unitId',l.unitId || '',unitChoices.filter(x => x[0] !== u.id)) : type==='비율' ? select('lines',i,'base',l.base || '합계',[...EstCalc.kinds,'합계','비목별'].map(k=>[k,k])) : select('lines',i,'sourceId',l.sourceId,sourceChoices); return `${select('lines',i,'type',type,['기초단가','일위대가','비율'].map(k=>[k,k]))}${link}${type==='비율' ? select('lines',i,'kind',l.kind || '경비',kindChoices) : esc(r.source?.kind || (r.unit ? '비목별' : '미연결'))}${field('lines',i,'quantity',l.quantity,'number')}${field('lines',i,'digits',l.digits ?? 0,'number','max="6"')}${money(r.amount)}${remove('lines',i)}`;})); body += `
${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}
행 비목별 자릿수 절사 후 합계를 원 미만 절사합니다. ${esc(result.errors.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 if (tab === 'quantities') { const choices = [['', '선택'], ...data.estimate.map(e => [e.id, e.name])]; const rows = (data.quantities || []).map((q,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)}`); $('panel').innerHTML = card('수량산출서','수량을 산출하고 선택한 내역에 반영합니다.',`${add('quantities','산출 행 추가')} `,table(['산출 항목','연결 공종','길이','폭','횟수','산출 수량',''],rows),'산출 수량 = 길이 × 폭 × 횟수. 같은 공종에 연결된 산출 수량을 합산해 내역 수량을 덮어씁니다.'); } else if (tab === 'resources') { const output=EstWorkflows.resources(data); const rows=output.rows.map(({source,quantity})=>`${esc(source.name)}${esc(source.spec)}${esc(source.unit)}${esc(source.kind)}${money(quantity)}${money(EstCalc.sourcePrice(source))}`); $('panel').innerHTML=card('동원인원·자재·장비', '내역 수량에서 일위대가를 전개하여 기초단가별 수량을 집계합니다.', '',table(['품명','규격','단위','비목','소요 수량','단가'],rows),`비율 행의 금액 전개, 단가산출 글과 장비 시간 환산은 미구현입니다. ${esc(output.errors.join(' / '))}`); } else if (tab === 'escalation') { const rows=(data.escalation || []).map((e,i)=>{ const r=EstWorkflows.escalation(e.bid,e.contract,e.current,e.quantity), excluded=EstWorkflows.excluded(e.planned,e.actual,e.progress); return `${field('escalation',i,'name',e.name)}${['bid','contract','current','quantity','planned','actual','progress'].map(k=>`${field('escalation',i,k,e[k],'number')}`).join('')}${money(r.rate)}%${money(r.difference)}${money(r.price)}${money(r.amount)}${money(excluded)}${remove('escalation',i)}`; }); $('panel').innerHTML=card('물가변동', '품목별 기준·계약·비교 단가와 적용제외 수량을 계산합니다.',add('escalation','품목 추가'),table(['품목','입찰 당시','계약 단가','변동 당시','수량','예정공정','실행공정','기성','등락률','등락폭','조정단가','조정금액','제외 수량',''],rows),'REA의 품목조정 경로입니다. 원가계산서 재계산·파일 연동과 성분별 자동 전개는 아직 미구현입니다.'); } 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 `${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)}`; }); $('panel').innerHTML = card('기성고', '전회·금회·사정 수량을 입력해 금회와 누계를 확인합니다.', '', `
${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')}${field('costs',i,'formula',c.formula || '')}${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]),`수식 예: [직접공사비]*0.1, [앞선 항목명]+[재료비]. 수식이 있으면 요율 대신 수식을 사용합니다. ${esc(result.errors.join(' / '))}`); } } 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==='quantities') { data.quantities ||= []; data.quantities.push({id:id(),name:'새 수량산출',estimateId:data.estimate[0]?.id || '',length:1,width:1,count:1}); } if (k==='machines') { data.machines ||= []; data.machines.push({sourceId:'',purchase:0,rentalCoefficient:0,fuelPrice:0,fuelQuantity:0,suppliesRate:0,laborPrice:0,laborFactor:0,laborHours:0}); } if (k==='escalation') { data.escalation ||= []; data.escalation.push({name:'새 품목',bid:0,contract:0,current:0,quantity:0,planned:0,actual:0,progress:0}); } 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)) || (data.machines || []).some(m=>m.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) || data.units.some(u => u.id !== item.id && u.lines.some(l => l.type === '일위대가' && l.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]; const value=el.type==='number' ? Number(el.value) : el.value; if (el.dataset.collection==='machines' && el.dataset.field==='sourceId' && value && data.machines.some((m,i)=>i!==Number(el.dataset.index) && m.sourceId===value)) { alert('이미 다른 기계에 연결된 기초단가입니다.'); render(); return; } if (el.type==='number' && (el.value.trim()==='' || !Number.isFinite(value) || value<0 || value>1e12)) { alert('0 이상 1조 이하의 숫자를 입력하세요.'); render(); return; } if (el.dataset.field==='digits' && (!Number.isInteger(value) || value>6)) { alert('자릿수는 0부터 6까지 입력하세요.'); render(); return; } if (el.dataset.field==='priceIndex') { if (!Number.isInteger(value) || value<1 || value>6) { alert('선택 번호는 1부터 6까지 입력하세요.'); render(); return; } arr[Number(el.dataset.index)].priceIndex=value-1; persist(); render(); return; } if (/^price[0-5]$/.test(el.dataset.field)) { const source=arr[Number(el.dataset.index)]; source.prices ||= [0,0,0,0,0,0]; source.prices[Number(el.dataset.field.slice(-1))]=value; persist(); 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(); document.getElementById('recalculate').addEventListener('click', () => { render(); }); document.getElementById('print').addEventListener('click', () => window.print());