feat: expand EST calculation coverage

This commit is contained in:
2026-11-04 22:23:40 +09:00
parent c7d2e99a25
commit 8715ec9206
7 changed files with 304 additions and 36 deletions
+137 -1
View File
@@ -18,7 +18,7 @@ def calculate(data):
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}]}],
"units": [{"id": "u", "lines": [{"sourceId": "s", "quantity": 2, "digits": 2}]}],
"estimate": [{"unitId": "u", "quantity": 3}],
"costs": [{"base": "직접공사비", "rate": 10}, {"base": "누계", "rate": 5}],
}
@@ -42,6 +42,74 @@ def test_est_test_unlinked_rows_contribute_zero():
assert result["units"]["u"]["total"] == result["total"] == 0
def test_source_selection_recursive_unit_and_percent():
data = {
"sources": [
{
"id": "s",
"kind": "실적",
"price": 900,
"prices": [0, 125, 155, 0, 0, 0],
"priceMode": "평균",
}
],
"units": [
{"id": "u1", "name": "기초", "lines": [{"sourceId": "s", "quantity": 2}]},
{
"id": "u2",
"name": "상위",
"lines": [
{"type": "일위대가", "sourceId": "", "unitId": "u1", "quantity": 1},
{
"type": "비율",
"sourceId": "",
"base": "실적",
"kind": "경비",
"quantity": 10,
},
],
},
],
"estimate": [{"unitId": "u2", "quantity": 2}],
"costs": [],
}
result = calculate(data)
assert result["units"]["u1"]["parts"]["실적"] == 280
assert result["units"]["u2"]["parts"]["경비"] == 28
assert result["directTotal"] == 616
def test_sample_percentage_row_and_cycle_error():
data = {
"sources": [{"id": "labor", "kind": "노무비", "price": 66841}],
"units": [
{
"id": "u",
"name": "호표",
"lines": [
{"sourceId": "labor", "quantity": 1},
{
"type": "비율",
"sourceId": "",
"base": "노무비",
"kind": "재료비",
"quantity": 2,
},
],
}
],
"estimate": [],
"costs": [],
}
result = calculate(data)
assert result["units"]["u"]["parts"]["재료비"] == 1336
data["units"][0]["lines"].append(
{"type": "일위대가", "sourceId": "", "unitId": "u", "quantity": 1}
)
assert result["errors"] == []
assert calculate(data)["errors"]
def test_progress_uses_approved_quantity_only_when_selected():
script = (
'const vm=require("vm"),fs=require("fs");const c={};'
@@ -55,3 +123,71 @@ def test_progress_uses_approved_quantity_only_when_selected():
assert ordinary["currentAmount"] == 300
assert ordinary["cumulativeAmount"] == 500
assert approved["cumulativeAmount"] == 300
def test_machine_components():
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.machine({purchase:1000000,rentalCoefficient:100,"
"fuelPrice:1000,fuelQuantity:2,suppliesRate:5,laborPrice:8000,laborFactor:0.125,laborHours:1})));"
)
result = json.loads(
subprocess.check_output(["node", "-e", script, str(CALC)], text=True, encoding="utf-8")
)
assert result == {"재료비": 2100, "노무비": 1000, "경비": 10, "실적": 0}
def test_formula_parser_rejects_invalid_expressions():
formula = ROOT / "tools" / "est-test" / "formula.js"
script = (
'const vm=require("vm"),fs=require("fs");const c={};'
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.f=EstFormula",c);'
'let failed=false;try{c.f.evaluate("1/0",{})}catch{failed=true};'
'console.log(JSON.stringify([c.f.evaluate("([금액]+2)*3",{"금액":8}),failed]));'
)
assert json.loads(
subprocess.check_output(["node", "-e", script, str(formula)], text=True, encoding="utf-8")
) == [30, True]
def test_escalation_comparison_branches():
workflows = ROOT / "tools" / "est-test" / "workflows.js"
script = (
'const vm=require("vm"),fs=require("fs");const c={};'
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.w=EstWorkflows",c);'
"console.log(JSON.stringify([c.w.escalation(100,80,120,2),"
"c.w.escalation(100,110,125,2),c.w.escalation(100,80,90,1),"
"c.w.excluded(3,5,4)]));"
)
a, b, lower, excluded = json.loads(
subprocess.check_output(["node", "-e", script, str(workflows)], text=True, encoding="utf-8")
)
assert (a["rate"], a["difference"], a["amount"]) == (20, 16, 192)
assert (b["difference"], b["amount"]) == (15, 250)
assert lower["difference"] == 0
assert excluded == 5
def test_resource_quantities_follow_nested_units():
workflows = ROOT / "tools" / "est-test" / "workflows.js"
script = (
'const vm=require("vm"),fs=require("fs");const c={};'
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.w=EstWorkflows",c);'
"console.log(JSON.stringify(c.w.resources(JSON.parse(process.argv[2]))));"
)
data = {
"sources": [{"id": "s", "name": "자재"}],
"units": [
{"id": "a", "lines": [{"sourceId": "s", "quantity": 2}]},
{"id": "b", "lines": [{"type": "일위대가", "unitId": "a", "quantity": 3}]},
],
"estimate": [{"unitId": "b", "quantity": 4}],
}
result = json.loads(
subprocess.check_output(
["node", "-e", script, str(workflows), json.dumps(data)], text=True, encoding="utf-8"
)
)
assert result["rows"][0]["quantity"] == 24
assert result["errors"] == []
+2 -2
View File
@@ -2,6 +2,6 @@
`index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다.
기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름, 간단한 수량산출 연결과 기성 수량 입력을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
기초 단가의 6개 출처 선택, 일위대가 간 참조·비율, 4개 비목 집계, 단순 기계경비, 원가 사칙연산 수식, 수량산출 연결, 기성 수량, 물가변동 품목 단가, 동원 수량 전개를 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 및 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다.
EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비의 외화·다중 연료·낙찰 분기, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 원가 연동, 동원의 산근 전개와 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다.
+33 -14
View File
@@ -2,6 +2,8 @@ const key = 'aislo-est-test-v1';
const sample = () => ({
projectName: '시험 내역서',
quantities: [],
machines: [],
escalation: [],
progress: [],
progressApproved: false,
sources: [
@@ -18,11 +20,11 @@ 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.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) &&
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)) && ids(d.costs);
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(); }
@@ -42,14 +44,18 @@ const card = (title, subtitle, button, body, note = '') => `<div class="card"><d
function render() {
const result = EstCalc.calculate(data);
$('projectName').value = data.projectName || '';
$('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['원가 합계',result.total]].map(([label,n]) => `<div class="metric"><span>${label}</span><strong>${money(n)} 원</strong></div>`).join('');
$('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['실적',result.direct['실적']],['원가 합계',result.total]].map(([label,n]) => `<div class="metric"><span>${label}</span><strong>${money(n)} 원</strong></div>`).join('');
if (result.errors.length) $('summary').innerHTML += `<div class="metric warning" role="alert">계산 오류: ${esc(result.errors.join(' / '))}</div>`;
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) => `<tr><td>${field('sources',i,'name',s.name)}</td><td>${field('sources',i,'spec',s.spec)}</td><td>${field('sources',i,'unit',s.unit)}</td><td>${select('sources',i,'kind',s.kind,kindChoices)}</td><td>${field('sources',i,'price',s.price,'number')}</td><td>${remove('sources',i)}</td></tr>`);
$('panel').innerHTML = card('기초 단가', '자재·노무·장비 등의 시험 단가를 직접 입력합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','단가 (원)',''],rows), '이곳의 단가가 일위대가 구성 행에 연결됩니다. 연결 중인 단가는 삭제할 수 없습니다.');
const rows = data.sources.map((s,i) => `<tr><td>${field('sources',i,'name',s.name)}</td><td>${field('sources',i,'spec',s.spec)}</td><td>${field('sources',i,'unit',s.unit)}</td><td>${select('sources',i,'kind',s.kind,kindChoices)}</td><td>${select('sources',i,'priceMode',s.priceMode || '직접입력',['직접입력','최저','평균','최고','선택'].map(k=>[k,k]))}</td>${Array.from({length:6},(_,n)=>`<td>${field('sources',i,`price${n}`,s.prices?.[n] || 0,'number')}</td>`).join('')}<td>${field('sources',i,'priceIndex',(s.priceIndex || 0)+1,'number')}</td><td>${field('sources',i,'price',s.price,'number')}</td><td class="money">${money(EstCalc.sourcePrice(s))}</td><td>${remove('sources',i)}</td></tr>`);
$('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 `<tr><td>${select('machines',i,'sourceId',m.sourceId,sourceChoices)}</td>${['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].map(k=>`<td>${field('machines',i,k,m[k],'number')}</td>`).join('')}${EstCalc.kinds.map(k=>`<td class="money">${money(p[k])}</td>`).join('')}<td>${remove('machines',i)}</td></tr>`; });
$('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);
@@ -58,17 +64,24 @@ function render() {
if (u) {
body += table(['일위대가 이름','단위','합계 (원)'],[`<tr><td>${field('units',ui,'name',u.name)}</td><td>${field('units',ui,'unit',u.unit)}</td><td class="money">${money(result.units[u.id].total)}</td></tr>`]);
body += `<div class="cardhead"><h2>구성 항목</h2>${add('lines','구성 추가')}</div>`;
body += table(['기초 단가','소요량','단가 (원)','비목','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; return `<tr><td>${select('lines',i,'sourceId',l.sourceId,sourceChoices)}</td><td>${field('lines',i,'quantity',l.quantity,'number')}</td><td class="money">${r.source ? money(r.source.price) : '—'}</td><td>${esc(r.source?.kind || '미연결')}</td><td class="money">${money(r.amount)}</td><td>${remove('lines',i)}</td></tr>`;}));
body += `<div class="details">${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}<br>행 금액은 소수 둘째 자리까지 절사하고 비목별 합계는 원 미만을 절사합니다.</div>`;
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 `<tr><td>${select('lines',i,'type',type,['기초단가','일위대가','비율'].map(k=>[k,k]))}</td><td>${link}</td><td>${type==='비율' ? select('lines',i,'kind',l.kind || '경비',kindChoices) : esc(r.source?.kind || (r.unit ? '비목별' : '미연결'))}</td><td>${field('lines',i,'quantity',l.quantity,'number')}</td><td>${field('lines',i,'digits',l.digits ?? 0,'number','max="6"')}</td><td class="money">${money(r.amount)}</td><td>${remove('lines',i)}</td></tr>`;}));
body += `<div class="details">${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}<br>행 비목별 자릿수 절사 후 합계를 원 미만 절사합니다. ${esc(result.errors.join(' / '))}</div>`;
} else body += '<div class="empty">일위대가를 추가하세요.</div>';
$('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 `<tr><td>${field('estimate',i,'name',e.name)}</td><td>${select('estimate',i,'unitId',e.unitId,unitChoices)}</td><td>${field('estimate',i,'quantity',e.quantity,'number')}</td><td class="money">${u ? money(u.total) : '—'}</td>${EstCalc.kinds.map(k=>`<td class="money">${money(r.parts[k])}</td>`).join('')}<td class="money">${money(r.total)}</td><td>${remove('estimate',i)}</td></tr>`;});
$('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.');
$('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) => `<tr><td>${field('quantities',i,'name',q.name)}</td><td>${select('quantities',i,'estimateId',q.estimateId,choices)}</td><td>${field('quantities',i,'length',q.length,'number')}</td><td>${field('quantities',i,'width',q.width,'number')}</td><td>${field('quantities',i,'count',q.count,'number')}</td><td class="money">${money(q.length*q.width*q.count)}</td><td>${remove('quantities',i)}</td></tr>`);
$('panel').innerHTML = card('수량산출서','수량을 산출하고 선택한 내역에 반영합니다.',`${add('quantities','산출 행 추가')} <button id="applyQuantities">내역에 수량 반영</button>`,table(['산출 항목','연결 공종','길이','폭','횟수','산출 수량',''],rows),'산출 수량 = 길이 × 폭 × 횟수. 같은 공종에 연결된 산출 수량을 합산해 내역 수량을 덮어씁니다.');
} else if (tab === 'resources') {
const output=EstWorkflows.resources(data);
const rows=output.rows.map(({source,quantity})=>`<tr><td>${esc(source.name)}</td><td>${esc(source.spec)}</td><td>${esc(source.unit)}</td><td>${esc(source.kind)}</td><td class="money">${money(quantity)}</td><td class="money">${money(EstCalc.sourcePrice(source))}</td></tr>`);
$('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 `<tr><td>${field('escalation',i,'name',e.name)}</td>${['bid','contract','current','quantity','planned','actual','progress'].map(k=>`<td>${field('escalation',i,k,e[k],'number')}</td>`).join('')}<td class="money">${money(r.rate)}%</td><td class="money">${money(r.difference)}</td><td class="money">${money(r.price)}</td><td class="money">${money(r.amount)}</td><td class="money">${money(excluded)}</td><td>${remove('escalation',i)}</td></tr>`; });
$('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;
@@ -78,8 +91,8 @@ function render() {
$('panel').innerHTML = card('기성고', '전회·금회·사정 수량을 입력해 금회와 누계를 확인합니다.', '<button id="syncProgress">내역 불러오기</button>', `<div class="details"><label><input id="progressApproved" type="checkbox" style="width:auto;min-width:0" ${data.progressApproved ? 'checked' : ''}> 누계에 금회사정 수량 사용</label></div>${table(['공종','계약 수량','단가','전회 수량','금회 수량','금회사정 수량','금회 금액','누계 수량','누계 금액',''],rows)}`, '내역 불러오기는 새 공종만 추가합니다. 이 화면은 수량 입력 방식만 지원하며 회차별 이력·기성 원가계산은 아직 구현되지 않았습니다.');
} else {
const bases = [...EstCalc.kinds,'직접공사비','누계'].map(k=>[k,k]);
const rows = data.costs.map((c,i) => `<tr><td>${field('costs',i,'name',c.name)}</td><td>${select('costs',i,'base',c.base,bases)}</td><td class="money">${money(result.costs[i].base)}</td><td>${field('costs',i,'rate',c.rate,'number')}</td><td class="money">${money(result.costs[i].amount)}</td><td class="money">${money(result.costs[i].running)}</td><td>${remove('costs',i)}</td></tr>`);
$('panel').innerHTML = card('원가계산서','기준 금액과 요율을 선택해 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','계산 금액','누계',''],[`<tr class="total"><td colspan="4">직접공사비</td><td class="money">${money(result.directTotal)}</td><td class="money">${money(result.directTotal)}</td><td></td></tr>`,...rows]),'각 행은 기준 금액 × 요율 ÷ 100의 원 미만을 절사합니다. 누계는 앞선 항목까지의 합계입니다. 수식 편집 및 법정 요율은 제공하지 않습니다.');
const rows = data.costs.map((c,i) => `<tr><td>${field('costs',i,'name',c.name)}</td><td>${select('costs',i,'base',c.base,bases)}</td><td class="money">${money(result.costs[i].base)}</td><td>${field('costs',i,'rate',c.rate,'number')}</td><td>${field('costs',i,'formula',c.formula || '')}</td><td class="money">${money(result.costs[i].amount)}</td><td class="money">${money(result.costs[i].running)}</td><td>${remove('costs',i)}</td></tr>`);
$('panel').innerHTML = card('원가계산서','기준 금액·요율 또는 수식을 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','수식','계산 금액','누계',''],[`<tr class="total"><td colspan="5">직접공사비</td><td class="money">${money(result.directTotal)}</td><td class="money">${money(result.directTotal)}</td><td></td></tr>`,...rows]),`수식 예: [직접공사비]*0.1, [앞선 항목명]+[재료비]. 수식이 있으면 요율 대신 수식을 사용합니다. ${esc(result.errors.join(' / '))}`);
}
}
function persist() { try { sessionStorage.setItem(key,JSON.stringify(data)); } catch { alert('임시 저장 공간을 사용할 수 없습니다. JSON 파일로 저장해 주세요.'); } }
@@ -93,6 +106,8 @@ document.addEventListener('click', event => {
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();
}
@@ -112,9 +127,9 @@ document.addEventListener('click', event => {
}
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==='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)) { 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();
}
});
@@ -126,7 +141,11 @@ document.addEventListener('change', event => {
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); });
+64 -17
View File
@@ -1,43 +1,90 @@
// EST 보고서의 성분별 행 절사와 원 미만 합계 절사를 참고한 테스트 계산.
// shortcut: 실제 EST의 모든 자릿수·비율 옵션은 미구현, 원본 대조 시 확장.
// EST Plus 2 REA 최종보고서 §3~7: 비목별 계산과 연결 순서.
const EstCalc = (() => {
const kinds = ['재료비', '노무비', '경비'];
const trunc = n => Math.trunc((Number(n) || 0) * 100) / 100;
const kinds = ['재료비', '노무비', '경비', '실적'];
const blank = () => Object.fromEntries(kinds.map(k => [k, 0]));
const truncate = (n, digits = 0) => Math.trunc((n + Math.sign(n) * 2e-10) * 10 ** digits) / 10 ** digits;
function sourcePrice(source) {
if (!source) return 0;
const values = (source.prices || []).filter(v => Number.isFinite(v) && v > 0);
if (source.priceMode === '최저') return values.length ? Math.min(...values) : 0;
if (source.priceMode === '최고') return values.length ? Math.max(...values) : 0;
if (source.priceMode === '평균') return values.length ? Math.trunc(values.reduce((a,b) => a+b, 0) / values.length) : 0;
if (source.priceMode === '선택') return source.prices?.[source.priceIndex || 0] || 0;
return source.price || 0;
}
function machine(m) {
const digits = m.digits ?? 1;
const rental = truncate(m.purchase * m.rentalCoefficient * 1e-7, digits);
const fuel = truncate(m.fuelPrice * m.fuelQuantity, digits);
const supplies = truncate(fuel * m.suppliesRate / 100, digits);
const operator = truncate(m.laborPrice * m.laborFactor * m.laborHours, digits);
return { 재료비: Math.trunc(fuel + supplies), 노무비: Math.trunc(operator), 경비: Math.trunc(rental), 실적: 0 };
}
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 records = Object.fromEntries(data.units.map(u => [u.id, u]));
const units = Object.create(null), visiting = new Set(), errors = [];
function calculateUnit(u) {
if (units[u.id]) return units[u.id];
if (visiting.has(u.id)) { errors.push(`일위대가 순환 참조: ${u.name}`); return {parts: blank(), lines: [], total: 0}; }
visiting.add(u.id);
const parts = blank();
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 };
const source = Object.hasOwn(prices, line.sourceId) ? prices[line.sourceId] : null;
const unit = Object.hasOwn(records, line.unitId) ? records[line.unitId] : null;
let unitParts = blank();
if (line.type === '일위대가') {
if (unit) unitParts = calculateUnit(unit).parts;
else errors.push(`${u.name}: 연결된 일위대가 없음`);
}
else if (line.type === '비율') {
const base = line.base || '합계';
if (base === '합계') unitParts[line.kind || '경비'] = kinds.reduce((sum,k) => sum + parts[k], 0);
else if (base === '비목별') unitParts = { ...parts };
else unitParts[line.kind || '경비'] = parts[base] || 0;
} else if (source) {
const machinery = (data.machines || []).find(m => m.sourceId === source.id);
if (machinery) unitParts = machine(machinery);
else unitParts[source.kind] = sourcePrice(source);
} else errors.push(`${u.name}: 연결된 기초단가 없음`);
const quantity = line.type === '비율' || line.unit === '%' ? line.quantity / 100 : line.quantity;
const components = Object.fromEntries(kinds.map(k => [k, truncate(unitParts[k] * quantity, line.digits ?? 0)]));
const amount = kinds.reduce((sum,k) => sum + components[k], 0);
if (line.calcIndex !== 2 && line.calcIndex !== 3) for (const k of kinds) parts[k] += components[k];
return { source, unit, components, 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]));
visiting.delete(u.id);
return units[u.id] = { parts, lines, total: kinds.reduce((sum, k) => sum + parts[k], 0) };
}
for (const u of data.units) calculateUnit(u);
const direct = blank();
const rows = data.estimate.map(row => {
const unit = units[row.unitId];
const unit = Object.hasOwn(units, row.unitId) ? units[row.unitId] : null;
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 references = Object.assign(Object.create(null), direct, { 직접공사비: 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);
references['누계'] = running;
let amount;
try { amount = row.formula ? Math.trunc(EstFormula.evaluate(row.formula, references)) : Math.trunc(base * row.rate / 100); }
catch (error) { errors.push(`${row.name}: ${error.message}`); amount = 0; }
running += amount;
references[row.name] = amount;
return { base, amount, running };
});
return { units, rows, direct, directTotal, costs, total: running };
return { units, rows, direct, directTotal, costs, total: running, errors };
}
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 };
return { kinds, calculate, progress, sourcePrice, truncate, machine };
})();
if (typeof module !== 'undefined') module.exports = EstCalc;
+33
View File
@@ -0,0 +1,33 @@
// 원가 및 단가산출의 사칙연산·괄호·[항목명] 참조를 평가한다.
const EstFormula = (() => {
function evaluate(expression, values) {
const tokens = expression.match(/\s+|\d+(?:\.\d+)?|\[[^\]]+\]|[()+*/-]/g) || [];
if (tokens.join('') !== expression) throw Error('지원하지 않는 수식 문자');
const items = tokens.filter(t => !/^\s+$/.test(t));
let i = 0;
function primary() {
const token = items[i++];
if (token === '(') { const n = sum(); if (items[i++] !== ')') throw Error('괄호 오류'); return n; }
if (token === '-') return -primary();
if (token === '+') return primary();
if (/^\d/.test(token || '')) return Number(token);
if (token?.startsWith('[')) { const key=token.slice(1,-1); if (!Object.hasOwn(values,key)) throw Error(`참조 항목 없음: ${key}`); return values[key]; }
throw Error('수식 오류');
}
function product() {
let n = primary();
while (items[i] === '*' || items[i] === '/') { const op=items[i++], v=primary(); if (op === '/' && v === 0) throw Error('0으로 나눌 수 없음'); n=op === '*' ? n*v : n/v; }
return n;
}
function sum() {
let n = product();
while (items[i] === '+' || items[i] === '-') { const op=items[i++], v=product(); n=op === '+' ? n+v : n-v; }
return n;
}
const result=sum();
if (i !== items.length || !Number.isFinite(result)) throw Error('수식 오류');
return result;
}
return { evaluate };
})();
if (typeof module !== 'undefined') module.exports = EstFormula;
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>EST 업무 테스트</title><link rel="stylesheet" href="style.css">
<script defer src="calc.js"></script><script defer src="app.js"></script>
<script defer src="formula.js"></script><script defer src="calc.js"></script><script defer src="workflows.js"></script><script defer src="app.js"></script>
</head>
<body>
<header>
@@ -12,7 +12,7 @@
<div class="toolbar"><button id="reset">▤ 새 내역서</button><label class="button">▣ 불러오기<input id="open" type="file" accept=".json,application/json" hidden></label><button id="save">▣ 저장하기</button><i></i><button id="recalculate">⟳ 전체 재계산</button><button id="print">▧ 인쇄</button><i></i><span>공사명</span><input id="projectName" aria-label="공사명" value="시험 내역서" maxlength="200"></div>
</header>
<div class="workspace">
<aside><div class="sidehead">작업화면 이동</div><nav class="tabs" aria-label="작업 화면"><button data-tab="estimate">▦ <span>내역서</span></button><button data-tab="units">▤ <span>일위대가</span></button><button data-tab="sources">▥ <span>기초단가</span></button><button data-tab="quantities">▤ <span>수량산출</span></button><button data-tab="costs">▧ <span>원가계산서</span></button><button data-tab="progress">▦ <span>기성고</span></button></nav><div class="sideinfo">작업 자료<br><b>독립 테스트</b><p>내용을 수정한 뒤 저장하기를 눌러 파일로 보관하세요.</p></div></aside>
<aside><div class="sidehead">작업화면 이동</div><nav class="tabs" aria-label="작업 화면"><button data-tab="estimate">▦ <span>내역서</span></button><button data-tab="units">▤ <span>일위대가</span></button><button data-tab="sources">▥ <span>기초단가</span></button><button data-tab="machines">⚙ <span>기계경비</span></button><button data-tab="quantities">▤ <span>수량산출</span></button><button data-tab="costs">▧ <span>원가계산서</span></button><button data-tab="progress">▦ <span>기성고</span></button><button data-tab="escalation">▤ <span>물가변동</span></button><button data-tab="resources">▤ <span>동원 집계</span></button></nav><div class="sideinfo">작업 자료<br><b>독립 테스트</b><p>내용을 수정한 뒤 저장하기를 눌러 파일로 보관하세요.</p></div></aside>
<main><div class="notice">구현 중 · 상단 메뉴는 배치 참고용이며 왼쪽 작업화면과 도구 버튼만 동작합니다. 예제 수치는 임의 값이며 원본 EST와의 동등성은 검증되지 않았습니다.</div><div class="summary" id="summary"></div><section id="panel" aria-live="polite"></section></main>
</div>
<footer><span>● 준비</span><span>저장: JSON 파일 · 탭을 닫기 전에 저장하세요</span></footer>
+33
View File
@@ -0,0 +1,33 @@
// EST Plus 2 REA 최종보고서 §6-11, §6-12의 계산 경로.
const EstWorkflows = (() => {
function escalation(bid, contract, current, quantity) {
if (!bid || !current) return { rate: 0, difference: 0, price: contract, amount: Math.trunc(contract * quantity) };
const rate = Math.trunc(((current - bid) / bid) * 10000) / 100;
let difference = 0;
if (bid >= contract && current >= bid) difference = Math.trunc(contract * rate / 100);
else if (bid < contract && current >= contract) difference = Math.trunc(current - contract);
const price = contract + difference;
return { rate, difference, price, amount: Math.trunc(price * quantity) };
}
function excluded(planned, actual, progress) { return Math.max(planned, actual, progress); }
function resources(data) {
const sources = Object.fromEntries(data.sources.map(s => [s.id,s]));
const units = Object.fromEntries(data.units.map(u => [u.id,u]));
const quantities = Object.create(null), errors = [];
function expand(unitId, multiplier, path) {
const unit = Object.hasOwn(units, unitId) ? units[unitId] : null;
if (!unit) return;
if (path.includes(unitId)) { errors.push(`순환 참조: ${unit.name}`); return; }
for (const line of unit.lines) {
if (line.calcIndex === 2 || line.calcIndex === 3 || line.type === '비율') continue;
const quantity = multiplier * line.quantity;
if (line.type === '일위대가') expand(line.unitId,quantity,[...path,unitId]);
else if (Object.hasOwn(sources,line.sourceId)) quantities[line.sourceId] = (quantities[line.sourceId] || 0) + quantity;
}
}
for (const row of data.estimate) expand(row.unitId,row.quantity,[]);
return { rows: Object.entries(quantities).map(([id,quantity]) => ({source:sources[id],quantity})), errors };
}
return { escalation, excluded, resources };
})();
if (typeof module !== 'undefined') module.exports = EstWorkflows;