diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py index a571234b..3c65e5a2 100644 --- a/B09_Estimation/B09_Estimation_MachineCost.py +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -82,6 +82,32 @@ OPERATOR_ALLOWANCE_NOTICE = ( ) +#: ⚠ **원천이 뭉개 놓은 줄을 원문으로 되살린다** (2026-09-09). +#: +#: `mach_base_2026.json` 의 대형 브레이커 여섯 줄은 **이름 칸에 표 전체가 뭉쳐** 들어가 +#: 규격이 비고 손료계수가 없다. 그래서 「대형브레이커」로 찾아지지도, 시간당 사용료가 +#: 서지도 않았다 — 구조물터파기(암절취)가 그 때문에 통째로 막혀 있었다. +#: +#: 값은 **건설공사 표준품셈 제8장 (0230) 대형 브레이커** 표에서 읽었고, **두 번 검증**했다. +#: ① 계수 합이 맞는다 — 상각 3,000 + 정비 2,833 + 관리 768 = **6,601** (표의 「계」와 같다) +#: ② 같은 방식으로 읽은 굴착기(0201) 표의 「계 2,085」가 카탈로그의 손료계수 +#: **0.0002085 와 정확히 일치**한다 — 열 배치를 잘못 읽지 않았다는 증거다. +#: +#: ⚠ **원천이 이 줄을 제대로 싣게 되면 이 표는 지운다.** 두 곳에 같은 값을 두면 +#: 나중에 한쪽만 고쳐진다. 취득가는 원천 값을 그대로 쓴다 — 여기서는 **이름·규격·손료계수**만 채운다. +MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = { + "0230-0002": ("대형 브레이커", "0.2"), + "0230-0004": ("대형 브레이커", "0.4"), + "0230-0006": ("대형 브레이커", "0.6"), + "0230-0007": ("대형 브레이커", "0.7"), + "0230-0008": ("대형 브레이커", "0.8"), + "0230-0010": ("대형 브레이커", "1.0"), +} + +#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601). +MASHED_LOSS_COEFFICIENT = Decimal("0.0006601") + + class MachineCostError(LookupError): """기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다.""" @@ -149,6 +175,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal for row in variables.get("mach_price", {}).get("records", []): code = row["machine_code"] coefficient = coefficients.get(code, {}) + fixed = MASHED_MACHINE_FIXES.get(code) + if fixed: + # 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다. + row = {**row, "machine_name": fixed[0], "specification": fixed[1]} + coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT} catalog.machines[code] = MachineSpec( machine_code=code, name=row["machine_name"], diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py index 95250c35..6bfce469 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -193,3 +193,110 @@ def reference_factor_values( raw_texts[code] = own_ref[1] return values, provenance, failures, raw_texts + + +# --------------------------------------------------------------------------- +# 작업량을 **직접 준** 기계 줄 (2026-09-09) +# --------------------------------------------------------------------------- +# +# 품셈은 기계 몫을 늘 공식으로만 주지 않는다. **시간당 작업량을 바로 적는** 줄이 있다. +# +# ['장비 (90%)', '깨기', '대형브레이커(㎥/hr)', '3.5', 'Q=(3.2+3.8)/2 (연암평균치 적용)'] +# +# 이 줄을 못 읽으면 암·발파암 갈래의 **깨기 몫이 통째로 빠진다** — 들어내기(백호우)만 +# 붙어 「일부만 선 단가」로 남는다. +# +# ⚠ **단위가 붙어 있을 때만 읽는다.** 「(㎥/hr)」·「(m/hr)」처럼 시간당 작업량임을 +# 표가 스스로 밝힌 줄만 본다. 숫자만 있는 칸을 작업량으로 넘겨짚지 않는다. + +_CAPACITY_UNIT = re.compile(r"[((]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시간)\s*[))]") + + +def _paired_machine_spec(node: dict[str, Any]) -> str: + """그 표에 함께 나오는 기종의 규격(「유압식백호우 (무한궤도,0.7㎥)」 → 0.7).""" + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog() + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + for cell in row: + found = resolve_machine(_clean(cell)) + if found is not None: + machine = catalog.machines.get(found[0]) + if machine is not None and machine.specification: + return str(machine.specification) + return "" + + +def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None: + """이름만으로 기종을 고른다 — 규격이 여럿이면 **짝의 규격**을 따른다. + + ⚠ 「대형브레이커(㎥/hr)」는 괄호가 **규격이 아니라 단위**라 보통 길로는 안 풀린다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + wanted = re.sub(r"\s", "", text) + if not wanted: + return None + catalog = load_machine_catalog() + hits = [ + (code, machine) + for code, machine in catalog.machines.items() + if wanted and wanted in re.sub(r"\s", "", machine.name) + ] + if not hits: + return None + if preferred_spec: + narrowed = [item for item in hits if str(item[1].specification) == str(preferred_spec)] + if len(narrowed) == 1: + return narrowed[0][0], narrowed[0][1].name + return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None + + +def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]: + """그 공종에서 **시간당 작업량을 직접 준 기계 줄**들. + + 돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구. + """ + from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure + + found: list[dict[str, Any]] = [] + ratio: Decimal | None = None + # 같은 표에 짝이 되는 기종이 있으면 **그 규격**을 따른다 — 「대형브레이커」는 규격을 + # 안 적고, 실무도 「대형브레이커 + B/H 0.7」처럼 붙는 굴착기 규격으로 잡는다. + paired_spec = _paired_machine_spec(node) + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + cells = [_clean(cell) for cell in row] + if not cells: + continue + seen_ratio = re.search(r"[((]\s*(\d+(?:\.\d+)?)\s*%\s*[))]", cells[0]) + if seen_ratio: + ratio = Decimal(seen_ratio.group(1)) + for index, cell in enumerate(cells): + if not _CAPACITY_UNIT.search(cell): + continue + machine = _machine_by_name(_CAPACITY_UNIT.sub("", cell).strip(), paired_spec) + if machine is None: + continue + capacity = next( + (parse_measure(token) for token in cells[index + 1 :] if parse_measure(token)), + None, + ) + if capacity is None or capacity <= 0: + continue + found.append( + { + "cell": cell, + "machine_code": machine[0], + "machine_name": machine[1], + "capacity_per_hour": capacity, + "ratio_pct": ratio, + "table_id": str(table.get("pum_table_id", "")), + # 그 줄의 칸들 — 「못 붙은 줄」 목록에서 이 줄을 걷어내는 데 쓴다. + "row_cells": [c for c in cells if c], + } + ) + return found diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index f8c64e04..35b78bd5 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -132,6 +132,11 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: ) +#: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤). +#: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다. +_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게") + + def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. @@ -159,7 +164,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: for code in sorted(machine_codes): machine = catalog.machines.get(code) record = operating.get(code) - if machine is None or machine.loss_coefficient_per_hour is None or record is None: + # ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이 + # 본체(굴착기·불도저)에 든다. 품셈 제8장 [주]⑤ 가 그 자리를 밝힌다: + # 「불도저 및 굴착기에 **리퍼, 브레이커, 부착용집게를 조합하여 사용**할 때는 + # …잡재료비율을 16%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및 + # 치즐 소모율을 추가**하는 것이다.」 + # ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면 + # 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다). + # ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다. + attachment = machine is not None and any( + word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS + ) + if machine is None or machine.loss_coefficient_per_hour is None: + incomplete.append(code) + continue + if record is None and not attachment: incomplete.append(code) continue @@ -192,6 +211,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: ) book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료")) + if record is None: + # 부착용 장비 — 여기서 끝난다. 연료·조종원은 본체 줄에 이미 들어 있다. + book.add_detail( + PriceDetail( + hourly_code, + base_code, + Decimal(0), + note=( + "부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다" + " (품셈 제8장 [주]⑤)." + ), + ) + ) + continue + liters = record.fuel_liters_per_hour if liters is not None: if record.misc_material_percent is not None: @@ -353,6 +387,7 @@ def build_unit_prices( ) from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( + direct_capacity_rows, reference_factor_values, ) @@ -374,9 +409,22 @@ def build_unit_prices( for failed_code, why in borrow_fail.items(): build.factor_sources.setdefault(failed_code, f"⚠ {why}") build.component_gaps = dict(axis.partial_items) + # 「작업량을 직접 준」 기계(깨기 대형브레이커)도 사용료 층을 세운다 — 안 세우면 + # 그 줄이 붙을 데가 없어 암·발파암 갈래의 깨기 몫이 통째로 빠진다. + capacity_rows = { + str(node.get("work_item_code", "")): direct_capacity_rows(node) + for node in master.get("work_items", []) + } + capacity_rows = {code: rows for code, rows in capacity_rows.items() if rows} + # ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이 # 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다. resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note} + # 작업량을 직접 준 줄도 이제 붙었다 — 그 줄의 칸들은 「못 붙은 줄」이 아니다. + capacity_cells: dict[str, set[str]] = {} + for capacity_code, capacity_list in capacity_rows.items(): + for entry in capacity_list: + capacity_cells.setdefault(capacity_code, set()).update(entry.get("row_cells") or []) for unmatched_row in axis.unmatched: # ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면 # 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측). @@ -385,16 +433,35 @@ def build_unit_prices( reference_text = resolved_rows.get(unmatched_row.work_item_code) if reference_text and reference_text in label: continue # 그 줄은 참조를 따라가 값을 얻었다 + if label in capacity_cells.get(unmatched_row.work_item_code, set()): + continue # 그 줄은 표가 작업량을 직접 줘 붙었다 if label and label not in labels: labels.append(label) # ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어 # **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다 # (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다. + def _names_a_machine(label: str) -> bool: + """그 줄이 **기계를 가리키나** — 기계가 빠지면 막고, 자재가 빠지면 드러내기만 한다. + + ⚠ 두 가지는 무게가 다르다. 기계 몫은 단가의 대부분이라 빠지면 금액이 통째로 + 틀리고, 자재 소모품(치즐 0.006본/hr)은 카탈로그가 서면 채워지는 알려진 미결이다 + (자원 축이 이미 그 규칙으로 가른다). + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + flat = re.sub(r"\s", "", label) + return any( + re.sub(r"\s", "", machine.name) in flat + for machine in load_machine_catalog().machines.values() + if len(re.sub(r"\s", "", machine.name)) >= 3 + ) + solved_codes = { code for code in borrow_note - if code in axis.partial_items and not build.unattached.get(code) + if code in axis.partial_items + and not any(_names_a_machine(label) for label in build.unattached.get(code, [])) } for code in solved_codes: build.component_gaps.pop(code, None) @@ -414,6 +481,7 @@ def build_unit_prices( machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. machine_codes |= formula_machine_codes(master) + machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows} build.incomplete_machines = _add_machine_layers(build.book, machine_codes) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 @@ -533,6 +601,31 @@ def build_unit_prices( build.factor_sources, ) + # 작업량을 직접 준 기계 줄(깨기) — 공식 몫과 **자리를 나눠 쓴다**. 같은 묶음(장비 + # 90%) 안에서 깨기와 들어내기가 차례로 붙는다. + attached_capacity = False + for capacity in capacity_rows.get(work_item_code, []): + hourly_code = f"X-{capacity['machine_code']}" + if hourly_code not in build.book.titles: + continue + group_share = ( + Decimal(1) + if capacity["ratio_pct"] is None + else Decimal(str(capacity["ratio_pct"])) / Decimal(100) + ) + build.book.add_detail( + PriceDetail( + title_code, + hourly_code, + (Decimal(1) / capacity["capacity_per_hour"]) * group_share, + note=( + f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" + f" (품셈 원문 표기 그대로)" + ), + ) + ) + attached_capacity = True + # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ @@ -543,7 +636,7 @@ def build_unit_prices( # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. - if not attachable and not build.book.details.get(title_code): + if not attachable and not attached_capacity and not build.book.details.get(title_code): build.book.titles.pop(title_code, None) if variant in build.variants.get(work_item_code, []): build.variants[work_item_code].remove(variant)