diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 36c3afcd..52b02f42 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -44,7 +44,13 @@ class SummaryRow: item: str = "" # 공종 (토사·연암·…) spec: str = "" # 규격 (기계(굴삭기)·백호우·…) unit: str = "㎥" - amount: float = 0.0 + amount: float = 0.0 # 반영률을 **곱한 뒤** 값 — 내역서에 쓰는 값 + # ⚠ 반영률 **적용 전** 값과 쓴 율을 함께 남긴다 (2026-09-07 3자 계약). + # 곱하기는 **B08 한 곳에서만** 한다. B09 가 율만 보고 또 곱하면 값이 두 배가 된다. + # 반영률 개념이 없는 줄은 `None` 이고, 100 % 인 줄도 **100.0 을 적는다** — + # 칸이 비어 있으면 「적용됐는지」를 받는 쪽이 단정할 수 없다. + amount_gross: float | None = None + application_ratio_pct: float | None = None note: str = "" # 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡). in_bill: bool = True @@ -83,9 +89,7 @@ def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float, if given <= 0: return [("암", total, "")] note = "" if abs(given - 100.0) < 1e-9 else f"입력 합 {given:g} % → 100 % 로 안분" - return [ - (name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0 - ] + return [(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0] def build_rows(source: SummaryInput) -> list[SummaryRow]: @@ -106,9 +110,7 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: ) for name, amount, note in _split_by_rock(earth.get(rock_key, 0.0), source): rows.append( - SummaryRow( - group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note - ) + SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note) ) rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0))) @@ -125,18 +127,29 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: group="성토면다짐", unit="㎡", amount=fill_face * _ratio(source, "fill_slope_compaction"), + amount_gross=fill_face, + application_ratio_pct=_ratio(source, "fill_slope_compaction") * 100.0, note=_ratio_note(source, "fill_slope_compaction", "성토면"), ) ) seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio( source, "seed_spray_cut" ) + # ⚠ 성·절토면 율이 다를 수 있어 **한 줄에 하나의 율**로 못 적는다. 적용 전 합을 함께 두고 + # 율은 두 율이 같을 때만 적는다 — 다르면 `None` 이고 비고에 두 율이 적힌다. + seed_gross = fill_face + cut_face + seed_fill_ratio = _ratio(source, "seed_spray_fill") + seed_cut_ratio = _ratio(source, "seed_spray_cut") rows.append( SummaryRow( group="초류종자살포", spec="씨드스프레이", unit="㎡", amount=seed, + amount_gross=seed_gross, + application_ratio_pct=( + seed_fill_ratio * 100.0 if seed_fill_ratio == seed_cut_ratio else None + ), note=_seed_note(source), ) ) @@ -146,6 +159,8 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: group="지장목제거", unit="㎡", amount=removal * _ratio(source, "obstacle_removal"), + amount_gross=removal, + application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0, note=_ratio_note(source, "obstacle_removal", "성토면+절토면"), ) ) @@ -215,6 +230,8 @@ def build_table(source: SummaryInput) -> dict[str, Any]: "spec": row.spec, "unit": row.unit, "amount": row.amount, + "amount_gross": row.amount_gross, + "application_ratio_pct": row.application_ratio_pct, "note": row.note, "in_bill": row.in_bill, } diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index f7cbef49..cc4647a2 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -18,6 +18,13 @@ ④예산내역서에 무대가 서서 운반비가 두 번 붙는다. 빼고 넘기지 않는 까닭은, 빠진 줄과 제외된 줄을 나중에 구별할 수 없기 때문이다. +⚠ **반영률은 B08 한 곳에서만 곱한다** (2026-09-07 3자 계약) + `quantity` 는 **곱한 뒤** 값이고 `quantity_gross` 는 곱하기 전 값이며 `application_ratio_pct` + 는 쓴 율이다. **셋을 함께 싣는 까닭**은 받는 쪽이 「이미 곱해졌나」를 단정할 수 있어야 + 하기 때문이다 — 율만 보내면 B09 가 또 곱해 값이 두 배가 된다. 100 % 인 줄도 `100.0` 을 + 적고, `None` 은 **반영률 개념이 없는 줄**에만 쓴다. + `verify_ratio_math()` 가 세 값이 서로 맞는지 실제로 재 본다. + ⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택) 값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을 세울 수 있다(울진 2 · 거창 5 · 오솔길 1). 설정 파일을 안 봐도 **인계본만으로 ④가 서게** 한다. @@ -175,6 +182,9 @@ def _earthwork_rows( "spec": str(row.get("spec") or ""), "unit": str(row.get("unit") or "㎥"), "quantity": float(row.get("amount") or 0.0), + # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. + "quantity_gross": row.get("amount_gross"), + "application_ratio_pct": row.get("application_ratio_pct"), "ground_class": ground, "haul_distance_m": None, "haul_equipment": None, @@ -213,6 +223,9 @@ def _haul_rows( "spec": str(row.get("ground") or ""), "unit": "㎥", "quantity": float(row.get("volume_m3") or 0.0), + # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). + "quantity_gross": None, + "application_ratio_pct": None, "ground_class": row.get("ground") or None, "haul_distance_m": float(row.get("average_distance_m") or 0.0), "haul_equipment": equipment, @@ -251,6 +264,8 @@ def _structure_rows( "spec": _spec_detail(structure), "unit": "m", "quantity": length, + "quantity_gross": None, + "application_ratio_pct": None, "ground_class": None, "haul_distance_m": None, "haul_equipment": None, @@ -317,7 +332,7 @@ def build_handoff( unmatched.extend(misses) materials = _material_rows(material_table or {}) - return { + result: dict[str, Any] = { "work_items": work_items, "materials": materials, # 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다. @@ -336,6 +351,9 @@ def build_handoff( } ), # 자재 쪽에만 할증이 있다 — 작업 공종에는 없다. + # ⚠ 세 갈래로 그대로 나른다(`applied`·`not_applied`·`rate_unavailable`). + # 「율이 없어 못 붙인 것」을 「붙였다」로 말하면 B09 가 나중에 한 번 더 붙인다. + "surcharge_status": (material_table or {}).get("surcharge_status"), "surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")), "unmatched_work_items": sorted(set(unmatched)), "mapping_pending_user": table.pending_user, @@ -349,6 +367,9 @@ def build_handoff( "bill_row_count": sum(1 for row in work_items if row["in_bill"]), "excluded_row_count": sum(1 for row in work_items if not row["in_bill"]), } + # ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다. + result["ratio_math_warnings"] = verify_ratio_math(result) + return result def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]: @@ -364,6 +385,25 @@ def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]: return found +def verify_ratio_math(handoff: dict[str, Any], *, tolerance: float = 1e-6) -> list[str]: + """⚠ `quantity == quantity_gross × 율/100` 이 실제로 맞는지 재 본다. + + 세 칸을 실어 두고 **서로 어긋나면** 받는 쪽이 어느 값을 믿을지 알 수 없다. + 「만들어 두고 안 부르면 없는 것과 같다」를 피하려고 `build_handoff()` 가 직접 부른다. + """ + found: list[str] = [] + for row in handoff.get("work_items") or []: + gross = row.get("quantity_gross") + ratio = row.get("application_ratio_pct") + if gross is None or ratio is None: + continue + expected = float(gross) * float(ratio) / 100.0 + actual = float(row.get("quantity") or 0.0) + if abs(expected - actual) > max(tolerance, abs(expected) * 1e-9): + found.append(f"{row.get('name')}: {actual:g} ≠ {gross:g} × {ratio:g} %") + return found + + def verify_bill_flags(handoff: dict[str, Any]) -> list[str]: """⚠ 코드가 없는데 내역에 서는 줄이 있으면 알린다. diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index 81d09270..c8283641 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -23,6 +23,10 @@ 「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면 (`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다. +⚠ 관급구분은 **세 값**이다 — `owner_supplied` · `contractor_supplied` · `unknown` + `unknown` 은 「아직 안 정함」이고 **지어내지 않겠다는 뜻**이다. B09 는 이 줄을 관급자재대에도 + 도급 재료비에도 넣지 않고 `missing` 으로 뺀다(2026-09-07 계약에 명시). + ⚠ 관급/사급은 **법이 아니라 발주 결정**이다 자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정 (`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨 @@ -71,6 +75,14 @@ INSTALL_BY_OWNER = "owner" # 관 직접설치 INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"} NOTE_INSTALL_BY_MISSING = "설치 주체 미지정" +#: ⚠ 할증 상태는 **세 갈래**다 (2026-09-07 3자 계약 정정). +#: 두 갈래(`True`/`False`)로 두면 「율을 못 찾아 안 붙인 것」이 「붙였다」로 나가고, +#: 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다. **깃발과 실제가 어긋나지 않을 것**이 +#: 요건이므로 상태를 그대로 말한다. +SURCHARGE_APPLIED = "applied" # 한 줄이라도 실제로 붙음 +SURCHARGE_NOT_APPLIED = "not_applied" # 붙일 줄이 없음(자재 자체가 없음) +SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 못 찾음 + NOTE_RATE_MISSING = "할증률 미확보" NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함" @@ -161,6 +173,15 @@ class MaterialRow: return " · ".join(parts) +def _surcharge_status(rows: list[MaterialRow]) -> str: + """할증이 실제로 붙었는가 — 세 갈래로 답한다.""" + if not rows: + return SURCHARGE_NOT_APPLIED + if any(row.surcharge_pct is not None and not row.surcharge_included for row in rows): + return SURCHARGE_APPLIED + return SURCHARGE_RATE_UNAVAILABLE + + def _supply_of(value: Any) -> tuple[str, str | None]: """설정 한 칸을 (관급구분, 설치주체) 로 읽는다. @@ -293,8 +314,11 @@ def build_table( } for row in ordered ], - # 이 표가 할증을 붙인 곳임을 못 박는다 — B09 는 다시 붙이지 않는다(㉠). - "surcharge_applied": True, + # ⚠ **깃발이 실제와 어긋나지 않게** 한다. 「붙일 자리였는데 율이 없어 못 붙였다」를 + # 「붙였다」로 말하면, 나중에 율이 들어왔을 때 B09 가 한 번 더 붙인다. + "surcharge_status": _surcharge_status(ordered), + # 옛 두 갈래 깃발 — **실제로 붙었을 때만** 참이다(호환을 위해 남긴다). + "surcharge_applied": _surcharge_status(ordered) == SURCHARGE_APPLIED, "surcharge_dataset": { "effective_date": table.effective_date, "source": table.source,