From 8076731f01b470f3ef241c053efe83c57648f8cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:43:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EA=B8=B0=EA=B3=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B3=B5=EB=8A=A5=EB=A0=A5=20=EA=B3=B5=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=A0=EA=B3=B5=20=EC=9E=A5=EB=B9=84=20=EB=AA=AB?= =?UTF-8?q?=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품셈 8-1-4 `Q = 3600÷Cm · q · K · f · E` 구현. 계수는 품셈 표에서 읽고 q(버킷 용량)는 기종 규격에서 옴. 수량 1단위당 기계 소요시간 = 1÷Q - 「유압식백호우 (무한궤도,0.7㎥)」를 카탈로그 굴착기(무한궤도) 0.7 로 이음. 이름·규격이 둘 다 맞을 때만 고름 - 배분율 표는 각 몫을 그 비율만큼만 셈 — 인력 0.23인 × 10 %, 장비 1/Q × 90 %. 종전엔 인력 원단위가 전량에 곱해져 9 배 부풀 자리였음 - 범위 표기(「0.55∼0.45」)는 확정값이 아니므로 가운데값을 취하지 않고 `FactorGap` 으로 드러냄. 계수 16 표 성립 / 못 세운 것은 목록으로 남김 - 산출근거 문구를 줄에 적어 사람이 검산 가능 (예 `Q = 3600 ÷ 18 × 0.7 × 0.9 × 0.77 × 0.60`) 결과: 배분율 미완 25 → 13 공종. 측구터파기 토사 5,288.4원/㎥ (인력 3,957.5 + 장비 1,330.9). 실물 인계자료 내역서 합계 478,680원 ㉣ 오탐 방지: 이 모듈은 `reject_efficiency_in_hourly_rate` 를 부르지 않음. E 는 품셈이 작업량 쪽에 넣으라는 값이라 여기서 막으면 정상 계산이 멈춤. 짝 시험 3건으로 못 박음 — Q 의 E 는 통과 / 사용료의 E 는 차단 / 같은 E 가 두 번 곱해지지 않았는지 금액으로 대조 검증: pytest 158 통과(신규 12). 공식은 손계산과 일치(58.212㎥/hr) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_MachineProductivity.py | 300 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 81 ++++- 2 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineProductivity.py diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py new file mode 100644 index 00000000..cd3fff20 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -0,0 +1,300 @@ +"""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 Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + +_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", "㎝") + +#: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다. +#: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다. +#: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다. +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.55∼0.45」·「0.2~0.8」. **확정값이 아니다.** +_RE_RANGE = re.compile(r"\d+(?:\.\d+)?\s*[∼~~-]\s*\d+(?:\.\d+)?") + + +class ProductivityError(ValueError): + """시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다.""" + + +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} × {self.volume_factor} × {self.efficiency}" + ) + + +@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. + """ + if factors.cycle_seconds <= 0: + raise ProductivityError( + f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다." + ) + cycles_per_hour = _SECONDS_PER_HOUR / factors.cycle_seconds + output = ( + cycles_per_hour + * factors.bucket_capacity_m3 + * factors.bucket_coefficient + * factors.volume_factor + * factors.efficiency + ) + if output <= 0: + raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") + return 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], +) -> CycleFactors | FactorGap | None: + """표 하나에서 계수를 뽑는다. + + 공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면 + `FactorGap`, 다 있으면 `CycleFactors`. + """ + 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 + + missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None] + capacity = bucket_from_machine_row + 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 diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 61219251..815a8f78 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -24,6 +24,12 @@ from functools import lru_cache from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog +from B09_Estimation.B09_Estimation_MachineProductivity import ( + CycleFactors, + FactorGap, + extract_cycle_factors, + machine_hours_per_unit, +) from B09_Estimation.B09_Estimation_MachineOperating import ( load_fuel_price, load_operating_records, @@ -68,6 +74,10 @@ class UnitPriceBuild: incomplete_machines: list[str] = field(default_factory=list) #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). partial_ratio: dict[str, Decimal] = field(default_factory=dict) + #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. + cycle_factors: dict[str, CycleFactors] = field(default_factory=dict) + #: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다. + factor_gaps: dict[str, FactorGap] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: @@ -228,19 +238,84 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: unit=unit, ) ) + # 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다. for row, ref in attachable: - build.book.add_detail(PriceDetail(title_code, ref, row.amount)) + share = _share_of(row) + build.book.add_detail(PriceDetail(title_code, ref, row.amount * share)) + + # 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4). + machine_share = _attach_machine_share(build, master, work_item_code, title_code) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ # 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다. covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build) - if covered is not None and covered < Decimal(100): - build.partial_ratio[work_item_code] = covered + if covered is not None: + covered += machine_share + if covered < Decimal(100): + build.partial_ratio[work_item_code] = covered return build +def _share_of(row) -> Decimal: + """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" + ratio = getattr(row, "group_ratio_pct", None) + return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100) + + +def _attach_machine_share( + build: UnitPriceBuild, + master: dict, + work_item_code: str, + title_code: str, +) -> Decimal: + """시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다. + + 계수가 다 안 서면 **아무것도 안 붙이고 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) + if not isinstance(factors, CycleFactors): + if isinstance(factors, FactorGap): + build.factor_gaps[work_item_code] = factors + continue + hourly_code = f"X-{factors.machine_code}" + if hourly_code not in build.book.titles: + # 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다. + build.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) + ) + build.book.add_detail( + PriceDetail( + title_code, + hourly_code, + machine_hours_per_unit(factors) * share, + note=factors.formula_text, + ) + ) + build.cycle_factors[work_item_code] = factors + return share * Decimal(100) + return _ZERO + + def _covered_ratio_pct( rows: list, attached_refs: set[str], build: UnitPriceBuild ) -> Decimal | None: