refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)

B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음).
화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음.
B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry
로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠.
B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져
부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음.
B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함.
B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-22 12:27:45 +09:00
co-authored by Claude Opus 5
parent 185f800d23
commit 8472fc9f40
231 changed files with 1617 additions and 1216 deletions
@@ -0,0 +1,415 @@
"""B09 원가계산 — 기계 시공능력 `Q` (품셈 8-1-4, PLAN 9-5 ③).
**왜 있는가** — 토공 주요 공종(흙깎기·측구터파기·성토)의 품셈 표는 **소요량표가 아니다.**
「인력 10 % + 장비 90 %」로 갈리고, 장비 몫은 자원 수량이 아니라 **공식의 계수**
(`K`·`f`·`E`·`Cm`)로 적혀 있다. 그래서 표를 베끼면 **인력 몫만 서고 장비 몫이 통째로
빠진다** — 2026-09-08 실측으로 측구터파기가 인력 10 % 몫(39,575.6원/㎥)만으로 서 있었다.
공식 (지식DB `05_원가정보/기계경비_산정.md` §4 — 품셈 8-1-4)
Q = n · q · K · f · E n = 3600 ÷ Cm (시간당 싸이클 수)
q 1싸이클 표준작업량 (버킷 용량 ㎥ — **기종 규격에서 온다**)
K 버킷계수 (표의 `K`·`k`)
f 체적환산계수 (표의 `f`)
E 작업효율 = 현장능력계수 × 실작업시간율 (표의 `E`)
Cm 1싸이클 소요시간(초) (표의 `㎝(sec)` — 원문 표기가 「㎝」이지 센티미터가 아니다)
수량 1단위당 기계 소요시간(hr) = 1 ÷ Q → × 시간당 사용료 = 그 공종의 기계경비
⚠ **㉣ 와 어긋나지 않는다** (PLAN 9-6). ㉣ 는 「작업효율 `E` 를 **시간당 사용료** 쪽에
넣지 말라」이고, 품셈이 `E` 를 넣으라는 자리가 **바로 여기(작업량 `Q`)** 다. 그러므로
`reject_efficiency_in_hourly_rate()` 는 이 모듈에서 **부르지 않는다** — 부르면 정상
계산이 멈추는 오탐이 된다. 진짜 위반은 **같은 `E` 를 `Q` 와 사용료에 둘 다 넣는 것**이라,
그쪽은 사용료 계산 자리(`B09_Estimation_MachineCost`)의 가드가 그대로 지킨다.
⚠ **모르는 값을 지어내지 않는다.** 표가 범위(「0.55∼0.45」)만 주고 확정값을 안 주면
계수를 못 세운 것으로 보고 `FactorGap` 으로 드러낸다 — 가운데값을 임의로 취하지 않는다
(CLAUDE.md 3장).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_ResourceAxis import RANGE_DASH_CLASS
_ZERO = Decimal(0)
_SECONDS_PER_HOUR = Decimal(3600)
#: 표의 행 머리 — 대문자·소문자가 섞여 온다(`K` 와 `k` 가 같은 표 안에 있다).
_KEY_BUCKET = ("k",)
_KEY_VOLUME = ("f",)
_KEY_EFFICIENCY = ("e",)
_KEY_CYCLE = ("㎝(sec)", "cm(sec)", "cm", "")
#: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다.
#: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다.
#: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다.
#: ⚠ **별칭표(`common_util_aliases`) 대상이 아니다** — 품셈 전 장에서 같은 기종을 달리 적는
#: **카탈로그 이름 정규화**라 범위(scope)가 없다(2026-09-13 브레인 판정). 규격·형식은 안 바꾸고
#: 이름 머리만 맞추므로, 형식이 둘인 기종은 여전히 「규격 미정」으로 남는다.
MACHINE_NAME_ALIASES = {
"유압식백호우": "굴착기",
"백호우": "굴착기",
"백호": "굴착기",
"유압식굴삭기": "굴착기",
"굴삭기": "굴착기",
}
#: 무한궤도·타이어 갈래. 카탈로그 이름이 「굴착기(무한궤도)」처럼 갈래를 품고 있다.
_TRACK_WORDS = ("무한궤도", "타이어", "습지")
_RE_PARENS = re.compile(r"[(]([^)]*)[)]")
_RE_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
_RE_FRACTION = re.compile(r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)$")
#: 범위 표기 — 「0.550.45」·「0.20.8」. **확정값이 아니다.**
_RE_RANGE = re.compile(rf"\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?")
class ProductivityError(ValueError):
"""시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다."""
_HUNDREDTH = Decimal("0.01")
def fix2(value: Decimal) -> Decimal:
"""**소수 2자리로 먼저 확정**(사사오입) — 작업량 Q 와 토량환산계수 f(명세 7장 · STmate 18번 §2.2).
⚠ 원값으로 나누면 어긋남 — `55,700 ÷ 15.708 = 3,545.96` ≠ `55,700 ÷ 15.71 = 3,545.5`.
f 도 `1/1.175 = 0.85` 로 먼저 자른 뒤 곱함(봉화 2024 단가산출근거 제2호표).
"""
return Decimal(value).quantize(_HUNDREDTH, rounding=ROUND_HALF_UP)
def parse_measure(cell: str) -> Decimal | None:
"""계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.**
읽는 모양 — 「0.77」 · 「1/1.30」(분수) · 「20(135°)」(괄호는 조건 설명이라 버린다).
안 읽는 모양 — 「0.55∼0.45」(범위) · 「육상과동일」(참조) · 빈 칸.
"""
text = str(cell).strip()
if not text:
return None
if _RE_RANGE.search(text):
return None # 범위는 확정값이 아니다 — 가운데를 임의로 취하지 않는다
fraction = _RE_FRACTION.match(text)
if fraction:
divisor = Decimal(fraction.group(2))
return None if divisor == 0 else Decimal(fraction.group(1)) / divisor
# 괄호 안은 조건 설명(각도 등)이므로 떼고 본다 — 「20(135°)」 → 20
outside = _RE_PARENS.sub("", text).strip()
found = _RE_NUMBER.search(outside)
return Decimal(found.group(0)) if found else None
@dataclass(frozen=True)
class CycleFactors:
"""한 표에서 뽑아낸 시공능력 계수 한 벌."""
work_item_code: str
pum_table_id: str
machine_code: str
machine_name: str
bucket_capacity_m3: Decimal # q
bucket_coefficient: Decimal # K
volume_factor: Decimal # f
efficiency: Decimal # E — **작업량 쪽에만 들어간다** (㉣)
cycle_seconds: Decimal # Cm
#: 인력 몫 배분율(%) — 「인력(10%)」이면 `10`. 없으면 `None`.
labor_ratio_pct: Decimal | None = None
machine_ratio_pct: Decimal | None = None
@property
def formula_text(self) -> str:
return (
f"Q = 3600 ÷ {self.cycle_seconds} × {self.bucket_capacity_m3} × "
f"{self.bucket_coefficient} × {fix2(self.volume_factor)} × {self.efficiency}"
+ (
f" = {hourly_output(self)} ㎥/hr (f·Q 소수 2자리 확정)"
if self.cycle_seconds > 0
else ""
)
)
@dataclass(frozen=True)
class FactorGap:
"""계수를 못 세운 표. **빈칸으로 두지 않고 무엇이 없는지 적는다.**"""
work_item_code: str
pum_table_id: str
missing: tuple[str, ...]
note: str = ""
def hourly_output(factors: CycleFactors) -> Decimal:
"""시간당 작업량 `Q` (㎥/hr).
`Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. f 와 Q 는 **소수 2자리로 확정**(`fix2`).
"""
if factors.cycle_seconds <= 0:
raise ProductivityError(
f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다."
)
output = (
_SECONDS_PER_HOUR
* factors.bucket_capacity_m3
* factors.bucket_coefficient
* fix2(factors.volume_factor)
* factors.efficiency
/ factors.cycle_seconds
)
if output <= 0:
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
return fix2(output)
def machine_hours_per_unit(factors: CycleFactors) -> Decimal:
"""수량 1단위당 기계 소요시간(hr). 여기에 시간당 사용료를 곱하면 기계경비가 된다."""
return Decimal(1) / hourly_output(factors)
def resolve_machine(cell: str) -> tuple[str, str] | None:
"""표의 기계 이름 셀을 기종 카탈로그 한 줄로 푼다.
「유압식백호우 (무한궤도,0.7㎥)」 → `0201-0070` 굴착기(무한궤도) 0.7.
**이름과 규격이 둘 다 맞을 때만** 고른다 — 규격이 안 맞으면 안 고른다.
"""
text = str(cell).strip()
if not text:
return None
inside = " ".join(_RE_PARENS.findall(text))
head = _RE_PARENS.sub("", text).strip()
name = MACHINE_NAME_ALIASES.get(head.replace(" ", ""), head)
capacity = parse_measure(_capacity_token(inside))
track = next((word for word in _TRACK_WORDS if word in inside), "")
if capacity is None:
return None
catalog = load_machine_catalog()
for code, machine in catalog.machines.items():
if name not in machine.name:
continue
if track and track not in machine.name:
continue
spec = parse_measure(machine.specification)
if spec is not None and spec == capacity:
return code, f"{machine.name} {machine.specification}"
return None
def _capacity_token(inside: str) -> str:
"""괄호 안에서 용량 토막만 뽑는다 — 「무한궤도,0.7㎥」 → 「0.7㎥」."""
for token in re.split(r"[,]", inside):
if any(unit in token for unit in ("", "m3", "M3", "루베")):
return token
return ""
def extract_cycle_factors(
work_item_code: str,
table: dict[str, Any],
choices: dict[tuple[str, str], Decimal] | None = None,
machines: dict[str, str] | None = None,
) -> CycleFactors | FactorGap | None:
"""표 하나에서 계수를 뽑는다.
공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면
`FactorGap`, 다 있으면 `CycleFactors`.
⚠ `choices` — 품셈이 **범위로 준 계수**(9-3-2 의 `E = 0.550.45`)에 사용자가 고른 값을
끼워 넣는다. **범위가 아닌 칸은 절대 안 덮는다** — 품셈이 값을 정한 자리를 사용자
설정이 밀어내면 그것이 곧 임의 수치다. 고를 수 있는 것도 원문 두 끝과 그 평균뿐이다
(`B09_Estimation_FactorChoices`).
"""
rows = table.get("raw_row") or []
values: dict[str, Decimal] = {}
machine: tuple[str, str] | None = None
bucket_from_machine_row: Decimal | None = None
ratios: dict[str, Decimal] = {}
saw_key = False
for row in rows:
cells = [str(c).strip() for c in row]
if not cells:
continue
head = cells[0].lower().replace(" ", "")
rest = cells[1:]
# 「장비(90%) | 유압식백호우 (무한궤도,0.7㎥) | k | 0.9」 모양
for index, cell in enumerate(cells):
found = resolve_machine(cell)
if found is not None and machine is None:
machine = found
bucket_from_machine_row = parse_measure(
_capacity_token(" ".join(_RE_PARENS.findall(cell)))
)
# 같은 줄 뒤쪽에 「k | 0.9」가 붙어 오는 표가 있다
tail = cells[index + 1 :]
for position, token in enumerate(tail):
if token.lower() in _KEY_BUCKET and position + 1 < len(tail):
parsed = parse_measure(tail[position + 1])
if parsed is not None:
values["K"] = parsed
break
ratio = _ratio_of(cells[0])
if ratio is not None:
label = "labor" if "인력" in cells[0] else "machine" if "장비" in cells[0] else ""
if label:
ratios[label] = ratio
if head in _KEY_BUCKET:
saw_key = True
values.setdefault("K", _first_measure(rest))
elif head in _KEY_VOLUME:
saw_key = True
values.setdefault("f", _first_measure(rest))
elif head in _KEY_EFFICIENCY:
saw_key = True
values.setdefault("E", _first_measure(rest))
elif head in _KEY_CYCLE:
saw_key = True
values.setdefault("Cm", _first_measure(rest))
if not saw_key and machine is None:
return None
# 범위 칸이라 못 읽은 자리에만 **고른 값**을 끼운다 — 읽힌 칸은 손대지 않는다.
for key in ("K", "f", "E", "Cm"):
if values.get(key) is None and choices:
picked = choices.get((work_item_code, key))
if picked is not None:
values[key] = picked
saw_key = True
missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None]
capacity = bucket_from_machine_row
# 고른 기종이 있으면 그것을 쓴다. 표가 장비를 말한 자리(층따기)에서는 **바꾸는 것**이고,
# 표에 장비가 없는 자리(흙깎기)에서는 [주] 에만 있는 값을 **채우는 것**이다.
# 어느 쪽이든 화면이 근거와 함께 보이고 사용자가 되돌릴 수 있다(확정 ① 딸림 지시).
picked_code = (machines or {}).get(work_item_code)
if picked_code:
chosen = load_machine_catalog().machines.get(picked_code)
if chosen is not None:
machine = (picked_code, chosen.name)
spec = parse_measure(chosen.specification)
if spec is not None:
capacity = spec
if machine is None:
missing.append("기계")
if capacity is None:
missing.append("q(버킷 용량)")
if missing:
return FactorGap(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
missing=tuple(missing),
note="표가 확정값 대신 범위·참조만 주었거나 기종을 못 골랐습니다.",
)
return CycleFactors(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
machine_code=machine[0],
machine_name=machine[1],
bucket_capacity_m3=capacity,
bucket_coefficient=values["K"],
volume_factor=values["f"],
efficiency=values["E"],
cycle_seconds=values["Cm"],
labor_ratio_pct=ratios.get("labor"),
machine_ratio_pct=ratios.get("machine"),
)
def _first_measure(cells: list[str]) -> Decimal | None:
"""그 행에서 **처음 읽히는 확정값**. 뒤 칸은 유도식·참조라 앞 칸이 우선이다."""
for cell in cells:
parsed = parse_measure(cell)
if parsed is not None:
return parsed
return None
_RE_RATIO = re.compile(r"[(]\s*(\d+(?:\.\d+)?)\s*%\s*[)]")
def _ratio_of(cell: str) -> Decimal | None:
found = _RE_RATIO.search(str(cell))
return Decimal(found.group(1)) if found else None
def attach_machine_share(
book: Any,
factor_gaps: dict[str, FactorGap],
cycle_factors: dict[str, CycleFactors],
master: dict[str, Any],
work_item_code: str,
title_code: str,
choices: dict[tuple[str, str], Decimal] | None = None,
machines: dict[str, str] | None = None,
sources: dict[str, str] | None = None,
) -> Decimal:
"""시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
⚠ 불도저 쪽(`attach_dozer_share`)과 **자리를 나눠 쓴다** — 두 식이 한 일위대가에
붙으면 장비를 두 번 세는 것이 된다.
계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은
`partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다).
"""
node = next(
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
None,
)
if node is None:
return _ZERO
for table in node.get("tables", []):
factors = extract_cycle_factors(work_item_code, table, choices, machines)
if not isinstance(factors, CycleFactors):
if isinstance(factors, FactorGap):
factor_gaps[work_item_code] = factors
continue
hourly_code = f"X-{factors.machine_code}"
if hourly_code not in book.titles:
# 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다.
factor_gaps[work_item_code] = FactorGap(
work_item_code=work_item_code,
pum_table_id=factors.pum_table_id,
missing=("시간당 사용료",),
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
share = (
Decimal(1)
if factors.machine_ratio_pct is None
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
)
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
book.add_output_detail(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
# 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.**
note=factors.formula_text
+ (
f" · {(sources or {}).get(work_item_code, '')}"
if (sources or {}).get(work_item_code)
else ""
),
output=hourly_output(factors),
)
cycle_factors[work_item_code] = factors
return share * Decimal(100)
return _ZERO