diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 41354814..fc884869 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -171,7 +171,15 @@ def _leaf_row( result.excluded.append(row) return row - if item.blocked_reason: + # ⚠⚠ **차단인지 아닌지는 `blocked_kind` 가 정한다 — 문구가 아니다.** + # 2026-09-09 실측: 배수관 다섯 줄이 `blocked_kind=None · in_bill=True` 인데도 + # **사유가 있다는 것만으로 막혀** 금액이 안 서고 있었다. 그 사유는 차단이 아니라 + # **주의 문구**였다 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」. + # ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말로** 단다. + if item.blocked_reason and not item.blocked_kind: + row.note = " / ".join(part for part in (row.note, f"ⓘ {item.blocked_reason}") if part) + + if item.blocked_reason and item.blocked_kind: # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 7af12c3c..c430fba5 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -391,6 +391,13 @@ def match_table( if match_choose_one_machine_table(node, table, catalog, result): return + # ⚠ **규격이 열로 선 표**(관부설 12-11)도 여기서 가른다 — 값이 「0.62/2.5」 같은 + # 나눗셈이라 보통 길로는 한 줄도 안 선다. + from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import match_spec_column_table + + if match_spec_column_table(node, table, catalog, result): + return + form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py index 11314660..958b89f1 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py @@ -198,3 +198,113 @@ def match_choose_one_machine_table( ) ) return True + + +# --------------------------------------------------------------------------- +# 규격이 **열**로 선 표 (2026-09-09) — 관부설 12-11-1·2·3 +# --------------------------------------------------------------------------- +# +# | 구 분 | 규격 | 단위 | 관 경 별 적 용 | +# | ∅800mm | ∅1000mm | ∅1200mm | ← 첫 줄이 **규격 이름만** 늘어선 줄 +# | 크레인 | 10ton | hr | 0.62/2.5 | 0.76/2.5 | 0.90/2.5 | +# | 배관공 | | 인 | 0.26/2.5 | 0.35/2.5 | 0.46/2.5 | +# +# ⚠ 값이 **나눗셈 식**이다 — 「0.62/2.5」는 「관 2.5m 한 개당 0.62시간」이라는 뜻이라 +# **m 당으로 환산된 값**이다. 그대로 두면 자원 줄이 하나도 안 서서 배수관이 통째로 +# 금액을 못 냈다(2026-09-09 실측: 아홉 줄 중 다섯이 길이까지 있는데 단가가 없었다). +# +# ⚠ **값이 빈 칸은 건너뛴다** — 기초콘크리트·거푸집·모래부설은 「별산」 자리다(계획서 9-9). + +_SPEC_HEAD = re.compile(r"^[∅Ø⌀]\s*\d") +_FRACTION = re.compile(r"^\s*(\d+(?:\.\d+)?)((?:\s*/\s*\d+(?:\.\d+)?)+)\s*$") + + +def _fraction_value(cell: str) -> Decimal | None: + """「0.62/2.5」·「0.016/2.5/2」를 수로. 나눗셈이 아니면 `None`.""" + matched = _FRACTION.match(cell) + if not matched: + return None + value = Decimal(matched.group(1)) + for part in matched.group(2).split("/"): + part = part.strip() + if not part: + continue + divisor = Decimal(part) + if divisor == 0: + return None + value = value / divisor + return value + + +def _cell_amount(cell: str) -> Decimal | None: + """값 칸 하나 — 숫자 그대로이거나 나눗셈 식.""" + text = _clean(cell) + if not text: + return None + if _NUMBER.match(text): + return parse_amount(text) + return _fraction_value(text) + + +def match_spec_column_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """규격이 **열**로 선 표를 읽는다. 그런 표가 아니면 `False`. + + ⚠ 첫 줄이 **규격 이름만** 늘어선 줄일 때만 내 표로 본다 — 「∅800mm ∅1000mm …」. + """ + rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] + if len(rows) < 2: + return False + specs = [_clean(cell) for cell in rows[0] if _clean(cell)] + if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs): + return False + + unit = table.get("basis_unit") or "" + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + + made = 0 + for index, row in enumerate(rows[1:], start=1): + cells = [_clean(cell) for cell in row] + if not cells: + continue + name, spec_text = split_name_and_spec(cells[0]) + entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "") + if entry is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0], + reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.", + ) + ) + continue + values = [_cell_amount(cell) for cell in cells[1:]] + values = [value for value in values if value is not None] + if len(values) < len(specs): + # 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다. + continue + for spec_name, amount in zip(specs, values[-len(specs) :]): + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=spec_name, + ) + ) + made += 1 + return made > 0