fix(B08): 층따기를 면적으로 안 내보내도록 막고, 매핑이 적은 밑수까지 대조
⚠ 또 하나의 단위 불일치 — 층따기 9-18 은 [주] 공식 `Q1 = 3600×q×K×f×E/㎝ = ㎥/시간` 이라 단가가 ㎥당인데 우리는 성토 비탈면적 ㎡ 2,645.21 을 보내고 있었음. 그대로 곱혀 4,102,708원이 서 있었음(B09 실측). - 매핑에 `basis_unit`·`basis_source`·`mismatch_reason` 을 두고, 우리 단위와 뜻이 다르면 **환산하지 않고** 막음(`input_missing` — 층따기 단 높이·폭은 설계 입력). 막힌 줄에도 집계값을 `spec_detail` 에 남겨 되짚게 함. - ⚠ 절 머리에 「(단위: …)」가 없는 공종은 마스터 `basis_unit` 이 빔(477 중 167만 참). 그래서 밑수 대조가 조용히 통과하고 있었음 — 매핑이 원문에서 읽어 적은 밑수를 `declared_units()` 로 함께 넘겨 대조가 서게 함. - 성토면다짐(9-17-1)은 시공량이 ㎡/시간이라 면적이 맞음 — 좁게 막아 형제 공종은 그대로. 시험 736 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,15 +76,34 @@ def basis_units(master: dict[str, Any] | None = None) -> dict[str, set[str]]:
|
||||
return out
|
||||
|
||||
|
||||
def merge_units(table: dict[str, set[str]], extra: dict[str, str] | None) -> dict[str, set[str]]:
|
||||
"""마스터 밑수에 **매핑이 원문에서 읽어 적은 밑수**를 얹는다.
|
||||
|
||||
⚠ **왜 얹나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서만 온다. 층따기 9-18
|
||||
처럼 **공식 [주]로만 단위가 밝혀지는 공종**은 그 자리가 비어(477 중 밑수가 선 것은 167)
|
||||
대조가 조용히 통과한다. 매핑이 적은 값은 **마스터가 빈 자리를 채우고, 있으면 함께 둔다**
|
||||
(하나로 줄이면 맞는 단위를 틀렸다고 말하게 된다).
|
||||
"""
|
||||
if not extra:
|
||||
return table
|
||||
merged = {code: set(units) for code, units in table.items()}
|
||||
for code, unit in extra.items():
|
||||
if unit:
|
||||
merged.setdefault(code, set()).add(normalize_unit(unit))
|
||||
return merged
|
||||
|
||||
|
||||
def verify_unit_matches_basis(
|
||||
rows: Iterable[dict[str, Any]], table: dict[str, set[str]] | None = None
|
||||
rows: Iterable[dict[str, Any]],
|
||||
table: dict[str, set[str]] | None = None,
|
||||
extra: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""⚠ 인계 줄의 단위가 품셈 밑수와 다르면 알린다.
|
||||
|
||||
**막지는 않는다** — 여기서 줄을 빼면 「빠진 줄」이 되어 더 안 보인다. 사유를 내고
|
||||
사람이 보게 한다. 코드가 없는 줄·수량이 없는 줄·마스터에 없는 코드는 대상이 아니다.
|
||||
"""
|
||||
known = table if table is not None else basis_units()
|
||||
known = merge_units(table if table is not None else basis_units(), extra)
|
||||
if not known:
|
||||
return []
|
||||
out: list[str] = []
|
||||
|
||||
@@ -46,6 +46,7 @@ from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import (
|
||||
normalize_unit,
|
||||
unit_for_code,
|
||||
verify_unit_matches_basis,
|
||||
)
|
||||
@@ -210,6 +211,20 @@ class WorkItemMapping:
|
||||
#: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다.
|
||||
pipe: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def declared_units(self) -> dict[str, str]:
|
||||
"""공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다.
|
||||
|
||||
⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가
|
||||
밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를
|
||||
함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다.
|
||||
"""
|
||||
found: dict[str, str] = {}
|
||||
for row in (*self.earthwork, *self.haul, *self.structure):
|
||||
code, unit = row.get("work_item_code"), row.get("basis_unit")
|
||||
if code and unit:
|
||||
found[str(code)] = str(unit)
|
||||
return found
|
||||
|
||||
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
|
||||
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
|
||||
exact = [
|
||||
@@ -470,6 +485,27 @@ def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple
|
||||
return None, NOTE_METHOD_MISSING
|
||||
|
||||
|
||||
def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None:
|
||||
"""(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**.
|
||||
|
||||
⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데,
|
||||
**공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의
|
||||
`Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 —
|
||||
그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다.
|
||||
⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다.
|
||||
"""
|
||||
declared = str((entry or {}).get("basis_unit") or "")
|
||||
if not declared or not unit:
|
||||
return None
|
||||
if normalize_unit(unit) == normalize_unit(declared):
|
||||
return None
|
||||
return (
|
||||
declared,
|
||||
str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING),
|
||||
str((entry or {}).get("mismatch_reason") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _earthwork_rows(
|
||||
summary_table: dict[str, Any],
|
||||
mapping: WorkItemMapping,
|
||||
@@ -492,6 +528,15 @@ def _earthwork_rows(
|
||||
lookup_ground, method_note = _mapping_ground(ground, methods)
|
||||
entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None
|
||||
code = (entry or {}).get("work_item_code")
|
||||
# ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18).
|
||||
# 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다.
|
||||
unit = str(row.get("unit") or "㎥")
|
||||
amount = float(row.get("amount") or 0.0)
|
||||
mismatch = _basis_mismatch(entry, unit) if code else None
|
||||
spec_detail = ""
|
||||
if mismatch is not None:
|
||||
spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})"
|
||||
unit, amount = mismatch[0], 0.0
|
||||
if code is None and not is_subtotal:
|
||||
label = f"{group}({ground})" if ground else group
|
||||
unmatched.append(f"{label} — {method_note}" if method_note else label)
|
||||
@@ -500,8 +545,8 @@ def _earthwork_rows(
|
||||
"work_item_code": code,
|
||||
"name": group,
|
||||
"spec": str(row.get("spec") or ""),
|
||||
"unit": str(row.get("unit") or "㎥"),
|
||||
"quantity": float(row.get("amount") or 0.0),
|
||||
"unit": unit,
|
||||
"quantity": amount,
|
||||
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
|
||||
"quantity_gross": row.get("amount_gross"),
|
||||
"application_ratio_pct": row.get("application_ratio_pct"),
|
||||
@@ -513,7 +558,7 @@ def _earthwork_rows(
|
||||
"haul_equipment": None,
|
||||
"station_from": None,
|
||||
"station_to": None,
|
||||
"spec_detail": "",
|
||||
"spec_detail": spec_detail,
|
||||
"composite_parts": None,
|
||||
"structure_kind": None,
|
||||
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
|
||||
@@ -522,12 +567,12 @@ def _earthwork_rows(
|
||||
"secondary_axes": None,
|
||||
"spec_class": None,
|
||||
"spec_class_basis": "",
|
||||
# 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다).
|
||||
"blocked_kind": None,
|
||||
"blocked_reason": "",
|
||||
# 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다.
|
||||
"blocked_kind": mismatch[1] if mismatch else None,
|
||||
"blocked_reason": mismatch[2] if mismatch else "",
|
||||
"composite_not_ready": None,
|
||||
# 합계 줄과 무대 줄은 값은 내되 내역에 안 선다.
|
||||
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal,
|
||||
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None,
|
||||
"excavation_method": methods.get(ground) if ground else None,
|
||||
"in_bill_reason": "집계 합계 줄 — 검산용"
|
||||
if is_subtotal
|
||||
@@ -1164,7 +1209,9 @@ def build_handoff(
|
||||
result["material_code_warnings"] = verify_no_code_on_materials(result)
|
||||
result["bill_flag_warnings"] = verify_bill_flags(result)
|
||||
# ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08).
|
||||
result["basis_unit_warnings"] = verify_unit_matches_basis(work_items)
|
||||
result["basis_unit_warnings"] = verify_unit_matches_basis(
|
||||
work_items, extra=table.declared_units()
|
||||
)
|
||||
result["placing_notes"] = placing_notes
|
||||
return result
|
||||
|
||||
|
||||
@@ -64,7 +64,11 @@
|
||||
{
|
||||
"group": "층따기",
|
||||
"work_item_code": "FP-09-18",
|
||||
"master_name": "층따기"
|
||||
"master_name": "층따기",
|
||||
"basis_unit": "㎥",
|
||||
"basis_source": "품셈 9-18 [주] 「Q1 = 3600×q×K×f×E/㎝ = ㎥/시간」 — 절 머리에 「(단위: …)」가 없고 **공식으로만** 단위가 밝혀지는 자리라 마스터 `basis_unit` 이 비어 있다(2026-09-08 B09 확인).",
|
||||
"mismatch_reason": "층따기는 품셈이 **체적(㎥)**으로 세는데 우리 집계는 **성토 비탈면적(㎡)** 입니다 — 층따기 단의 높이·폭이 있어야 체적이 나옵니다(교본: 「층따기 높이·폭은 설계도서에 명시」). 그 값이 정해지면 물량이 섭니다.",
|
||||
"mismatch_kind": "input_missing"
|
||||
},
|
||||
{
|
||||
"group": "면고르기",
|
||||
|
||||
Reference in New Issue
Block a user