fix(B09): 배수관이 금액으로 섬 — 차단 판정과 규격 열 표 둘을 고침

⚠⚠ **차단인지는 `blocked_kind` 가 정한다 — 문구가 아니다.**
배수관 다섯 줄이 `blocked_kind=None · in_bill=True` 인데도 **사유가 있다는 것만으로**
막혀 금액이 안 서고 있었음. 그 사유는 차단이 아니라 **주의 문구**였음 —
「관종을 안 정해 기본값(파형강관)으로 섰습니다」.
⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말(ⓘ)로** 닮. 차단 표시가 붙은 줄은 그대로 막음.
ⓘ 오늘 두 번째 같은 병임 — 아침엔 겹침 설명을 `blocked_reason` 에 넣었더니 막힌 줄로 읽혔음.

⚠ 그리고 막힘이 풀리자 **다음 벽**이 드러남 — 관부설 12-11 표를 한 줄도 못 읽고 있었음.
그 표는 **규격이 열**(∅800·∅1000·∅1200)이고 값이 **나눗셈 식**(「0.62/2.5」 = 관 2.5m
한 개당 0.62시간 ⇒ m 당 환산값)임.
- `match_spec_column_table` 신설 — 첫 줄이 규격 이름만 늘어선 표일 때만 내 표로 봄
- 「0.016/2.5/2」처럼 겹친 나눗셈도 읽음 · 값이 빈 칸(기초콘크리트·거푸집)은 건너뜀
- 관경마다 갈래가 섬. B08 이 「Ø800」으로 보내도 「∅800mm」 갈래에 붙음

결과 — 배수관 네 줄이 처음으로 금액이 섬
  Ø1000 10m × 10,903.7 = 109,037 · Ø800 11m × 10,973.0 = 120,703 ·
  Ø800 9m ×2 = 197,514  ⇒ 합 **427,254원**
  본체 31,118,251 → **31,545,505원**
  ⚠ Ø1500 은 품셈 표에 그 관경이 없어(800·1000·1200) 그대로 막힘 — 후보만 보임

검증: pytest 307 통과(신규 4) · 자원 축 547 → 565 줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:33:24 +09:00
co-authored by Claude Opus 5
parent 0e841b35f1
commit cc61dddefa
3 changed files with 126 additions and 1 deletions
@@ -171,7 +171,15 @@ def _leaf_row(
result.excluded.append(row) result.excluded.append(row)
return 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 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
# 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
# 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
@@ -391,6 +391,13 @@ def match_table(
if match_choose_one_machine_table(node, table, catalog, result): if match_choose_one_machine_table(node, table, catalog, result):
return 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", "") form = table.get("pum_form", "")
if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: 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 result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1
@@ -198,3 +198,113 @@ def match_choose_one_machine_table(
) )
) )
return True 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