From 52d76a9286e4ecacc6899c669663d357e1950d3b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 05:39:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B09):=20=EC=A0=9C=EC=9E=A1=EB=B9=84=20?= =?UTF-8?q?=EB=B0=91=EC=88=98=20=3D=20=EC=A7=81=EC=A0=91=EB=85=B8=EB=AC=B4?= =?UTF-8?q?=EB=B9=84=20+=20=EC=95=88=20=EB=B6=88=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20=EC=9E=87=EA=B8=B0=20+=20700=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창이 B09 를 읽기 전용으로 교차검토해 낸 지적을 처리 제잡비 밑수 — **사람 품(직접노무비)만**으로 바꿈. 기계 안의 조종원 노임은 안 셈 - 근거 셋을 주석에 인용: 산림품셈 13-6-2 [주]③ 「노무비의 합계액」 · 건설품셈 제8장 「잡재료 등 손료 : **직접노무비**에 …」 · 같은 장에서 기계를 넣을 때는 「노무비, 기계손료 및 운전경비의 합」이라 **따로 적음** - 뜻으로도 그쪽 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람 품에 비례해 얹는 자리인데 그 표엔 굴착기가 이미 본 자원으로 서 있음 - 찰쌓기 60~80 기준 ㎡당 2,565.90 → **2,098.46** (제잡비 붙는 17줄 전부 걸림) - ⚠ 잠정 — 사용자 확정 대기. 조종원 포함이면 약 +22 % 안 불리던 가드 둘 (「있다」와 「돈다」는 다름) - ㉥ 물빼기 파이프 — **조판에서 실제로 부름**. 지금은 늘 아랫단이라 안 걸리되 설계 조건이 실리는 날 그 값만 바꾸면 바로 걸림 - ㉢ 배합 분해 — 부를 자리가 아직 없음. **그 사실과 부를 위치를 코드에 적음** 700줄 제한 (CLAUDE.md 4장) — 셋을 나눔 - `_UnitPrice_View`(화면용 조회) · `_ResourceAxis_Sources`(자료 적재·셀 파싱) · `_BillOfQuantities_Rows`(줄 만들기). 가르는 금을 각 파일 머리말에 적음 - 부르는 쪽이 어디서 오는지 신경 쓰지 않게 재수출 물결표 목록을 `RANGE_DASHES` 한 곳으로 모음 — 네 파일에 따로 적혀 서로 달랐음 (지금 물리는 것은 없었으나 같은 목록이 네 벌이면 언젠가 하나만 고쳐짐) 검증: pytest 206 통과, 700줄 초과 0 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 314 ++--------------- .../B09_Estimation_BillOfQuantities_Rows.py | 322 ++++++++++++++++++ B09_Estimation/B09_Estimation_Guards.py | 13 + .../B09_Estimation_MachineProductivity.py | 3 +- B09_Estimation/B09_Estimation_PriceBook.py | 25 +- B09_Estimation/B09_Estimation_ResourceAxis.py | 229 ++----------- .../B09_Estimation_ResourceAxis_Sources.py | 220 ++++++++++++ .../B09_Estimation_ResourceAxis_Transposed.py | 3 +- B09_Estimation/B09_Estimation_UnitPrice.py | 244 ++----------- .../B09_Estimation_UnitPrice_View.py | 258 ++++++++++++++ 10 files changed, 919 insertions(+), 712 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_Sources.py create mode 100644 B09_Estimation/B09_Estimation_UnitPrice_View.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index d8410b1f..4a5f7329 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -24,7 +24,9 @@ from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_Guards import ( + OVERHEAD_TIER_WITHOUT_PIPE, check_excluded_rows_not_priced, + check_drain_pipe_not_double_counted, check_included_materials_not_listed, check_free_haul_not_priced, check_haul_volume_within_cut, @@ -304,6 +306,15 @@ def _number_of(path: tuple[int, ...]) -> str: return "-".join(str(n) for n in path) +from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402 + _composite_row, + _excluded_row, + _haul_price_of, + _leaf_row, + _material_row, +) + + def build_bill( payload: dict[str, Any], *, @@ -415,6 +426,15 @@ def build_bill( ], ) + # ㉥ 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 파이프를 자재로 또 세지 않았는가. + # 지금은 제잡비를 늘 아랫단으로 붙여 `overhead_tier` 가 늘 미설치다 — 그래도 + # **부르는 자리를 비워 두지 않는다**(「있다」와 「돈다」는 다르다). 설계 조건이 + # 인계에 실리는 날 그 값만 바꿔 넣으면 바로 걸린다. + check_drain_pipe_not_double_counted( + overhead_tier=str(payload.get("overhead_tier") or OVERHEAD_TIER_WITHOUT_PIPE), + material_names=[m.material_name for m in materials], + ) + # ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡). # 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다. # 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다. @@ -468,300 +488,6 @@ def build_bill( return result -def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal: - """그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다.""" - for row in result.rows: - if row.name == item.name and row.unit_price_krw is not None: - return row.unit_price_krw - 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( - item_no="", - level=1, - code=item.work_item_code, - name=item.name, - spec=item.spec, - unit=item.unit, - quantity=item.quantity, - in_bill=False, - note=item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.", - ) - - -def _leaf_row( - item_no: str, - node: _MasterNode, - item: HandoffWorkItem, - unit_prices: UnitPriceBuild, - result: BillResult, -) -> BillRow: - """세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다.""" - row = BillRow( - item_no=item_no, - level=node.level, - code=node.code, - name=item.name or node.name, - spec=item.spec, - unit=item.unit, - quantity=item.quantity, - in_bill=item.in_bill, - ) - if item.application_ratio_pct is not None: - # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. - row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" - - if not item.in_bill: - # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). - row.quantity = item.quantity - row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다." - result.excluded.append(row) - return row - - if item.blocked_reason: - # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. - # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 - # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. - row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}" - result.missing.append( - { - "name": row.name, - "code": node.code, - "unit": row.unit, - "quantity": str(item.quantity), - "reason": row.note, - "blocked_kind": item.blocked_kind, - } - ) - return row - - price_code = f"B-{node.code}" - if item.variant_value and price_code not in unit_prices.book.titles: - # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. - # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. - picked = find_variant_code(node.code, item.variant_value, unit_prices) - if picked is not None: - price_code = picked - row.spec = f"{row.spec} {item.variant_value}".strip() - - if price_code not in unit_prices.book.titles: - # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 - # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). - # 한 층 아래 공종 + **규격 갈래**(`#무근구조물`) 둘 다 후보로 본다. - children = sorted( - code - for code in unit_prices.book.titles - if ( - code.startswith(f"{price_code}-") - and code.count("-") == price_code.count("-") + 1 - and "#" not in code - ) - or code.startswith(f"{price_code}#") - ) - if children: - names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) - row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" - reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" - else: - row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." - reason = "일위대가 없음" - result.missing.append( - { - "name": row.name, - "code": node.code, - "unit": row.unit, - "quantity": str(item.quantity), - "reason": reason, - "candidates": ", ".join(children), - } - ) - return row - - missing_basis = unit_prices.basis_missing.get(node.code) - if missing_basis: - # ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배 - # 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.** - row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}" - result.missing.append( - { - "name": row.name, - "code": node.code, - "unit": row.unit, - "quantity": str(item.quantity), - "reason": "밑수 미확보 — 곱하면 10배·100배 틀림", - } - ) - return row - - covered = unit_prices.partial_ratio.get(node.code) - if covered is not None: - # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 - # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. - row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." - result.missing.append( - { - "name": row.name, - "code": node.code, - "unit": row.unit, - "quantity": str(item.quantity), - "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", - } - ) - return row - - title = unit_prices.book.title(price_code) - if not title.unit: - # ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다. - # 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**. - # 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다. - row.note = " / ".join( - part - for part in ( - row.note, - f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 " - "보고 곱했습니다. 확인 필요.", - ) - if part - ) - - # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. - if price_code not in result.used_unit_prices: - result.used_unit_prices.append(price_code) - - unit_money = unit_prices.book.resolve(price_code) - line = unit_money.scaled(item.quantity) - row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) - # 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상. - row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) - # 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다. - row.material_krw = line.material - row.labor_krw = line.labor - row.expense_krw = line.expense - return row - - -def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: - """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" - row = BillRow( - item_no="", - level=1, - code=None, - name=material.material_name, - spec=material.spec, - unit=material.unit, - quantity=material.total_amount, - note=material.surcharge_note, - ) - if material.supply_type == SUPPLY_UNKNOWN: - row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." - result.missing.append( - { - "name": material.display_name, - "unit": material.unit, - "quantity": str(material.total_amount), - "reason": "공급 구분 미정(unknown)", - } - ) - return row - # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이 - # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 - # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). - if material.supply_type == SUPPLY_OWNER: - row.note = ( - row.note - or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " - "관급자재대(총원가 밖 별도 표기)로 갑니다." - ) - reason = "관급 자재 단가 없음" - else: - row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." - reason = "사급 자재 단가 없음(미결 No.18)" - - result.missing.append( - { - "name": material.display_name, - "unit": material.unit, - "quantity": str(material.total_amount), - "reason": reason, - "supply_type": material.supply_type, - } - ) - return row - - def bill_summary(result: BillResult) -> dict[str, Any]: """화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다.""" return { diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py new file mode 100644 index 00000000..46a19f4c --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -0,0 +1,322 @@ +"""B09 원가계산 — ④ 예산내역서 **줄 만들기** (`B09_Estimation_BillOfQuantities` 보조). + +가르는 금은 「표 한 장을 짜는가 / 줄 하나를 만드는가」다. 700줄 제한(CLAUDE.md 4장)에 +걸려 나눴고, 부르는 쪽은 종전대로 조판 모듈에서 가져다 쓴다. + +⚠ **여기 있는 줄 만들기는 하나같이 「못 세우면 금액을 비운다」**로 끝난다 — +0 으로 때우면 총액이 그럴듯해지고 무엇이 빠졌는지 안 보인다. +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_BillOfQuantities import ( + SUPPLY_OWNER, + SUPPLY_UNKNOWN, + BillResult, + BillRow, + HandoffMaterial, + HandoffWorkItem, + _BLOCKED_LABELS, + _MasterNode, + _decimal, +) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code + +_ZERO = Decimal(0) + + +def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal: + """그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다.""" + for row in result.rows: + if row.name == item.name and row.unit_price_krw is not None: + return row.unit_price_krw + 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( + item_no="", + level=1, + code=item.work_item_code, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=False, + note=item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.", + ) + + +def _leaf_row( + item_no: str, + node: _MasterNode, + item: HandoffWorkItem, + unit_prices: UnitPriceBuild, + result: BillResult, +) -> BillRow: + """세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다.""" + row = BillRow( + item_no=item_no, + level=node.level, + code=node.code, + name=item.name or node.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=item.in_bill, + ) + if item.application_ratio_pct is not None: + # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. + row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" + + if not item.in_bill: + # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). + row.quantity = item.quantity + row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다." + result.excluded.append(row) + return row + + if item.blocked_reason: + # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. + # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 + # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. + row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": row.note, + "blocked_kind": item.blocked_kind, + } + ) + return row + + price_code = f"B-{node.code}" + if item.variant_value and price_code not in unit_prices.book.titles: + # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. + # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. + picked = find_variant_code(node.code, item.variant_value, unit_prices) + if picked is not None: + price_code = picked + row.spec = f"{row.spec} {item.variant_value}".strip() + + if price_code not in unit_prices.book.titles: + # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 + # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). + # 한 층 아래 공종 + **규격 갈래**(`#무근구조물`) 둘 다 후보로 본다. + children = sorted( + code + for code in unit_prices.book.titles + if ( + code.startswith(f"{price_code}-") + and code.count("-") == price_code.count("-") + 1 + and "#" not in code + ) + or code.startswith(f"{price_code}#") + ) + if children: + names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) + row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" + reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" + else: + row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." + reason = "일위대가 없음" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": reason, + "candidates": ", ".join(children), + } + ) + return row + + missing_basis = unit_prices.basis_missing.get(node.code) + if missing_basis: + # ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배 + # 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.** + row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": "밑수 미확보 — 곱하면 10배·100배 틀림", + } + ) + return row + + covered = unit_prices.partial_ratio.get(node.code) + if covered is not None: + # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 + # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. + row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", + } + ) + return row + + title = unit_prices.book.title(price_code) + if not title.unit: + # ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다. + # 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**. + # 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다. + row.note = " / ".join( + part + for part in ( + row.note, + f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 " + "보고 곱했습니다. 확인 필요.", + ) + if part + ) + + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. + if price_code not in result.used_unit_prices: + result.used_unit_prices.append(price_code) + + unit_money = unit_prices.book.resolve(price_code) + line = unit_money.scaled(item.quantity) + row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) + # 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상. + row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + # 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다. + row.material_krw = line.material + row.labor_krw = line.labor + row.expense_krw = line.expense + return row + + +def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: + """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" + row = BillRow( + item_no="", + level=1, + code=None, + name=material.material_name, + spec=material.spec, + unit=material.unit, + quantity=material.total_amount, + note=material.surcharge_note, + ) + if material.supply_type == SUPPLY_UNKNOWN: + row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": "공급 구분 미정(unknown)", + } + ) + return row + # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이 + # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 + # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). + if material.supply_type == SUPPLY_OWNER: + row.note = ( + row.note + or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " + "관급자재대(총원가 밖 별도 표기)로 갑니다." + ) + reason = "관급 자재 단가 없음" + else: + row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + reason = "사급 자재 단가 없음(미결 No.18)" + + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": reason, + "supply_type": material.supply_type, + } + ) + return row diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index 800ceac2..0bf0fdc7 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -92,6 +92,13 @@ def check_haul_volume_within_cut( ) +#: ⚠ **아직 부르는 자리가 없다** (2026-09-08 메인 창 교차검토에서 확인). +#: 배합 분해(콘크리트 → 시멘트·모래·자갈)가 우리 일위대가에 아직 안 서 있어 +#: 넘길 값이 없다. **「있다」와 「돈다」는 다르다** — 조건이 생기는 곳을 아래에 적어 둔다. +#: +#: **부를 자리** — `B09_Estimation_UnitPrice.build_unit_prices()` 에서 콘크리트 계열 +#: (`FP-12-01*`)에 시멘트·모래·자갈 줄이 붙기 시작하면, 그 공종의 시멘트 총량과 +#: 콘크리트 체적을 넘겨 부를 것. 지금 그 표는 「㎥」까지만 내고 분해가 없다. def check_mix_decomposed_once( *, cement_total_kg: Decimal, @@ -218,6 +225,12 @@ OVERHEAD_TIER_WITHOUT_PIPE = "without_pipe" DRAIN_PIPE_NAMES = ("물빼기파이프", "물빼기 파이프", "물구멍", "배수공") +#: ⚠ **아직 부르는 자리가 없다** — 제잡비를 늘 **아랫단**(파이프 미설치)으로만 붙여 +#: 조건이 안 온다. 값이 틀린 것은 아니고, **윗단을 쓰게 되는 날 막을 것이 없는** 상태다. +#: +#: **부를 자리** — `B09_Estimation_BillOfQuantities.build_bill()` 의 자재 검사 옆. +#: 제잡비 윗단을 고르는 설계 조건(물빼기 파이프 설치 여부)이 인계에 실리면, +#: 그 조건과 자재 이름 목록을 넘겨 부를 것. 지금은 그 칸 자체가 없다. def check_drain_pipe_not_double_counted( *, overhead_tier: str, diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index cd3fff20..e5a6ea05 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -36,6 +36,7 @@ from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog +from B09_Estimation.B09_Estimation_ResourceAxis import RANGE_DASHES _ZERO = Decimal(0) _SECONDS_PER_HOUR = Decimal(3600) @@ -64,7 +65,7 @@ _RE_PARENS = re.compile(r"[((]([^))]*)[))]") _RE_NUMBER = re.compile(r"-?\d+(?:\.\d+)?") _RE_FRACTION = re.compile(r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)$") #: 범위 표기 — 「0.55∼0.45」·「0.2~0.8」. **확정값이 아니다.** -_RE_RANGE = re.compile(r"\d+(?:\.\d+)?\s*[∼~~-]\s*\d+(?:\.\d+)?") +_RE_RANGE = re.compile(rf"\d+(?:\.\d+)?\s*[{RANGE_DASHES}]\s*\d+(?:\.\d+)?") class ProductivityError(ValueError): diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index d73ee436..ad91cc60 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -194,12 +194,30 @@ class PriceBook: raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다") total = Money3() + # 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다. + # 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋. + direct_labor = Decimal(0) for row in rows: # ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로 # 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다). if row.percent_of_labor is not None: # 제잡비 — **노무비 합계**의 %가 **경비**로 붙는다(품셈 13-6-1 [주]③). - total = total + Money3(expense=total.labor * row.percent_of_labor / Decimal(100)) + # + # ⚠ **밑수는 사람 품(직접노무비)이다** — 기계 줄 안의 조종원 노임은 + # 안 센다. 근거 셋(2026-09-08 원문 대조, 두 창 합의): + # ① 산림품셈 13-6-2 [주]③ — 「제잡비는 콘크리트 버켓 손료, 다짐기계 + # 손료 비용이며 **노무비의 합계액**에 위 표의 비율을 곱한 금액을 + # 상한으로 하여 계상한다」 + # ② 건설품셈 제8장 — 「잡재료 등 손료 : **직접노무비**에 다음 표의 + # 비율을 곱한 것을 상한으로 한다」 (같은 이름의 규정) + # ③ 같은 장 — 기계를 넣을 때는 「잡재료비 = **노무비, 기계손료 및 + # 운전경비의 합** × 잡재료비율」이라 **따로 적음** ⇒ 넓은 쪽이면 + # 명시하는 서식인데 13-6-2 는 그냥 「노무비」다. + # 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람 + # 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다. + # ⚠ 잠정 — 조종원 노임을 넣으면 찰쌓기 60~80 기준 2,098.46 → 2,565.90 + # (약 +22 %). 사용자 확정 대기(PLAN 9-6). + total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100)) continue child = self.resolve(row.ref_code, (*_seen, code)) @@ -207,7 +225,10 @@ class PriceBook: # 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등). total = total + total.scaled(row.percent_of_parent / Decimal(100)) continue - total = total + child.scaled(row.quantity) + scaled = child.scaled(row.quantity) + if self.titles[row.ref_code].kind is PriceKind.LABOR: + direct_labor = direct_labor + scaled.labor + total = total + scaled return total def unmatched_codes(self) -> list[str]: diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index f4d0e8d9..630d651b 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -145,7 +145,13 @@ class ResourceCatalog: #: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」), #: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다. -_RE_RANGE_CELL = re.compile(r"^\d+(?:\.\d+)?\s*[∼~〜\-–]\s*\d+(?:\.\d+)?$") +#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로 +#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음). +#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.** +#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다. +RANGE_DASHES = "∼~〜~-–‐" + +_RE_RANGE_CELL = re.compile(rf"^\d+(?:\.\d+)?\s*[{RANGE_DASHES}]\s*\d+(?:\.\d+)?$") _RE_HANGUL = re.compile(r"[가-힣]") @@ -192,201 +198,6 @@ def _normalize(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).strip() -def load_labor_catalog(file_name: str = "labor_const_2026-01-01.json") -> ResourceCatalog: - """노임 카탈로그 132직종 + `aliases`. 코드는 `occupation_code`.""" - payload = _read_json(*_CATALOG_SUBPATH, file_name) - variables = payload["variables"] - records = variables["labor_rate"]["records"] - entries = [ - CatalogEntry(code=str(r["occupation_code"]), name=r["occupation_name"], kind="labor") - for r in records - ] - return ResourceCatalog(entries=entries, aliases=dict(variables.get("aliases", {}))) - - -def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list[CatalogEntry]: - """기종 카탈로그 613건을 매칭용 항목으로 편다. - - ⚠ **규격이 매칭의 일부**다 — 「굴착기(무한궤도)」만 23 규격이라 이름만으로는 - 한 대가 안 정해진다(지시 4번). `CatalogEntry.spec` 에 규격을 실어 둔다. - """ - from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog - - catalog = load_machine_catalog(file_name) - return [ - CatalogEntry(code=m.machine_code, name=m.name, kind="machine", spec=m.specification) - for m in catalog.machines.values() - ] - - -def load_material_catalog_entries( - file_name: str = "mat_price_public_2026-08-14.json", -) -> list[CatalogEntry]: - """관급 자재 6,999건을 매칭용 항목으로 편다. - - ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323). - **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다. - """ - from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog - - catalog = load_material_catalog(file_name) - return [ - CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification) - for m in catalog.items.values() - ] - - -def load_combined_catalog() -> ResourceCatalog: - """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다.""" - labor = load_labor_catalog() - return ResourceCatalog( - entries=[ - *labor.entries, - *load_machine_catalog_entries(), - *load_material_catalog_entries(), - ], - aliases=labor.aliases, - ) - - -def load_work_item_master( - file_name: str = "work_item_master_2026-01-01.json", -) -> dict[str, Any]: - """메인 창 산출물 — **읽기 전용**.""" - return _read_json(*_MASTER_SUBPATH, file_name) - - -def split_name_and_spec(cell: str) -> tuple[str, str]: - """셀 문자열에서 이름과 규격을 가른다. - - 「유압식백호우 (무한궤도,0.7㎥)」 → (`유압식백호우`, `무한궤도,0.7㎥`) - 「덤프트럭 15톤」 → (`덤프트럭`, `15톤`) - """ - text = str(cell or "").strip() - match = _RE_SPEC.search(text) - if not match: - return text, "" - spec = (match.group(1) or match.group(2) or "").strip() - name = (text[: match.start()] + text[match.end() :]).strip(" ,()()") - return name, spec - - -#: 기종 이름의 괄호 안은 **규격과 형식이 섞여** 있다 — -#: 「굴착기(무한궤도, 0.7㎥)」 는 이름 `굴착기(무한궤도)` + 규격 `0.7` 이다. -#: 형식(무한궤도·타이어)은 이름의 일부이고, 숫자가 든 조각만 규격이다. -_RE_MACHINE = re.compile(r"^(?P[^()()]+)[((](?P[^))]*)[))]") -_RE_SIZE_TOKEN = re.compile(r"\d+(?:\.\d+)?") - - -def parse_machine_cell(cell: str) -> tuple[str, str]: - """기종 셀을 카탈로그 이름과 규격으로 가른다. - - 「굴착기(무한궤도, 0.7㎥)」 → (`굴착기(무한궤도)`, `0.7`) - 「굴착기 (무한궤도)」 → (`굴착기(무한궤도)`, ``) ← 규격은 옆 칸에 있다 - 괄호가 없으면 원문 그대로 돌려준다. - """ - text = _normalize(cell) - match = _RE_MACHINE.match(text) - if not match: - return text, "" - base = match.group("base") - parts = [p for p in re.split(r"[,·/]", match.group("inner")) if p] - form_parts = [p for p in parts if not _RE_SIZE_TOKEN.search(p)] - size_parts = [p for p in parts if _RE_SIZE_TOKEN.search(p)] - name = f"{base}({','.join(form_parts)})" if form_parts else base - spec = "" - if size_parts: - found = _RE_SIZE_TOKEN.search(size_parts[0]) - spec = found.group(0) if found else "" - return name, spec - - -def spec_candidates(cells: list[str]) -> list[str]: - """규격이 옆 칸에 있는 표가 많다 — 뒷 칸들에서 규격 후보를 모은다. - - 실측 배치: `['굴착기+부착용집게', '0.2㎥', 'hr', '2.71', …]` · - `['굴착기 (무한궤도)', '굴착기(무한궤도,0.2㎥)', 'hr', '0.80', …]` - """ - found: list[str] = [] - for cell in cells: - text = _normalize(cell) - if not text or len(text) > 30: - continue - _, spec = parse_machine_cell(text) - if spec: - found.append(spec) - continue - token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³")) - if token: - found.append(token.group(0)) - return found - - -def parse_amount(cell: str) -> Decimal | None: - """숫자 셀만 값으로 본다. 숫자가 아니면 None — 억지로 읽지 않는다.""" - text = _normalize(cell) - if not _RE_NUMBER.match(text): - return None - try: - return Decimal(text.replace(",", "")) - except InvalidOperation: # pragma: no cover - 정규식이 먼저 거른다 - return None - - -#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25). -#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다. -_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$") - - -def parse_amount_expression(cell: str) -> Decimal | None: - """「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**. - - 식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은 - 「값 × 비율%」 한 모양뿐이다. - """ - found = _RE_RATIO_EXPRESSION.match(_normalize(cell)) - if found is None: - return None - return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100) - - -#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값. -#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」 -#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]). -#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기). -#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다. -_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[((](\d+(?:\.\d+)?)[))]$") - - -def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None: - """「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`.""" - text = _normalize(cell) - found = _RE_ALTERNATIVE.match(text) - if found: - return Decimal(found.group(1)), Decimal(found.group(2)) - plain = parse_amount(text) - return None if plain is None else (plain, None) - - -def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal: - """표 형태에 맞춰 소요량으로 환산한다. - - ⚠ **여기가 20배 틀리는 자리다.** - - `productivity`(생산량형, 예 「㎥/1인/1일」) → **1 ÷ 값** - - `requirement`(소요량형, 예 「100㎥당 인부 x인」) → **값 ÷ basis_quantity** - """ - if pum_form == "productivity": - if raw == 0: - raise ResourceAxisError("생산량이 0 이라 소요량으로 뒤집을 수 없습니다") - return Decimal(1) / raw - if pum_form == "requirement": - divisor = basis_quantity if basis_quantity else Decimal(1) - if divisor == 0: - raise ResourceAxisError("기준 수량이 0 입니다") - return raw / divisor - raise ResourceAxisError(f"자원 축을 붙일 수 없는 표 형태입니다: {pum_form}") - - @dataclass class ResourceRow: """자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나.""" @@ -721,6 +532,14 @@ def match_table( #: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다). #: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는 #: 떼면 안 되므로 넓게 잡지 않는다. +#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값. +#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」 +#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]). +#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기). +#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다. +_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[((](\d+(?:\.\d+)?)[))]$") + + _RE_RATIO_SUFFIX = re.compile(r"[((][\d.\s]*%?[))]$") @@ -827,3 +646,21 @@ def write_resource_axis( json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) return {"resource_axis": axis_path, "unmatched": unmatched_path} + + +# 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다. +# 가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. +from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402 + convert_amount, + load_combined_catalog, + load_labor_catalog, + load_machine_catalog_entries, + load_material_catalog_entries, + load_work_item_master, + parse_amount, + parse_amount_expression, + parse_amount_pair, + parse_machine_cell, + spec_candidates, + split_name_and_spec, +) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py new file mode 100644 index 00000000..69f3c23f --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py @@ -0,0 +1,220 @@ +"""B09 원가계산 — 자원 축 **자료 적재·셀 파싱** (`B09_Estimation_ResourceAxis` 보조). + +가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. 700줄 제한(CLAUDE.md +4장)에 걸려 나눴고, 부르는 쪽은 종전대로 `B09_Estimation_ResourceAxis` 에서 가져다 쓴다. + +⚠ **셀을 억지로 읽지 않는다** — 범위(「0.55∼0.45」)·참조(「육상과동일」)·반복부호(「〃」)는 +확정값이 아니므로 `None` 을 돌려주고, 그 사실이 위쪽에서 드러난다. +""" + +from __future__ import annotations + +import json +import os +import re +from decimal import Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + CatalogEntry, + RANGE_DASHES, + ResourceAxisError, + ResourceCatalog, + _normalize, + _project_root, + _read_json, + _RE_NUMBER, + _RE_RANGE_CELL, + _RE_SPEC, + _RE_ALTERNATIVE, +) + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") +_MASTER_SUBPATH = ("resources", "data_work_item_master") + + +def load_labor_catalog(file_name: str = "labor_const_2026-01-01.json") -> ResourceCatalog: + """노임 카탈로그 132직종 + `aliases`. 코드는 `occupation_code`.""" + payload = _read_json(*_CATALOG_SUBPATH, file_name) + variables = payload["variables"] + records = variables["labor_rate"]["records"] + entries = [ + CatalogEntry(code=str(r["occupation_code"]), name=r["occupation_name"], kind="labor") + for r in records + ] + return ResourceCatalog(entries=entries, aliases=dict(variables.get("aliases", {}))) + + +def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list[CatalogEntry]: + """기종 카탈로그 613건을 매칭용 항목으로 편다. + + ⚠ **규격이 매칭의 일부**다 — 「굴착기(무한궤도)」만 23 규격이라 이름만으로는 + 한 대가 안 정해진다(지시 4번). `CatalogEntry.spec` 에 규격을 실어 둔다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog(file_name) + return [ + CatalogEntry(code=m.machine_code, name=m.name, kind="machine", spec=m.specification) + for m in catalog.machines.values() + ] + + +def load_material_catalog_entries( + file_name: str = "mat_price_public_2026-08-14.json", +) -> list[CatalogEntry]: + """관급 자재 6,999건을 매칭용 항목으로 편다. + + ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323). + **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog + + catalog = load_material_catalog(file_name) + return [ + CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification) + for m in catalog.items.values() + ] + + +def load_combined_catalog() -> ResourceCatalog: + """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다.""" + labor = load_labor_catalog() + return ResourceCatalog( + entries=[ + *labor.entries, + *load_machine_catalog_entries(), + *load_material_catalog_entries(), + ], + aliases=labor.aliases, + ) + + +def load_work_item_master( + file_name: str = "work_item_master_2026-01-01.json", +) -> dict[str, Any]: + """메인 창 산출물 — **읽기 전용**.""" + return _read_json(*_MASTER_SUBPATH, file_name) + + +def split_name_and_spec(cell: str) -> tuple[str, str]: + """셀 문자열에서 이름과 규격을 가른다. + + 「유압식백호우 (무한궤도,0.7㎥)」 → (`유압식백호우`, `무한궤도,0.7㎥`) + 「덤프트럭 15톤」 → (`덤프트럭`, `15톤`) + """ + text = str(cell or "").strip() + match = _RE_SPEC.search(text) + if not match: + return text, "" + spec = (match.group(1) or match.group(2) or "").strip() + name = (text[: match.start()] + text[match.end() :]).strip(" ,()()") + return name, spec + + +#: 기종 이름의 괄호 안은 **규격과 형식이 섞여** 있다 — +#: 「굴착기(무한궤도, 0.7㎥)」 는 이름 `굴착기(무한궤도)` + 규격 `0.7` 이다. +#: 형식(무한궤도·타이어)은 이름의 일부이고, 숫자가 든 조각만 규격이다. +_RE_MACHINE = re.compile(r"^(?P[^()()]+)[((](?P[^))]*)[))]") +_RE_SIZE_TOKEN = re.compile(r"\d+(?:\.\d+)?") + + +def parse_machine_cell(cell: str) -> tuple[str, str]: + """기종 셀을 카탈로그 이름과 규격으로 가른다. + + 「굴착기(무한궤도, 0.7㎥)」 → (`굴착기(무한궤도)`, `0.7`) + 「굴착기 (무한궤도)」 → (`굴착기(무한궤도)`, ``) ← 규격은 옆 칸에 있다 + 괄호가 없으면 원문 그대로 돌려준다. + """ + text = _normalize(cell) + match = _RE_MACHINE.match(text) + if not match: + return text, "" + base = match.group("base") + parts = [p for p in re.split(r"[,·/]", match.group("inner")) if p] + form_parts = [p for p in parts if not _RE_SIZE_TOKEN.search(p)] + size_parts = [p for p in parts if _RE_SIZE_TOKEN.search(p)] + name = f"{base}({','.join(form_parts)})" if form_parts else base + spec = "" + if size_parts: + found = _RE_SIZE_TOKEN.search(size_parts[0]) + spec = found.group(0) if found else "" + return name, spec + + +def spec_candidates(cells: list[str]) -> list[str]: + """규격이 옆 칸에 있는 표가 많다 — 뒷 칸들에서 규격 후보를 모은다. + + 실측 배치: `['굴착기+부착용집게', '0.2㎥', 'hr', '2.71', …]` · + `['굴착기 (무한궤도)', '굴착기(무한궤도,0.2㎥)', 'hr', '0.80', …]` + """ + found: list[str] = [] + for cell in cells: + text = _normalize(cell) + if not text or len(text) > 30: + continue + _, spec = parse_machine_cell(text) + if spec: + found.append(spec) + continue + token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³")) + if token: + found.append(token.group(0)) + return found + + +def parse_amount(cell: str) -> Decimal | None: + """숫자 셀만 값으로 본다. 숫자가 아니면 None — 억지로 읽지 않는다.""" + text = _normalize(cell) + if not _RE_NUMBER.match(text): + return None + try: + return Decimal(text.replace(",", "")) + except InvalidOperation: # pragma: no cover - 정규식이 먼저 거른다 + return None + + +#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25). +#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다. +_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$") + + +def parse_amount_expression(cell: str) -> Decimal | None: + """「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**. + + 식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은 + 「값 × 비율%」 한 모양뿐이다. + """ + found = _RE_RATIO_EXPRESSION.match(_normalize(cell)) + if found is None: + return None + return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100) + + +def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None: + """「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`.""" + text = _normalize(cell) + found = _RE_ALTERNATIVE.match(text) + if found: + return Decimal(found.group(1)), Decimal(found.group(2)) + plain = parse_amount(text) + return None if plain is None else (plain, None) + + +def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal: + """표 형태에 맞춰 소요량으로 환산한다. + + ⚠ **여기가 20배 틀리는 자리다.** + - `productivity`(생산량형, 예 「㎥/1인/1일」) → **1 ÷ 값** + - `requirement`(소요량형, 예 「100㎥당 인부 x인」) → **값 ÷ basis_quantity** + """ + if pum_form == "productivity": + if raw == 0: + raise ResourceAxisError("생산량이 0 이라 소요량으로 뒤집을 수 없습니다") + return Decimal(1) / raw + if pum_form == "requirement": + divisor = basis_quantity if basis_quantity else Decimal(1) + if divisor == 0: + raise ResourceAxisError("기준 수량이 0 입니다") + return raw / divisor + raise ResourceAxisError(f"자원 축을 붙일 수 없는 표 형태입니다: {pum_form}") diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py index 38bcfb67..417b70c1 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py @@ -24,6 +24,7 @@ from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_ResourceAxis import ( + RANGE_DASHES, AxisResult, ResourceCatalog, ResourceRow, @@ -253,7 +254,7 @@ def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None _TRACK_PREFERENCE = ("무한궤도",) -_RE_SPEC_RANGE = re.compile(r"^(\d+(?:\.\d+)?)[∼~~-](\d+(?:\.\d+)?)$") +_RE_SPEC_RANGE = re.compile(rf"^(\d+(?:\.\d+)?)[{RANGE_DASHES}](\d+(?:\.\d+)?)$") def _spec_matches(catalog_spec: str, wanted: str) -> bool: diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index f60b44ac..19976501 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -42,6 +42,7 @@ from B09_Estimation.B09_Estimation_PriceBook import ( PriceTitle, ) from B09_Estimation.B09_Estimation_ResourceAxis import ( + RANGE_DASHES, AxisResult, build_resource_axis, load_combined_catalog, @@ -229,7 +230,9 @@ def load_basis_missing( #: 13-6-1·2 는 ∼, 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다. #: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다 #: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다). -_TILDE_CHARS = "∼~〜~" +#: 물결표 목록은 `B09_Estimation_ResourceAxis.RANGE_DASHES` 한 곳에서 온다 — +#: 붙임표(`-`·`–`)는 갈래 이름에 안 쓰이므로 물결표만 골라 쓴다. +_TILDE_CHARS = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐") def normalize_variant_key(text: str) -> str: @@ -539,223 +542,6 @@ def cached_build() -> UnitPriceBuild: return build_unit_prices() -def _plain(text: str) -> str: - """화면용 평문 — 마크다운 강조 표시를 벗긴다.""" - return _RE_EMPHASIS.sub(lambda match: match.group(1), text) - - -def _status_notes() -> list[str]: - """화면에 낼 「지금 무엇이 안 선 상태인가」. - - 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 - 거의 없다는 것이 이 자리의 진짜 공백이다. - """ - from B09_Estimation.B09_Estimation_MaterialCatalog import ( - catalog_summary, - load_material_catalog, - ) - - summary = catalog_summary(load_material_catalog()) - # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다. - return [ - _plain(note) - for note in [ - f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " - "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " - "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", - "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — " - "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 " - "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)", - ( - f"관급 자재 **설치 주체가 미지정**" - f"({summary['owner_supplied_install_unspecified']:,}건)이라 " - "안전관리비 대상액에 자동으로 넣지 않습니다." - ), - ] - ] - - -def build_summary(build: UnitPriceBuild) -> dict: - """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" - kinds: dict[str, int] = {} - for title in build.book.titles.values(): - kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 - # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다. - # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다 - # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07). - totals = sorted( - build.book.resolve(code).total - for code, title in build.book.titles.items() - if title.kind is PriceKind.UNIT_PRICE - ) - stats: dict[str, str] = {} - low: list[dict[str, str]] = [] - if totals: - stats = { - "min": _money_text(totals[0]), - "median": _money_text(totals[len(totals) // 2]), - "max": _money_text(totals[-1]), - } - # ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라 - # 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다. - low = [ - {"code": code, "name": title.name, "total": _money_text(money)} - for code, title in build.book.titles.items() - if title.kind is PriceKind.UNIT_PRICE - and code[2:].split("#")[0] not in build.partial_ratio - and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW - ] - - # 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다. - high = [ - {"code": code, "name": title.name, "total": _money_text(money)} - for code, title in build.book.titles.items() - if title.kind is PriceKind.UNIT_PRICE - and not title.unit - and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW - ] - - return { - "titles": len(build.book.titles), - "unit_price_totals": stats, - # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다. - "suspiciously_low": low, - # 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다. - "blocked_items": len(build.partial_ratio), - # 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다. - "unknown_basis_high": high, - "unknown_basis": sum( - 1 - for code, title in build.book.titles.items() - if title.kind is PriceKind.UNIT_PRICE and not title.unit - ), - "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), - "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), - "skipped_work_items": len(build.skipped), - "incomplete_machines": len(build.incomplete_machines), - "kinds": kinds, - # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). - "notes": _status_notes(), - } - - -def _money_text(value: Decimal) -> str: - """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2). - - 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 - (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). - """ - return str(round_at(value, OutputPlace.UNIT_PRICE_ROW)) - - -def list_unit_prices(build: UnitPriceBuild) -> list[dict]: - """목록표 — 「무엇이 있나」 한 줄씩.""" - rows: list[dict] = [] - for code, title in sorted(build.book.titles.items()): - if title.kind is not PriceKind.UNIT_PRICE: - continue - money = build.book.resolve(code) - rows.append( - { - "code": code, - "name": title.name, - "spec": title.spec, - "unit": title.unit, - "material": _money_text(money.material), - "labor": _money_text(money.labor), - "expense": _money_text(money.expense), - "total": _money_text(money.total), - } - ) - return rows - - -def detail_of(build: UnitPriceBuild, code: str) -> dict: - """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" - title = build.book.title(code) - money = build.book.resolve(code) - rows: list[dict] = [] - for detail in build.book.details.get(code, []): - if detail.percent_of_labor is not None: - # 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야 - # 화면 합계와 실제 단가가 어긋나지 않는다. - labor_so_far = sum((Decimal(str(r["labor"])) for r in rows), _ZERO) - amount = labor_so_far * detail.percent_of_labor / Decimal(100) - rows.append( - { - "code": detail.ref_code, - "name": "제잡비", - "spec": f"노무비의 {detail.percent_of_labor}%", - "unit": "%", - "quantity": str(detail.percent_of_labor), - "material": "0", - "labor": "0", - "expense": str(amount), - "total": _money_text(amount), - "source": "품셈 [주]", - "drillable": False, - "note": detail.note, - } - ) - continue - - child = build.book.title(detail.ref_code) - unit_money = build.book.resolve(detail.ref_code) - line = unit_money.scaled(detail.quantity) - rows.append( - { - "ref_code": detail.ref_code, - "name": child.name, - "spec": child.spec, - "unit": child.unit, - "source_index": SOURCE_INDEX.get(child.kind, 0), - "source_label": SOURCE_LABEL.get(child.kind, ""), - "drillable": child.kind in DRILLABLE_KINDS, - "quantity": str(detail.quantity), - "unit_material": _money_text(unit_money.material), - "unit_labor": _money_text(unit_money.labor), - "unit_expense": _money_text(unit_money.expense), - "unit_total": _money_text(unit_money.total), - "material": _money_text(line.material), - "labor": _money_text(line.labor), - "expense": _money_text(line.expense), - # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다. - # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다. - "total": str( - round_at(line.material, OutputPlace.UNIT_PRICE_ROW) - + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW) - + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW) - ), - "note": detail.note, - } - ) - # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」 - # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다. - summed = { - key: sum((Decimal(r[key]) for r in rows), Decimal(0)) - for key in ("material", "labor", "expense", "total") - } - # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. - # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. - check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") - return { - "code": code, - "name": title.name, - "spec": title.spec, - "unit": title.unit, - "kind": title.kind.value, - "material": str(summed["material"]), - "labor": str(summed["labor"]), - "expense": str(summed["expense"]), - "total": str(summed["total"]), - # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. - "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], - # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. - "precise_total": _money_text(money.total), - "rows": rows, - } - - @dataclass class DirectCostBreakdown: """⑤ 공사원가계산서가 받는 **직접비 3분할**. @@ -830,3 +616,25 @@ def cost_input_from_quantities( ), breakdown, ) + + +# 화면용 조회(요약·목록·본표)는 700줄 제한으로 `_View` 파일로 옮겼다. +# **부르는 쪽이 어디서 오는지 신경 쓰지 않게** 여기서 다시 내보낸다. +from B09_Estimation.B09_Estimation_UnitPrice_View import ( # noqa: E402 + build_summary, + detail_of, + list_unit_prices, +) + +__all__ = [ + "UnitPriceBuild", + "build_unit_prices", + "cached_build", + "build_summary", + "detail_of", + "list_unit_prices", + "direct_cost_from_quantities", + "cost_input_from_quantities", + "find_variant_code", + "normalize_variant_key", +] diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py new file mode 100644 index 00000000..14c57ed6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -0,0 +1,258 @@ +"""B09 원가계산 — 일위대가 **화면용 조회** (요약·목록·본표). + +조립(`B09_Estimation_UnitPrice`)과 **보여주기**를 갈라 둔 파일이다. 700줄 제한(CLAUDE.md +4장)에 걸려 나눴고, 가르는 금은 「값을 만드는가 / 만든 값을 화면 모양으로 옮기는가」다. + +⚠ 단수 처리는 **여기서** 한다 — 계산 함수 안에서 자르지 않는다 +(`B09_Estimation_Rounding` 머리말). 일위대가 금액란은 0.1원 버림이다. +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price +from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog +from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import ( + DRILLABLE_KINDS, + SOURCE_INDEX, + SOURCE_LABEL, + SUSPICIOUSLY_HIGH_KRW, + SUSPICIOUSLY_LOW_KRW, + UnitPriceBuild, +) +from B09_Estimation.B09_Estimation_Guards import check_column_sums + +_ZERO = Decimal(0) +_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") + + +def _plain(text: str) -> str: + """화면용 평문 — 마크다운 강조 표시를 벗긴다.""" + return _RE_EMPHASIS.sub(lambda match: match.group(1), text) + + +def _status_notes() -> list[str]: + """화면에 낼 「지금 무엇이 안 선 상태인가」. + + 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 + 거의 없다는 것이 이 자리의 진짜 공백이다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import ( + catalog_summary, + load_material_catalog, + ) + + summary = catalog_summary(load_material_catalog()) + # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다. + return [ + _plain(note) + for note in [ + f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " + "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " + "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", + "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — " + "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 " + "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)", + ( + f"관급 자재 **설치 주체가 미지정**" + f"({summary['owner_supplied_install_unspecified']:,}건)이라 " + "안전관리비 대상액에 자동으로 넣지 않습니다." + ), + ] + ] + + +def build_summary(build: UnitPriceBuild) -> dict: + """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" + kinds: dict[str, int] = {} + for title in build.book.titles.values(): + kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 + # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다. + # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다 + # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07). + totals = sorted( + build.book.resolve(code).total + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + ) + stats: dict[str, str] = {} + low: list[dict[str, str]] = [] + if totals: + stats = { + "min": _money_text(totals[0]), + "median": _money_text(totals[len(totals) // 2]), + "max": _money_text(totals[-1]), + } + # ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라 + # 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다. + low = [ + {"code": code, "name": title.name, "total": _money_text(money)} + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + and code[2:].split("#")[0] not in build.partial_ratio + and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW + ] + + # 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다. + high = [ + {"code": code, "name": title.name, "total": _money_text(money)} + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + and not title.unit + and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW + ] + + return { + "titles": len(build.book.titles), + "unit_price_totals": stats, + # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다. + "suspiciously_low": low, + # 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다. + "blocked_items": len(build.partial_ratio), + # 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다. + "unknown_basis_high": high, + "unknown_basis": sum( + 1 + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE and not title.unit + ), + "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), + "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), + "skipped_work_items": len(build.skipped), + "incomplete_machines": len(build.incomplete_machines), + "kinds": kinds, + # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). + "notes": _status_notes(), + } + + +def _money_text(value: Decimal) -> str: + """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2). + + 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 + (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). + """ + return str(round_at(value, OutputPlace.UNIT_PRICE_ROW)) + + +def list_unit_prices(build: UnitPriceBuild) -> list[dict]: + """목록표 — 「무엇이 있나」 한 줄씩.""" + rows: list[dict] = [] + for code, title in sorted(build.book.titles.items()): + if title.kind is not PriceKind.UNIT_PRICE: + continue + money = build.book.resolve(code) + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "material": _money_text(money.material), + "labor": _money_text(money.labor), + "expense": _money_text(money.expense), + "total": _money_text(money.total), + } + ) + return rows + + +def detail_of(build: UnitPriceBuild, code: str) -> dict: + """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" + title = build.book.title(code) + money = build.book.resolve(code) + rows: list[dict] = [] + for detail in build.book.details.get(code, []): + if detail.percent_of_labor is not None: + # 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야 + # 화면 합계와 실제 단가가 어긋나지 않는다. + # 밑수는 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다 + # (근거 인용 셋은 `PriceBook.resolve` 의 같은 자리 주석). + labor_so_far = sum( + ( + Decimal(str(row_item["labor"])) + for row_item in rows + if row_item.get("kind") == PriceKind.LABOR.value + ), + _ZERO, + ) + amount = labor_so_far * detail.percent_of_labor / Decimal(100) + rows.append( + { + "code": detail.ref_code, + "name": "제잡비", + "spec": f"노무비의 {detail.percent_of_labor}%", + "unit": "%", + "quantity": str(detail.percent_of_labor), + "material": "0", + "labor": "0", + "expense": str(amount), + "total": _money_text(amount), + "source": "품셈 [주]", + "drillable": False, + "note": detail.note, + } + ) + continue + + child = build.book.title(detail.ref_code) + unit_money = build.book.resolve(detail.ref_code) + line = unit_money.scaled(detail.quantity) + rows.append( + { + "ref_code": detail.ref_code, + "name": child.name, + "spec": child.spec, + "unit": child.unit, + # 제잡비 밑수를 가릴 때 쓴다 — 사람 품(`labor`)만 센다. + "kind": child.kind.value, + "source_index": SOURCE_INDEX.get(child.kind, 0), + "source_label": SOURCE_LABEL.get(child.kind, ""), + "drillable": child.kind in DRILLABLE_KINDS, + "quantity": str(detail.quantity), + "unit_material": _money_text(unit_money.material), + "unit_labor": _money_text(unit_money.labor), + "unit_expense": _money_text(unit_money.expense), + "unit_total": _money_text(unit_money.total), + "material": _money_text(line.material), + "labor": _money_text(line.labor), + "expense": _money_text(line.expense), + # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다. + # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다. + "total": str( + round_at(line.material, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW) + ), + "note": detail.note, + } + ) + # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」 + # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다. + summed = { + key: sum((Decimal(r[key]) for r in rows), Decimal(0)) + for key in ("material", "labor", "expense", "total") + } + # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. + # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. + check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") + return { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "kind": title.kind.value, + "material": str(summed["material"]), + "labor": str(summed["labor"]), + "expense": str(summed["expense"]), + "total": str(summed["total"]), + # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. + "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], + # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. + "precise_total": _money_text(money.total), + "rows": rows, + } From 1959aec6edf681eca0ccd699de1461f90e155497 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 05:41:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(B09):=20=EC=8B=9C=ED=97=98=200=20?= =?UTF-8?q?=EC=9D=B4=EB=8D=98=20=EA=B0=80=EB=93=9C=20=EB=91=98=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=20=E2=80=94=20=EC=A1=B0=EC=A2=85=EC=9B=90=20=EB=82=98?= =?UTF-8?q?=EB=88=97=EC=88=98=C2=B7=EB=AD=89=EC=B9=9C=20=ED=91=9C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창이 「가드에 짝 시험이 붙어 있나」를 물었고, 세어 보니 둘이 0 이었음 (`tmp/tests` 가 git 밖이라 그쪽에서 확인할 길이 없었음) - `check_operator_hours_basis` — 8시간 나눗수는 통과, 5.09시간으로 줄이면 걸림 (나눗수를 줄이는 것이 곧 효율을 사용료에 넣는 것, 품셈 8-1-6) - `match_packed_rows` — 뭉치지 않은 표는 **가로채지 않고**, 뭉친 표는 읽음 (그 문이 넓었을 때 자원 축이 304 → 244 줄로 떨어졌음) 검증: pytest 209 통과 Co-Authored-By: Claude Opus 5 (1M context) From faa113106b624a4e6e40662c30741df20456331a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 05:46:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(B09):=209-7=20=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=E2=80=94=20=ED=91=9C=20=EB=8B=A4=EC=84=AF?= =?UTF-8?q?=C2=B7=ED=99=95=EC=A0=95=20=EB=8C=80=EA=B8=B0=C2=B7=EB=A7=89?= =?UTF-8?q?=ED=9E=8C=20=EA=B2=83=EC=9D=98=20=EB=BF=8C=EB=A6=AC=20=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창이 8장에서 한 것과 같은 모양으로 9장을 정리 - 완료 기록 — 표 다섯이 화면으로 섰음(⑤·③·④·일위대가·자재대). 자원 축 418줄 · 일위대가 203 · 가드 7 + 열 방향 검사 · 시험 209 - 확정 대기를 **㉮ 지금 답할 수 있는 것 / ㉯ 더 알아봐야 하는 것**으로 가르고 항목마다 세 칸(지금 답할 수 있나 · 무엇이 채워지면 풀리나 · 어디까지 찾아봤나) - 「낼 수 있는 것 / 막힌 것」 표 — **막힌 것의 뿌리는 셋뿐** (사급 자재 단가 · 밑수 미확보 · 사용자 입력). 셋 다 우리가 만들 수 없는 것 - 데이터 파일 지문표 + 「지문은 마스터 파일 자체의 것이어야 함」 경위 - 「일하는 법」 — 품셈 표 일곱 모양, 「숫자가 늘어난 것은 좋아진 신호가 아님」, 「크기가 말이 되나」가 네 번 다 잡은 것(셋이 최소값), 「있다와 돈다는 다름」, 「사고가 난 자리에는 반드시 짝 시험」, 계약은 양쪽에서 마주 걸 것, `tmp/tests` 가 git 밖이라 교차검토로 시험 유무를 못 판정하는 한계, 폴더 튐 네 번과 「쓰는 쪽에 경로 검사」 검증: pytest 209 · tsc 0건 · 700줄 초과 0 · ruff 22파일 정렬됨. 화면 전수는 공용 브라우저 로그아웃 상태라 로그인 뒤로 남김 Co-Authored-By: Claude Opus 5 (1M context)