feat(B09 불러오기): 원가계산서 요율 · 기초 단가표 읽기 · 이 공사 단가 책 이음 · 올린 파일 열쇠 길 (PLAN 707-2 · 707-3)

- POST estimation/import/rates · import/prices (file | file_key · 저장 안 함 · patch 만)
- 요율 = 행 이름 → 줄 키 · 손입력 칸(안전 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · 엑셀 대조
- 단가표 = 노임 · 자재 · 기계 → labor_cover · machine_cover · materials.applied · 짝 없음 · 짝 여럿

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:53:33 +09:00
co-authored by Claude Opus 5.5
parent 0b2330aee6
commit 4cd4fdb3a3
5 changed files with 786 additions and 1 deletions
@@ -0,0 +1,174 @@
"""707-2 원가계산서 요율 · 707-3 기초 단가표 불러오기 — 실무 xlsx 넷(EST 거창 · STmate 영월 · 울진 대흥 ·
울진 소광) · 이음 규칙 · 덮개 지킴(새 판 [업데이트] 뒤).
총액 대조 = 엑셀 비목 합 + 실은 요율 · 손입력으로 우리 식(`compute_total`) → 엑셀 총공사비:
대흥 = 같음 · 영월 = 「국가지점번호판검증수수료」 762,000 원만 다름(우리 표에 줄 없음) ·
거창 = 퇴직공제부금비(셈 없는 현행 비목)만큼 · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄.
"""
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Import_Prices as ip # noqa: E402
from B09_Estimation import B09_Estimation_Import_Rates as ir # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
FILES = {
"거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
"소광": "25년 산불진화임도(기번8)(울진소광)/"
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
def _file(tag):
path = K / FILES[tag]
if not path.is_file():
pytest.skip(f"실무 xlsx 없음 — {tag}")
return path.name, path.read_bytes()
@pytest.fixture(scope="module")
def rates():
return {tag: ir.read_rates(*_file(tag)) for tag in FILES}
def test_707_2_요율_짝(rates):
dh = rates["대흥"]
assert dh["kind"] == "STmate" and rates["거창"]["kind"] == "EST"
assert len(dh["rates"]) == 13 # 율 줄 10 + 일반관리비 · 이윤 · 부가세
assert (dh["rates"]["indirect_labor"], dh["rates"]["safety"], dh["rates"]["profit"]) == (
14.5,
1.86,
15.0,
)
assert dh["estimate"]["safety_base"] == 5349000 # 「x1.86% + 5,349,000」
assert dh["estimate"]["procurement_fee_pct"] == 0.54 # 375,160 ÷ 69,474,220
gc = rates["거창"] # EST 요율 칸 · 건강보험 글 「13.595%」 대신 칸 3.595
assert (gc["rates"]["health"], gc["rates"]["care"], gc["rates"]["retirement"]) == (
3.595,
13.14,
2.3,
)
assert gc["estimate"]["safety_base"] == 0 # 글에 + 기초액 없음
assert any("퇴직공제부금비" in n and "셈 없음" in n for n in gc["notes"])
sk = rates["소광"]
assert sum("율 없음" in n for n in sk["notes"]) == 4 # 산출근거 빈칸 — 지금 율 그대로
assert any("페기물처리" in n for n in sk["notes"])
def test_707_2_총액_대조(rates):
got = {tag: ir.check(r, {}, rate_table()) for tag, r in rates.items()}
assert got["대흥"]["grand"] == got["대흥"]["excel_grand"] == 1201879000
assert got["대흥"]["differ"] == []
assert got["영월"]["excel_grand"] - got["영월"]["grand"] == 762000 # 국가지점번호판 줄 몫
assert set(got["영월"]["differ"]) == {"도급공사비", "총공사비"}
lines = {ln["key"]: ln for ln in got["거창"]["lines"]}
rate_keys = ("indirect_labor", "accident", "employment", "health", "care", "pension")
assert all(lines[k]["same"] for k in (*rate_keys, "safety", "other", "environment"))
assert not lines["overhead"]["same"] # 퇴직공제부금비 5,603,907 이 순공사원가에 없음
assert lines["overhead"]["excel"] - lines["overhead"]["ours"] == 448312 # = 5,603,907 × 8 %
def test_707_3_단가표_읽기():
counts = {}
for tag in FILES:
got = ip.read_prices(*_file(tag))
counts[tag] = tuple(len(got[k]) for k in ip.KINDS)
assert counts == {"거창": (17, 52, 22), "영월": (16, 55, 29), "대흥": (12, 39, 25),
"소광": (14, 35, 24)} # fmt: skip
BASE = {
"labor": [
{"key": "LB1", "name": "석공", "spec": "", "unit": "인", "price": 268908},
{"key": "LB2", "name": "보통인부", "spec": "", "unit": "인", "price": 172068},
],
"machine": [
{
"key": "EQ1",
"name": "굴착기(무한궤도)",
"spec": "0.7㎥",
"labor": 1,
"material": 2,
"expense": 3,
},
{
"key": "EQ2",
"name": "굴착기(무한궤도)",
"spec": "0.2㎥",
"labor": 1,
"material": 2,
"expense": 3,
},
], # fmt: skip
"compare": [
{"key": "MO1", "name": "휘발유", "spec": "전국평균", "unit": "L", "applied": 1863.25},
{"key": "MT1", "name": "레미콘", "spec": "25-18-80", "unit": "㎥", "applied": 92330},
],
}
def _m(name, spec, n):
return {"name": name, "spec": spec, "unit": "hr", "labor": n, "material": n, "expense": n}
READ = {
"labor": [
{"name": "석 공", "spec": "", "unit": "인", "price": 258935},
{"name": "보통인부", "spec": "", "unit": "인", "price": 172068}, # 책과 같음
{"name": "벌 목 부", "spec": "", "unit": "인", "price": 253243},
],
"machine": [
_m("굴삭기(무한궤도)", "0.7㎥", 10),
_m("굴삭기(타이어형)", "0.7㎥", 11), # 타이어 = 다른 기계
_m("굴삭기+브레이카", "0.7㎥", 12),
_m("굴삭기(무한궤도)", "0.2㎥", 13),
_m("굴삭기(무한궤도)", "0.2㎥", 14), # 같은 책 줄에 둘 = 못 고름
],
"material": [
{"name": "휘 발 유", "spec": "", "unit": "ℓ", "price": 1473}, # 이름 · 단위(ℓ = L)
{"name": "레미콘(관급)", "spec": "25-18-80", "unit": "m3", "price": 92330},
],
}
def test_707_3_이음_규칙():
user = {"labor_cover": {"LB2": 1}, "materials": {"MO1": {"pick": "유가", "supply": "관급"}}}
out = ip.propose(READ, BASE, user)
patch = out["patch"]
assert patch["labor_cover"] == {"LB1": 258935} # 같은 값 = 옛 덮개 걷음
assert patch["machine_cover"] == {"EQ1": {"labor": 10, "material": 10, "expense": 10}}
assert patch["materials"]["MO1"] == {"supply": "관급", "applied": 1473} # pick 뺌
assert "MT1" not in patch["materials"] # 「(관급)」 이름 다름 = 짝 없음
assert "굴삭기(무한궤도) 0.2㎥(짝 여럿)" in out["missing"]["machine"]
assert out["counts"]["machine"] == {"read": 5, "linked": 1, "covered": 1, "missing": 4,
"book": 2} # fmt: skip
assert out["counts"]["labor"]["linked"] == 2 and out["missing"]["labor"] == ["벌 목 부"]
def test_707_3_덮개_새_판_뒤_남음(tmp_path):
"""[업데이트] = 단가 책만 새로 굳힘 — 사용자 값의 노임 · 기계 덮개는 그대로 · 셈은 덮개."""
cover = {"labor_cover": {"LB1": 258935, "": None},
"machine_cover": {"EQ1": {"labor": 10, "material": 10, "expense": 10},
"EQ2": {"labor": 1}}} # fmt: skip
saved = store.write_user(tmp_path, cover)
assert saved["labor_cover"] == {"LB1": 258935} # 빈 값 버림
assert saved["machine_cover"] == {"EQ1": {"labor": 10, "material": 10, "expense": 10}}
for price in (268908, 280000): # 옛 판 → 새 판 책
store.write_book(tmp_path, {"rows": {"LB1": {"key": "LB1", "price": price}}})
user = store.read_user(tmp_path)
assert user["labor_cover"] == saved["labor_cover"]
inputs = {"labor": [{"key": "LB1", "name": "석공", "price": price}]}
row = compute_base(inputs, user)["labor"][0]
assert (row["price"], row["book"], row["covered"]) == (258935, price, True)