feat(B09): 공종 단가 기준 단위를 품셈 원문에서 채움 — 층따기 단위 불일치 드러남

- 일위대가 211개 중 73개가 단위를 못 달고 있었고, 단위가 없으면 내역서의 단위
  불일치 검사가 못 걸림. 층따기가 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고
  있었음 (돌쌓기 「m × ㎡당」과 같은 병).
- 원문에 적힌 자리 둘에서만 읽음 — 절 머리 아래 「(단위: ㎥당)」 · 공식형 [주]의
  「Q= ㎥/시간」. 73개 중 12개를 채웠고 나머지는 미확보로 둠.
- ⚠ 「인 당」·「일 당」은 품의 단위이지 공종 단위가 아니라 받지 않음. 틀린 단위로
  검사를 통과시키면 없느니만 못함.
- 자원 줄이 단위를 주면 그 값이 이김 — 원문 조회는 빈 자리만 채움.
- 결과: 층따기 4,102,708원이 「단위가 안 맞습니다」로 바뀜. 본체 합계
  7,837,891 → 3,735,183원. ⚠ 줄어든 것이 맞음 — B08 이 ㎥ 로 보내야 풀림.

시험 6건 추가, 249건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:09:27 +09:00
co-authored by Claude Opus 5
parent 188bcfb78c
commit 189108e1f7
2 changed files with 145 additions and 0 deletions
@@ -55,6 +55,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import (
load_labor_catalog, load_labor_catalog,
load_work_item_master, load_work_item_master,
) )
from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
@@ -390,6 +391,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
break break
unit = next((r.amount_unit for r in rows if r.amount_unit), "") unit = next((r.amount_unit for r in rows if r.amount_unit), "")
if not unit:
# ⚠ 단위가 없으면 **내역서의 단위 불일치 검사가 못 걸린다** — 층따기가
# 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었다. 품셈 원문에 적힌 것만
# 채우고(「(단위: ㎥당)」·「Q= ㎥/시간」), 없으면 비워 둔다.
unit = work_item_unit(work_item_code) or ""
base_name = names.get(work_item_code) or work_item_code base_name = names.get(work_item_code) or work_item_code
build.book.add_title( build.book.add_title(
PriceTitle( PriceTitle(
@@ -0,0 +1,139 @@
"""B09 — **공종 단가의 기준 단위**를 품셈 원문에서 읽는다 (2026-09-08).
** 있는가** 일위대가 211 **73개가 단위를 달고** 있었다. 단위가 없으면
내역서의 **단위 불일치 검사가 걸린다** 실제로 층따기가 수량 × 단가
**4,102,708** 내고 있었다(돌쌓기가 m × 이던 것과 같은 ).
단위가 적힌 자리는 원문에 ****이다.
머리 아래 ### 13-4-5. 찰쌓기(장비)」 다음의 「(단위: ㎡당)」
공식형 표의 [] Q1=3600×q×K×f×E/= **/시간** (층따기 9-18)
** · 공종 단위가 아니다.** 그것은 **품의 단위**(사람·), 공종
단위로 쓰면 인짜리 공종이라는 뜻이 된다. 그래서 **물리 수량 단위만** 받는다.
**없으면 비워 둔다.** 마스터 `basis_unit` 대개 비어 있고(73 대부분 `None`),
찾은 것을 짐작으로 채우면 **틀린 단위로 검사를 통과**시켜 오히려 나쁘다.
미확보 두면 화면이 기준 단위가 표에 없습니다 드러낸다.
"""
from __future__ import annotations
import os
import re
from functools import lru_cache
#: 산림사업 표준품셈 원문 (FP-* 공종의 출처).
_FOREST_SPEC = (
"resources",
"knowledge",
"original",
"행정규칙",
"임도 품셈 적용기준 (현 산림사업 표준품셈)",
"첨부",
"(산림청고시 제2025-82호) 산림사업 표준품셈.md",
)
#: **공종 수량 단위로 인정하는 것.** 여기 없는 표기(인·일·회·시간)는 품의 단위이지
#: 공종의 단위가 아니다 — 받으면 「몇 인짜리 공종」이 된다.
_QUANTITY_UNITS = frozenset(
{
"",
"",
"m",
"",
"",
"개소",
"",
"",
"ton",
"",
"",
"ha",
"kg",
"",
"",
"",
"",
}
)
_RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$")
#: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다.
_RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[:]\s*(.+?)\s*\)\s*$")
#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」.
_RE_FORMULA_UNIT = re.compile(r"=\s*([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b")
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _clean_unit(text: str) -> str | None:
"""「100㎡당」 → 「㎡」. 공종 단위가 아니면 `None`."""
tight = "".join(str(text).split())
tight = re.sub(r"^\d+(?:,\d{3})*(?:\.\d+)?", "", tight) # 밑수 수량은 뗀다
tight = tight[:-1] if tight.endswith("") else tight
return tight if tight in _QUANTITY_UNITS else None
@lru_cache(maxsize=1)
def section_units() -> dict[str, str]:
"""절 번호 → 공종 단위. **원문에 적힌 것만** 담는다."""
path = os.path.join(_project_root(), *_FOREST_SPEC)
try:
lines = open(path, encoding="utf-8").read().splitlines()
except OSError:
return {}
units: dict[str, str] = {}
section: str | None = None
for raw in lines:
line = raw.strip()
heading = _RE_HEADING.match(line)
if heading:
section = heading.group(1)
# ⚠ 절 제목 자체가 식을 품는 경우가 있다 — 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」.
found = _RE_FORMULA_UNIT.search(heading.group(2))
if found and section not in units:
units[section] = found.group(1)
continue
if section is None or section in units:
continue
unit_line = _RE_UNIT_LINE.match(line)
if unit_line:
cleaned = _clean_unit(unit_line.group(1))
if cleaned:
units[section] = cleaned
continue
found = _RE_FORMULA_UNIT.search(line)
if found:
units[section] = found.group(1)
return units
def section_of(work_item_code: str) -> str:
"""공종 코드 → 절 번호. `FP-13-04-05` → `13-4-5`."""
parts = str(work_item_code or "").split("-")
if not parts or parts[0] != "FP":
return ""
numbers = []
for part in parts[1:]:
if not part.isdigit():
return ""
numbers.append(str(int(part)))
return "-".join(numbers)
def unit_of(work_item_code: str) -> str | None:
"""그 공종 단가의 기준 단위. **원문에 없으면 `None`** — 짐작으로 채우지 않는다."""
section = section_of(work_item_code)
if not section:
return None
units = section_units()
# 하위 절에 안 적혀 있으면 **한 단계 위 절**을 본다 — 「13-4-5」가 없으면 「13-4」.
# ⚠ 두 단계 위까지는 안 올라간다. 장(13) 전체는 공종이 섞여 단위가 하나가 아니다.
if section in units:
return units[section]
parent = section.rsplit("-", 1)[0]
return units.get(parent) if parent != section and "-" in section else None