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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user