From 56e0351389efdf2b0d8ab22876575a9b029fffc7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:31:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9D=B8=EA=B3=84=20=EC=83=88=20?= =?UTF-8?q?=EC=B9=B8=207=EA=B0=9C=20=EC=88=98=EC=9A=A9=20+=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C(=EC=98=B9=EB=B2=BD)=20=EC=A4=84=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B08 이 보내는 칸 중 **하나(`application_ratio_pct`)만 읽고 있었음** — 나머지는 조용히 버려지고 있었음. 메인이 「저장값이 안 닿아 늘 기본값으로 돌던」 결함을 잡은 것과 같은 자리라 대조해 발견 - 새로 읽는 칸: `quantity_gross` · `application_ratio_breakdown` · `quantity_breakdown` · `composite_parts` · `composite_not_ready` · `structure_kind` - **묶음 줄 조립** — 품셈에 그 공종이 없는 것(옹벽)은 조각들의 `단가 × 조각수량` 합이 곧 그 줄의 1단위 단가. 조각이 하나라도 비면 **금액을 안 만들고** 사유를 그대로 적음 - `composite_not_ready` 만 온 줄도 묶음으로 봄 — 그러지 않으면 「코드 없음」으로 뭉뚱그려져 무엇이 없는지 안 보임 - 구조물 줄은 사유를 갈라 적음 — 「구조물 전개식(원단위)이 없어 조각을 못 세웠습니다」 실측(구조물 시험 프로젝트 5601e828): 23줄 · 합계 540,244원 돌쌓기(찰) 4줄이 「후보 3건」으로 뜸 — 뒷길이가 저장 안 돼 갈래를 못 고름. 임의로 고르지 않고 후보를 보임 옹벽은 「원단위 미확보 (retaining_wall H=2.5) — 표에 있는 규격: 반중력식 H=2.0」 으로 사유째 뜸 ㉥ 가드 오탐 없음(메인이 택한 아랫단 + 물구멍 자재 조합에서 안 걸림) 검증: pytest 190 통과 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 107 +++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index add44313..b3e00a18 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -61,6 +61,16 @@ class HandoffWorkItem: ground_class: str = "" #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. application_ratio_pct: Decimal | None = None + #: 반영률 적용 **전** 수량. 산출근거에만 쓴다. + quantity_gross: Decimal | None = None + #: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다. + application_ratio_breakdown: dict | None = None + quantity_breakdown: dict | None = None + #: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지. + composite_parts: tuple = () + #: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다. + composite_not_ready: tuple = () + structure_kind: str = "" @property def display_name(self) -> str: @@ -192,6 +202,12 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[ haul_equipment=row.get("haul_equipment"), # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), + quantity_gross=_decimal(row.get("quantity_gross"), None), + application_ratio_breakdown=row.get("application_ratio_breakdown"), + quantity_breakdown=row.get("quantity_breakdown"), + composite_parts=tuple(row.get("composite_parts") or ()), + composite_not_ready=tuple(row.get("composite_not_ready") or ()), + structure_kind=row.get("structure_kind") or "", ) for row in payload["work_items"] ] @@ -271,6 +287,7 @@ def build_bill( # 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다. used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = [] orphans: list[HandoffWorkItem] = [] + composites: list[HandoffWorkItem] = [] for item in work_items: if not item.in_bill: # ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라 @@ -278,6 +295,11 @@ def build_bill( # 줄」로 잘못 읽힌다. result.excluded.append(_excluded_row(item)) continue + if (item.composite_parts or item.composite_not_ready) and not item.work_item_code: + # 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다 + # (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다. + composites.append(item) + continue if not item.work_item_code or item.work_item_code not in index: orphans.append(item) continue @@ -321,14 +343,26 @@ def build_bill( emitted.setdefault(leaf.code, item_no) result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result)) + # ── 1-2) 묶음 줄 ────────────────────────────────────────────────────────── + for item in composites: + counters[""] = counters.get("", 0) + 1 + result.rows.append(_composite_row(str(counters[""]), item, unit_prices, result)) + # ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ──────────────────────────────────── for item in orphans: + # 구조물 줄은 사유가 다르다 — 품셈에 그 공종이 없어 **전개식(원단위)** 이 있어야 + # 조각으로 설 수 있다. 「코드가 없다」로만 적으면 무엇을 해야 하는지 안 보인다. + reason = ( + "구조물 전개식(원단위)이 없어 조각을 못 세웠습니다 — B08 원단위 필요." + if item.origin == "structure" + else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다." + ) result.missing.append( { "name": item.display_name, "unit": item.unit, "quantity": str(item.quantity), - "reason": "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다.", + "reason": reason, } ) @@ -380,6 +414,77 @@ def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal: return _ZERO +def _composite_row( + item_no: str, + item: HandoffWorkItem, + unit_prices: UnitPriceBuild, + result: BillResult, +) -> BillRow: + """묶음 줄 — 조각들의 `단가 × 조각수량` 을 더해 **1단위 단가**를 만든다. + + 옹벽처럼 품셈에 그 공종이 없는 것은 **조각을 합친 것이 곧 그 줄의 단가**다 + (PLAN 9-3 「제목 + 상세」 한 쌍). 조각이 하나라도 비면 **금액을 만들지 않는다** — + 절반짜리 단가가 서는 것이 가장 위험하다. + """ + row = BillRow( + item_no=item_no, + level=1, + code=None, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=item.in_bill, + ) + missing_parts: list[str] = [] + money = None + for part in item.composite_parts: + code = str(part.get("code") or "") + amount = _decimal(part.get("quantity"), None) + if not code or amount is None or f"B-{code}" not in unit_prices.book.titles: + missing_parts.append(code or str(part.get("name") or "이름 없음")) + continue + scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount) + money = scaled if money is None else money + scaled + + reasons: list[str] = [] + for pending in item.composite_not_ready: + # 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다. + if isinstance(pending, str): + missing_parts.append(pending) + continue + label = str(pending.get("code") or pending.get("name") or "이름 없음") + why = str(pending.get("reason") or pending.get("note") or "") + missing_parts.append(label) + if why: + reasons.append(f"{label}: {why}") + + if missing_parts or money is None: + detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4]) + row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}" + result.missing.append( + { + "name": row.name, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": ( + f"묶음 조각 미확보 ({len(missing_parts)}건)" + + (f" — {reasons[0]}" if reasons else "") + ), + } + ) + return row + + line = money.scaled(item.quantity) + row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW) + row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + row.material_krw = line.material + row.labor_krw = line.labor + row.expense_krw = line.expense + row.note = f"묶음 {len(item.composite_parts)}조각 합계" + return row + + def _excluded_row(item: HandoffWorkItem) -> BillRow: """검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**""" return BillRow(