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:
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user