Files
Aislo/B09_Estimation/B09_Estimation_MachineProductivity.py
T
eomsangdonandClaude Opus 5 c3618fe872 feat(b09): 2차 편집 ② 일위대가·단산 구성행 수정 · ③ Q 식 편집 — 프로젝트 단위 · 서버가 다시 계산 · ↺
- 구성행(sheet_rows): 수량 고치기 · 줄 빼기(수량 0, 흐리게) · 줄 더하기(단가표 고르개) — 비율 줄·돌림 참조는 받지 않음
- Q 식(price_basis_q): 식은 명세 13장 식 언어 — B08 구조물도 풀이기(evaluate_sheets, Node 한 벌)를 그대로 부름 ·
  소수 2자리 사사오입 확정 → 새 수량 = 수량 × 옛 Q ÷ 새 Q · 비고에 「Q(사용자) = 식 = 값」
  · PriceDetail.output(시공능력 Q)을 Q 로 선 장비 줄 넷 자리(굴착기·도자·직접 작업량·암 잎)에서 실음 · 저장 모양에도 실음
- 따로 짰던 파이썬 식 셈(B09_Estimation_Expression)은 걷어냄 — 식 풀이 두 벌 금지(브레인 판정)
- 새 문: GET /estimation/edits/sheet/{code}(본표 + 줄마다 고친 값 표시) · GET /estimation/edits/search(줄 더하기 고르개)
- 화면: 본표 [편집] — 수량 칸 · ✕ · Q 식 칸 · 줄 더하기 · 고친 줄 「사용자」 + ↺
- 검증: 시험 1664 통과 · 골든셋 초록 · ORCA — 제 3 호표 보통인부 0.023→0.046 이면 5,652→9,610 · 내역 본체 122,848,989→122,857,198,
  ↺ 뒤 5,652 · 산근 2호표 Q 58.21→116.42 이면 1,695→847, ↺ 뒤 1,695 · 틀린 식 422 「모르는 이름: abc」 · 고친 값 {} 로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:37:18 +09:00

416 lines
17 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 원가계산 — 기계 시공능력 `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