From b97eb042b6abfefc7e04f5bd827c3c08e8a5ded4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 11:47:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EB=B6=88=EB=8F=84=EC=A0=80=20?= =?UTF-8?q?=ED=91=9C(8-2-1)=20=EC=9D=BD=EC=96=B4=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=EB=B3=84=20=EB=8B=A8=EA=B0=80=20=EC=84=B8=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FP-10-11 불도저 운반이 굴착기 식(8-1-4)으로 읽혀 「K·Cm 없음」으로 잘못 진단되던 것을 8-2-1 식으로 읽게 함. 갈래 셋(토사·파쇄암·발파암) 각각 섬 (1,439.7 / 3,289.3 / 3,959.4 원/㎥). - 표가 기종 이름을 안 적어 삽날 용량 q゚ 3.2㎥ + 속도 55/70(2단)으로 불도저(무한궤도) 19톤을 되짚음. 하나로 안 좁혀지면 안 고름. - 공식표에만 나오는 기종을 시간당 사용료 층에 넣음 — 안 넣으면 공식이 다 서고도 붙을 사용료가 없어 빈 일위대가가 남았음. - 아무것도 안 붙은 제목은 걷어 냄 (화면에서 「조립 불가」로 터지던 자리). - 시험 6건 추가, 224건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_MachineProductivity.py | 296 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 31 +- 2 files changed, 325 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 79bc2e56..f6e907aa 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -341,6 +341,9 @@ class DozerFactors: haul_distance_m: Decimal # L forward_speed_m_min: Decimal # V1 reverse_speed_m_min: Decimal # V2 + #: 표가 기종 이름을 안 적어 `q゚`·`V1`·`V2` 로 되짚은 결과(`resolve_dozer`). + machine_code: str = "" + machine_name: str = "" @property def cycle_minutes(self) -> Decimal: @@ -379,3 +382,296 @@ def dozer_hourly_output(factors: DozerFactors) -> Decimal: def dozer_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: """그 규격의 전진·후진 속도(2단). 표에 없는 규격이면 `None` — 지어내지 않는다.""" return _DOZER_SPEEDS_2ND_GEAR.get(tonnage) + + +#: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다. +#: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다. +_DOZER_BLADE_M3 = { + ("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지 + ("무한궤도", Decimal("7")): Decimal("1.1"), + ("무한궤도", Decimal("10")): Decimal("1.5"), + ("무한궤도", Decimal("12")): Decimal("2.0"), + ("무한궤도", Decimal("13")): Decimal("1.5"), # 습지 + ("무한궤도", Decimal("19")): Decimal("3.2"), + ("무한궤도", Decimal("32")): Decimal("5.5"), + ("타이어", Decimal("15")): Decimal("3.1"), + ("타이어", Decimal("28")): Decimal("4.0"), + ("타이어", Decimal("33")): Decimal("5.7"), +} + +#: 타이어형 2단 속도 — 8-2-1 2.나. +_DOZER_TIRE_SPEEDS_2ND_GEAR = { + Decimal("15"): (Decimal(200), Decimal(125)), + Decimal("28"): (Decimal(200), Decimal(200)), + Decimal("33"): (Decimal(210), Decimal(250)), +} + +#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. +_DOZER_WET_TONS = (Decimal("4"), Decimal("13")) + +#: 표의 머리말. ⚠ **`e` 와 `E` 는 대소문자만 다르고 뜻이 전혀 다르다** — +#: `e` 는 운반거리계수, `E` 는 작업효율이다. 그래서 이 표는 **소문자로 내려 읽으면 안 된다** +#: (굴착기 쪽 `extract_cycle_factors` 는 내려 읽는다 — 그쪽엔 `e` 가 없어 안전하다). +_DOZER_SINGLE_KEYS = { + "L": "L", + "q0": "q0", + "q゚": "q0", + "q₀": "q0", + "e": "e", + "V1": "V1", + "V2": "V2", + "t": "t", +} +#: 갈래를 거느리는 머리말 — 「E | 토사 | 0.55」 아래에 「암석 | 0.25」가 딸려 온다. +_DOZER_GROUP_KEYS = ("E", "f") + +_RE_GEAR = re.compile(r"(\d+)\s*단") + + +def dozer_tire_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: + """타이어형 전진·후진 속도(2단). 표에 없으면 `None`.""" + return _DOZER_TIRE_SPEEDS_2ND_GEAR.get(tonnage) + + +def resolve_dozer( + blade_m3: Decimal, + forward: Decimal, + reverse: Decimal, + gear: int = 2, +) -> tuple[str, str] | None: + """삽날 용량과 속도로 **불도저 기종을 되짚는다**. + + 임도 품셈 표는 기종 이름을 안 적고 `q゚`·`V1`·`V2` 만 준다. 그 셋이 8-2-1 표에서 + 한 규격만 가리킬 때 그 기종으로 본다 — **둘 이상이면 안 고른다**(지어내지 않는다). + + q゚ 3.2㎥ + 55/70 m/분(2단) → 불도저(무한궤도) 19 톤 + + ⚠ **2단만 되짚는다.** 1·3·4단 속도표는 여기 안 들고 있어, 다른 단이면 `None` 이다. + """ + if gear != 2: + return None + candidates = [] + for (track, tonnage), blade in _DOZER_BLADE_M3.items(): + if blade != blade_m3: + continue + speeds = dozer_speeds(tonnage) if track == "무한궤도" else dozer_tire_speeds(tonnage) + if speeds == (forward, reverse): + candidates.append((track, tonnage)) + if len(candidates) != 1: + return None + + track, tonnage = candidates[0] + if track == "무한궤도" and tonnage in _DOZER_WET_TONS: + wanted = "습지 불도저" + else: + wanted = f"불도저({track})" + for code, machine in load_machine_catalog().machines.items(): + if machine.name == wanted and parse_measure(machine.specification) == tonnage: + return code, f"{machine.name} {machine.specification}" + return None + + +def dozer_machine_hours_per_unit(factors: DozerFactors) -> Decimal: + """수량 1단위당 불도저 소요시간(hr).""" + return Decimal(1) / dozer_hourly_output(factors) + + +def extract_dozer_factors( + work_item_code: str, + table: dict[str, Any], +) -> dict[str, DozerFactors] | FactorGap | None: + """불도저 표 하나에서 **갈래별** 계수를 뽑는다 (품셈 8-2-1). + + 돌려주는 것 — 불도저 표가 아니면 `None`, 계수가 모자라면 `FactorGap`, + 다 서면 `{갈래: DozerFactors}` (토사·파쇄암·발파암처럼 `f` 갈래마다 한 벌). + + ⚠ **딸린 줄은 한 칸 왼쪽으로 밀려 온다** — 머리 줄은 「f | 토사 | 1/1.30」이고 + 다음 줄은 「파쇄암 | 1/1.35」다. 자리를 그대로 읽으면 갈래가 통째로 빠진다. + """ + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return None + + single: dict[str, Decimal] = {} + groups: dict[str, dict[str, Decimal]] = {"E": {}, "f": {}} + gears: dict[str, int] = {} + current: str | None = None + + for cells in rows: + if not cells or not cells[0]: + continue + head = cells[0] + if head in _DOZER_SINGLE_KEYS: + current = None + key = _DOZER_SINGLE_KEYS[head] + value = _first_measure(cells[1:]) + if value is not None: + single[key] = value + found = _RE_GEAR.search(" ".join(cells[1:])) + if found: + gears[key] = int(found.group(1)) + elif head in _DOZER_GROUP_KEYS: + current = head + label = cells[1] if len(cells) > 1 else "" + value = _first_measure(cells[2:]) + if label and value is not None: + groups[head][_normalize_label(label)] = value + elif current is not None: + # 딸린 줄 — 「파쇄암 | 1/1.35」. 값이 없으면 갈래 줄이 아니다. + value = _first_measure(cells[1:]) + if value is not None: + groups[current][_normalize_label(head)] = value + + if not groups["f"] or "q0" not in single or "V1" not in single: + return None # 불도저 표가 아니다 + + missing = [key for key in ("L", "q0", "e", "V1", "V2", "t") if key not in single] + if not groups["E"]: + missing.append("E(작업효율)") + if missing: + return FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=tuple(missing), + note="불도저 표(8-2-1)인데 계수가 모자랍니다.", + ) + + gear = gears.get("V1", gears.get("V2", 2)) + machine = resolve_dozer(single["q0"], single["V1"], single["V2"], gear) + if machine is None: + return FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=("기계",), + note=( + f"삽날 {single['q0']}㎥ · {single['V1']}/{single['V2']}m/분({gear}단) " + "으로는 규격이 하나로 안 좁혀집니다." + ), + ) + + built: dict[str, DozerFactors] = {} + for label, volume_factor in groups["f"].items(): + efficiency = _dozer_efficiency(groups["E"], label) + if efficiency is None: + continue # 그 갈래의 작업효율이 없다 — 가운데값을 지어내지 않는다 + built[label] = DozerFactors( + work_item_code=work_item_code, + blade_capacity_m3=single["q0"], + distance_factor=single["e"], + volume_factor=volume_factor, + efficiency=efficiency, + haul_distance_m=single["L"], + forward_speed_m_min=single["V1"], + reverse_speed_m_min=single["V2"], + machine_code=machine[0], + machine_name=machine[1], + ) + return built or FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=("E(갈래별 작업효율)",), + note="`f` 갈래에 맞는 작업효율을 못 골랐습니다.", + ) + + +def _normalize_label(text: str) -> str: + return "".join(str(text).split()) + + +def _dozer_efficiency(efficiencies: dict[str, Decimal], label: str) -> Decimal | None: + """그 갈래의 작업효율 `E`. + + ⚠ 표가 `f` 는 「토사·파쇄암·발파암」으로 잘게 주고 `E` 는 「토사·암석」으로 굵게 준다. + 그래서 **암 갈래는 「암석」 줄을 쓴다** — 그 표가 암을 한 값으로 묶어 준 것이다. + """ + if label in efficiencies: + return efficiencies[label] + if "암" in label: + for key, value in efficiencies.items(): + if "암" in key: + return value + return efficiencies.get("토사") if len(efficiencies) == 1 else None + + +def dozer_variants(node: dict[str, Any]) -> list[str]: + """그 공종이 불도저 공식으로 세울 수 있는 갈래 이름들. 아니면 빈 목록.""" + labels: list[str] = [] + for table in node.get("tables", []): + found = extract_dozer_factors(str(node.get("work_item_code", "")), table) + if isinstance(found, dict): + labels.extend(label for label in found if label not in labels) + return labels + + +def formula_machine_codes(master: dict[str, Any]) -> set[str]: + """**공식표에서만 드러나는 기종 코드.** + + ⚠ 자원 축에는 안 나온다 — 표가 기계를 「줄」로 안 적고 계수로만 적기 때문이다. + 이 코드를 시간당 사용료 층에 안 넣으면, 공식은 다 서 놓고 **붙일 사용료가 없어** + 빈 일위대가가 남는다(2026-09-08 불도저 운반에서 실제로 그랬다). + """ + codes: set[str] = set() + for node in master.get("work_items", []): + code = str(node.get("work_item_code", "")) + for table in node.get("tables", []): + factors = extract_cycle_factors(code, table) + if isinstance(factors, CycleFactors): + codes.add(factors.machine_code) + found = extract_dozer_factors(code, table) + if isinstance(found, dict): + codes.update(f.machine_code for f in found.values() if f.machine_code) + return codes + + +def attach_dozer_share( + book: Any, + factor_gaps: dict[str, FactorGap], + master: dict[str, Any], + work_item_code: str, + title_code: str, + variant: str, +) -> Decimal: + """불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0. + + ⚠ 굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에 + 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 + 때만** 굴착기 쪽으로 간다. + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + 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 + wanted = _normalize_label(variant) + for table in node.get("tables", []): + found = extract_dozer_factors(work_item_code, table) + if isinstance(found, FactorGap): + factor_gaps[work_item_code] = found + continue + if not isinstance(found, dict): + continue + factors = found.get(wanted) + if factors is None: + 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.work_item_code, + missing=("시간당 사용료",), + note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", + ) + continue + book.add_detail( + PriceDetail( + title_code, + hourly_code, + dozer_machine_hours_per_unit(factors), + note=factors.formula_text, + ) + ) + return Decimal(100) + return _ZERO diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 043e61c4..985ce1e9 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -27,7 +27,11 @@ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, FactorGap, + attach_dozer_share, + dozer_variants, + formula_machine_codes, extract_cycle_factors, + extract_dozer_factors, machine_hours_per_unit, ) from B09_Estimation.B09_Estimation_MachineOperating import ( @@ -325,6 +329,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: _add_labor_titles(build.book, wages) machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} + # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. + machine_codes |= formula_machine_codes(master) build.incomplete_machines = _add_machine_layers(build.book, machine_codes) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 @@ -341,6 +347,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: code = node.get("work_item_code") if not code or (code, "") in by_item: continue + # 불도저 운반(8-2-1)은 **갈래(토사·파쇄암·발파암)마다 값이 다르다** — 한 벌로 + # 뭉치면 어느 것도 안 맞는다. 갈래가 있으면 갈래를 세우고 여기서 끝낸다. + labels = dozer_variants(node) + if labels: + for label in labels: + by_item.setdefault((code, label), []) + continue for table in node.get("tables", []): if isinstance(extract_cycle_factors(code, table), CycleFactors): by_item[(code, "")] = [] @@ -410,9 +423,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: ) # 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4). - machine_share = ( - _ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code) + # ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번 + # 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다. + machine_share = attach_dozer_share( + build.book, build.factor_gaps, master, work_item_code, title_code, variant ) + if not machine_share and not variant: + machine_share = _attach_machine_share(build, master, work_item_code, title_code) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 @@ -422,6 +439,15 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: if work_item_code in axis.partial_items: build.partial_ratio.setdefault(work_item_code, _ZERO) + # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 + # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. + if not attachable 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) + build.skipped.append(work_item_code) + continue + covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build) if covered is not None: covered += machine_share @@ -440,6 +466,7 @@ def _has_full_formula(master: dict, work_item_code: str) -> bool: return False return any( isinstance(extract_cycle_factors(work_item_code, table), CycleFactors) + or isinstance(extract_dozer_factors(work_item_code, table), dict) for table in node.get("tables", []) )