feat(B09): 원가 바탕 단가 — 뼈대 · 노무 · 경비 · 환율 · 자재 대비 · 중기 탭(144-1~4)

- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동
- 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길
- 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록
- 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수)
- 공용 표 = 칸 누름 · 줄로 이동(강조) 더함
- 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 15:01:27 +09:00
co-authored by Claude Opus 5.5
parent c0cf0ab90a
commit e1d83398d4
15 changed files with 2075 additions and 11 deletions
+170
View File
@@ -0,0 +1,170 @@
"""144-2~4 B09 바탕 단가 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Base.py` ↔ `.ts`).
견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 같은 입력:
노무비목록표 E10~E12 · 환율및기초자료 G6~G8 · 경비목록표 E4 · E5 · E7 · E13 · E16 · E21 ·
자재단가대비표 5~12줄 · 중기사용료 1 · 2 · 4 · 11 · 14 · 16 호표(F11 126,000 …)
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Base import ( # noqa: E402
compute_base,
rounddown,
round_half,
roundup,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
FACTOR = {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"}
LABOR = [
{"key": "LB000048", "code": "1048", "name": "건설기계운전사", "price": 267360},
{"key": "LB000049", "code": "1049", "name": "화물차운전사", "price": 226709},
{"key": "LB000050", "code": "1050", "name": "일반기계운전사", "price": 161142},
{"key": "LB000002", "code": "1002", "name": "보통인부", "price": None},
]
def _m(key, name, price, loss, fuel, lph, misc, crew_code):
return {"key": key, "name": name, "spec": "", "price": price, "loss": loss, "fuel": fuel,
"fuel_lph": lph, "misc_pct": misc, "crew": 1 if crew_code else None,
"crew_code": crew_code} # fmt: skip
MACHINES = [
_m("X00001", "불도우저19ton", 184499, 1811, "경유", 25, 16, "1048"),
_m("X00003", "굴삭기(0.2m3)", 62550, 2085, "경유", 5, 21, "1048"),
_m("X00005", "굴삭기(0.7m3)", 110926, 2085, "경유", 11.6, 22, "1048"),
_m("X00032", "덤프자동덮개", 1546, 2684, None, None, None, None),
_m("X00048", "콘크리트믹서0.45m3", 5409, 3039, "휘발유", 3.9, 2, "1050"),
_m("X00074", "덤프트럭2.5ton(토사)", 20793, 2967, "경유", 2.9, None, "1049"),
_m("X09999", "기준가 없는 기계", None, 2000, "중유", 3, 10, "1048"),
]
def _mt(key, name, **prices):
return {"key": key, "name": name, "spec": "", "unit": "", "origin": "M01", "prices": prices}
MATERIALS = [
_mt("MO1", "휘발유", 관급=1369, 물가정보=0, 물가자료=0),
_mt("MO2", "경유", 관급=1272),
_mt("M3", "각재", 관급=500661, 물가정보=829341, 물가자료=748503),
_mt("M4", "못", 관급=1503, 물가정보=1935, 물가자료=1665),
_mt("M8", "화강석", 관급=0, 물가정보=288000, 물가자료=288000),
_mt("M9", "사용자 값 줄", 물가자료=1000),
_mt("b08:sand", "골재"),
]
INPUTS = {
"labor": LABOR,
"machines": MACHINES,
"materials": MATERIALS,
"fuel_keys": {"경유": "MO2", "휘발유": "MO1"},
"driver_factor": FACTOR,
"fx": [
{"key": "FX000001", "name": "USD", "unit": "원/USD", "value": 1414.9, "date": "2026-08-14"}
],
}
USER = {"materials": {"M9": {"applied": 950, "supply": "관급"}, "M4": {"supply": "별도"},
"b08:sand": {"applied": None}}} # fmt: skip
def _run_ts(inputs, user):
calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts"
body = (
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeBase(i, u)));"
)
script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_끝수_엑셀_자리():
assert rounddown(33412.7689, 1) == 33412.7 and rounddown(126000.9) == 126000
assert rounddown(-1.25, 1) == -1.2 # 0 쪽으로
assert roundup(69849380, -3) == 69850000 and roundup(69850000, -3) == 69850000
assert round_half(2.5) == 3 and round_half(0.125, 2) == 0.13 and round_half(-2.5) == -3
assert rounddown(0.1 + 0.2, 1) == 0.3 # 떠도는 끝수는 버리지 않음
def test_144_2_노무_경비_환율_기초자료():
s = compute_base(INPUTS, {})
assert [r["price"] for r in s["labor"]] == [267360, 226709, 161142, None]
assert ("labor", "LB000002") in {(g["tab"], g["key"]) for g in s["gaps"]}
hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]}
assert hours == {"1048": 55700, "1050": 33571, "1049": 47231} # 기초자료 G6 · G8 · G7
assert s["basis"]["drivers"][0]["factor"] == "1/8 × 16/12 × 25/20"
assert [r["price"] for r in s["expense"]][:3] == [
184499,
62550,
110926,
] # 경비목록표 E4 · E5 · E7
assert s["expense"][0]["unit"] == "천원"
assert s["basis"]["fx"][0]["value"] == 1414.9
def test_144_3_자재단가대비_최저_사용자_관급():
s = compute_base(INPUTS, USER)
rows = {r["key"]: r for r in s["compare"]}
# 엑셀 O 열 = 최저 · P 열 출처(0 은 뺌 · 같은 값이면 앞 칸 — 화강석 「물정」)
assert (rows["MO1"]["applied"], rows["MO1"]["applied_from"]) == (1369, "관급")
assert (rows["M3"]["applied"], rows["M3"]["lowest_from"]) == (500661, "관급")
assert rows["M4"]["applied"] == 1503 and rows["M4"]["supply"] == "별도"
assert (rows["M8"]["applied"], rows["M8"]["applied_from"]) == (288000, "물가정보")
assert (rows["M9"]["lowest"], rows["M9"]["applied"], rows["M9"]["applied_from"]) == (
1000,
950,
"사용자",
)
assert rows["M9"]["supply"] == "관급"
assert rows["b08:sand"]["applied"] is None # 출처 없음 · 사용자 빈칸 = 빈칸
material = {r["key"]: r["price"] for r in s["material"]}
assert material == {r["key"]: r["applied"] for r in s["compare"]} # 재료비목록표 = 적용 단가
gaps = [(g["tab"], g["key"]) for g in s["gaps"] if g["tab"] == "compare"]
assert gaps == [("compare", "b08:sand")]
def test_144_4_중기사용료_엑셀_검산():
s = compute_base(INPUTS, {})
got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]}
assert got["X00001"] == (126000, 55700, 36888, 33412) # 중기목록표 4줄
assert got["X00003"] == (76436, 55700, 7695, 13041)
assert got["X00005"] == (96829, 55700, 18001, 23128)
assert got["X00032"] == (414, 0, 0, 414) # 덮개 — 운전사 · 연료 없음
assert got["X00048"] == (40659, 33571, 5445, 1643) # 일반기계운전사 · 휘발유 · 잡유 2 %
assert got["X00074"][1:3] == (47231, 3688) # 화물차운전사 · 잡품 없음
assert got["X09999"] == (None, 55700, None, None) # 기준가 · 중유 단가 없음 = 그 비목 · 합 빈칸
block = s["machine_blocks"][0]
assert [ln["amount"] for ln in block["lines"]] == [
33412.7,
55700,
31800,
5088,
] # 중기사용료 F7~F10
assert block["lines"][0]["qty"] == 0.1811 and block["lines"][0]["ref"] == {
"tab": "expense",
"key": "X00001",
}
why = {g["why"] for g in s["gaps"] if g["key"] == "X09999"}
assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"}
def test_거울_TS_가_같은_값():
for user in ({}, USER):
assert _run_ts(INPUTS, user) == compute_base(INPUTS, user)
+114
View File
@@ -0,0 +1,114 @@
"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기.
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김)
- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함
- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸
"""
import asyncio
import json
import re
import sys
from pathlib import Path
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_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
#: 엑셀 보이는 탭(목차 · 안내 빼고) — 조사 F 1절 차례
EXCEL_TABS = [
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가목록표", "일위대가표",
"단가산출근거목록표", "단가산출근거", "환율및기초자료", "중기목록표", "중기사용료",
"재료비목록표", "노무비목록표", "경비목록표", "자재단가대비표", "재료비수량금액집계표",
"노무비수량금액집계표", "경비수량금액집계표", "중기시간금액집계표",
] # fmt: skip
B08_SHEETS = {
"material": {
"rows": [
{"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16},
{"key": "rubble", "group": "골재", "spec": "막자갈", "unit": "㎥", "amount": None},
{"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": 244},
]
}
}
def test_탭_차례가_엑셀과_같다():
source = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8")
block = source[
source.index("export const TABS") : source.index("];", source.index("export const TABS"))
]
names = re.findall(r'\["\w+", "([^"]+)"\]', block)
assert names[0] == "연결 점검"
assert names[1:] == EXCEL_TABS
def test_길_셋():
routes = {(r.path, tuple(sorted(r.methods))) for r in router_mod.router.routes}
assert ("/api/projects/{project_id}/estimation", ("GET",)) in routes
assert ("/api/projects/{project_id}/estimation", ("PUT",)) in routes
assert ("/api/projects/{project_id}/estimation/prices", ("POST",)) in routes
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "app.include_router(b09_estimation_router" in main
def test_사용자_값_다듬기_저장_자리(tmp_path):
raw = {
"materials": {"MT1": {"applied": 1200, "supply": "관급", "x": 1}, "MT2": {"applied": None},
"MT3": {"supply": "엉뚱"}},
"estimate": {"estimated_price": "abc", "profit_adjust": 905980, "work_type": "토목",
"rates": {"RT000001": 3.56, "RT000002": None}, "zzz": 1},
"other": 1,
} # fmt: skip
clean = store.write_user(tmp_path, raw)
assert clean == {
"materials": {"MT1": {"applied": 1200.0, "supply": "관급"}, "MT2": {"applied": None}},
"estimate": {"estimated_price": None, "profit_adjust": 905980.0, "work_type": "토목",
"rates": {"RT000001": 3.56, "RT000002": None}},
} # fmt: skip
path = tmp_path / "B09_Estimation" / "v1" / "user_values.json"
assert json.loads(path.read_text(encoding="utf-8")) == clean
assert store.read_user(tmp_path) == clean
assert store.read_book(tmp_path) is None
def test_단가_굳히기_운전사_연료_따라옴():
book = prices.refresh_book(["EQ000004"]) # 불도저(무한궤도) 19ton · 조종원 1048 · 경유
rows = book["rows"]
assert set(rows) == {"EQ000004", "LB000048", "MO000002"}
assert rows["EQ000004"]["loss"] == 1811 and rows["EQ000004"]["crew_code"] == "1048"
assert rows["LB000048"]["code"] == "1048" and rows["MO000002"]["prices"]["유가"] > 0
assert book["driver_factor"] == {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"}
# [저장] = 책에 없는 새 줄만 더함 — 있는 줄 값은 안 바꿈
stale = {**book, "rows": {**rows, "LB000048": {**rows["LB000048"], "price": 1}}}
grown = prices.extend_book(stale, ["EQ000004", "LB000002"])
assert grown["rows"]["LB000048"]["price"] == 1 and "LB000002" in grown["rows"]
# 책 밖 줄 = 지금 M01 값 + fresh 표시
inputs = prices.build_inputs(stale, ["EQ000004", "LB000002"])
fresh = {row["key"]: row.get("fresh", False) for row in inputs["labor"]}
assert fresh == {"LB000002": True, "LB000048": False}
assert inputs["fuel_keys"] == {"경유": "MO000002"}
def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch):
async def fake_materials(_project_id):
return B08_SHEETS
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
body = asyncio.run(router_mod.payload("p", tmp_path))
sheets = body["sheets"]
assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == []
assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만
assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님)
assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [
("compare", "b08:sand"),
("compare", "b08:cement"),
]
assert any("연결된 구조물 조합 사본 없음" in n for n in body["notes"])
assert body["book"] is None # GET 은 아무것도 쓰지 않음
assert not (tmp_path / "B09_Estimation").exists()