Files
Aislo/B09_Estimation/B09_Estimation_Prices.py
T
eomsangdonandClaude Opus 5.5 e1d83398d4 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
2026-10-09 15:01:27 +09:00

267 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나).
원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) ·
[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가).
줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음:
연결된 사본이 쓰는 자원 키(`linked_resource_keys` — 143-1 「연결된 사본」 원천 · 145-1 · 2 호표가
들어오면 그 결과를 돌려줌 · 그전엔 빈 목록 = 빈 표 「연결 없음」)
+ 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」)
+ B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력)
운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱).
연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230)
DRIVER_TABLE = "CC000336"
DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수")
#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값
FUEL_REGION = "전국평균"
#: 자재품목 값 열 → 대비표 출처 칸
MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격")
def linked_resource_keys(project_root: str | Path) -> list[str]:
"""연결된 사본(구조물 조합 사본)이 쓰는 자원 키(LB · EQ · MT · MO) — 자리만.
143-1 「연결된 사본」 원천 함수 · 145-1 · 2 산근 · 대가 호표가 들어오면 그 자원 키를 돌려줌 ·
그전엔 빈 목록(구조물 조합 사본 없으면 그 구조물은 없음 · 연결 안 된 값은 비움).
"""
return []
def _f(value: Any) -> float | None:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def _master():
return m01.loaded()[1]
def _row(master, key: str) -> dict[str, Any] | None:
return master.index.get(key[:2], {}).get(key)
def labor_input(row: dict[str, Any]) -> dict[str, Any]:
return {
"key": row["키"],
"code": str(row.get("원문번호") or ""),
"name": row.get("이름") or "",
"spec": row.get("규격") or "",
"unit": "인",
"price": _f(row.get("값")),
"source": row.get("출처") or "",
}
def machine_input(row: dict[str, Any]) -> dict[str, Any]:
v = row.get("값") or {}
return {
"key": row["키"],
"code": str(row.get("원문번호") or ""),
"name": row.get("이름") or "",
"spec": row.get("규격") or "",
"price": _f(v.get("가격")),
"loss": _f(v.get("손료계수")),
"fuel": v.get("주연료종류") or None,
"fuel_lph": _f(v.get("주연료")),
"misc_pct": _f(v.get("잡재료율")),
"crew": _f(v.get("조종원")),
"crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None,
"source": (row.get("출처") or {}).get("가격") or row.get("원문") or "",
}
def material_input(row: dict[str, Any]) -> dict[str, Any]:
"""자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄."""
if row["키"].startswith("MO"):
prices = {"유가": _f(row.get("값"))}
pages = {"유가": row.get("규격") or None}
origin = "유가"
spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip()
unit = (row.get("단위") or "").replace("원/", "")
else:
prices = {s: _f(row.get(s)) for s in MT_SOURCES}
pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"}
origin = "M01"
spec = row.get("규격") or ""
unit = row.get("단위") or ""
return {
"key": row["키"],
"name": row.get("이름") or "",
"spec": spec,
"unit": unit,
"origin": origin,
"prices": prices,
"pages": pages,
"source": row.get("출처") or "",
}
def driver_factor(master) -> dict[str, Any] | None:
table = _row(master, DRIVER_TABLE)
if not table:
return None
parts = {r.get("구분"): r for r in table.get("줄") or []}
if any(p not in parts for p in DRIVER_PARTS):
return None
top = bottom = 1
text = []
for p in DRIVER_PARTS:
top *= int(parts[p]["분자"])
bottom *= int(parts[p]["분모"])
text.append(f"{parts[p]['분자']}/{parts[p]['분모']}")
return {"num": top, "den": bottom, "text": " × ".join(text)}
def fx_inputs(master) -> list[dict[str, Any]]:
return [
{"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "",
"value": _f(row.get("값")), "date": row.get("규격") or ""}
for key, row in sorted(master.index.get("FX", {}).items())
] # fmt: skip
def _fuel_row(master, kind: str) -> dict[str, Any] | None:
return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}")
def demand(master, keys: list[str]) -> dict[str, Any]:
"""수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키."""
labor: list[str] = []
machines: list[str] = []
materials: list[str] = []
missing: list[str] = []
fuel_keys: dict[str, str] = {}
def add(bucket: list[str], key: str) -> None:
if key not in bucket:
bucket.append(key)
for key in keys:
if _row(master, key) is None:
missing.append(key)
elif key.startswith("LB"):
add(labor, key)
elif key.startswith("EQ"):
add(machines, key)
elif key.startswith(("MT", "MO")):
add(materials, key)
for key in machines:
v = _row(master, key).get("값") or {}
code = v.get("조종원직종")
driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None
if driver:
add(labor, driver["키"])
kind = v.get("주연료종류")
fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None
if fuel:
add(materials, fuel["키"])
fuel_keys[kind] = fuel["키"]
return {
"labor": labor,
"machines": machines,
"materials": materials,
"fuel_keys": fuel_keys,
"missing": missing,
}
def _convert(master, key: str) -> dict[str, Any]:
row = _row(master, key)
if key.startswith("LB"):
return labor_input(row)
if key.startswith("EQ"):
return machine_input(row)
return material_input(row)
def refresh_book(keys: list[str]) -> dict[str, Any]:
"""[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘."""
master = _master()
want = demand(master, keys)
rows = {
key: _convert(master, key)
for key in [*want["labor"], *want["machines"], *want["materials"]]
}
return {
"made": datetime.now().isoformat(timespec="seconds"),
"rows": rows,
"driver_factor": driver_factor(master),
"fx": fx_inputs(master),
}
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
if book is None:
return refresh_book(keys)
master = _master()
want = demand(master, keys)
rows = dict(book["rows"])
for key in [*want["labor"], *want["machines"], *want["materials"]]:
if key not in rows:
rows[key] = _convert(master, key)
return {**book, "rows": rows}
def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]:
"""B08 총괄자재수량집계표 줄(수량 있는 줄만) → 자재 입력 줄(M01 짝 없음 · 출처 빈칸)."""
rows = ((b08_sheets or {}).get("material") or {}).get("rows") or []
out = []
for row in rows:
if row.get("amount") is None:
continue
out.append(
{
"key": f"b08:{row['key']}",
"name": row.get("group") or "",
"spec": row.get("spec") or "",
"unit": row.get("unit") or "",
"origin": "B08",
"prices": {},
"pages": {},
"source": "B08 총괄자재수량집계표",
"qty": row.get("amount"),
}
)
return out
def build_inputs(
book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None
) -> dict[str, Any]:
"""표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시)."""
master = _master()
want = demand(master, keys)
saved = (book or {}).get("rows") or {}
def pick(key: str) -> dict[str, Any]:
if key in saved:
return saved[key]
return {**_convert(master, key), "fresh": True}
factor = (book or {}).get("driver_factor") if book else driver_factor(master)
return {
"labor": [pick(k) for k in want["labor"]],
"machines": [pick(k) for k in want["machines"]],
"materials": [pick(k) for k in want["materials"]] + b08_material_rows(b08_sheets),
"fuel_keys": want["fuel_keys"],
"driver_factor": factor,
"fx": (book or {}).get("fx") if book else fx_inputs(master),
"missing": want["missing"],
}