Files
Aislo/B09_Estimation/B09_Estimation_WorkItemUnit.py
eomsangdonandClaude Opus 5 799e0b1e79 feat(B09): 확정 5차 — 제근 밑수를 건설품셈에서 빌려 옴 (÷1,000 환산 포함)
사용자 확정 5차 6번 「제근 밑수 — 면적 축. 건설품셈 3-9-2 「1,000㎡당」을 빌려 씀」.

- `WorkItemUnit.BORROWED_UNITS` 신설 — **사용자가 정한 자리만** 담음. 우리가 골라 채우는
  표가 아님. 값과 **근거 문구를 한 자리에** 둠(⚠ 교차 참조 표시 — 임도 전용 품이 있는데
  건설품셈을 쓰는 것이라 화면이 그 사실을 밝혀야 함)
- ⚠⚠ `BORROWED_BASIS_PER` — **「1,000㎡당」이라 ÷1,000 환산**. 단위만 「㎡」로 달고 값을
  그대로 두면 **금액이 천 배**가 됨. 단위와 배수를 한 자리에 두고 시험으로 못 박음
- ⇒ 제근 여섯 갈래에 밑수가 붙어 단가가 섬 — 굴착기 0.7㎥·소림 **55.4원/㎡**

⚠ **자릿수가 견준 값과 어긋남을 사유에 함께 적음** — 실무 영월 「뿌리뽑기」는 475원/㎡ 이고,
빌려 온 3-9-2 는 같은 1,000㎡당에 굴착기(0.2㎥) 3.76hr 로 산림품셈 9-21 의 0.80hr 보다
4.7배 큼. **100㎡당으로 보면 실무와 자릿수가 맞음.** 값은 확정대로 두고 **어긋남을 보임** —
지어내지도, 조용히 넘기지도 않음.

검증: pytest 311 통과(신규 4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:22:10 +09:00

224 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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*$")
#: 「단위:」를 빼고 **괄호만** 적는 절이 있다 — 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」.
#: 씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 안 읽히고 있었다(2026-09-08).
#:
#: ⚠ **앞의 「인/」은 품의 단위이고 뒤가 공종 단위다** — 「인/㎡당」은 「㎡당 몇 인」이라
#: 공종 단위가 ㎡ 다. 그래서 그것만 건너뛴다.
#: ⚠ **「㎥/1대, 1일」·「대/ton」 같은 것은 안 받는다** — 그건 소요량이 아니라
#: **시공량**(1대가 하루에 몇 ㎥)이라, 공종 단위로 읽으면 뜻이 뒤집힌다.
#: 쉼표가 들어간 것(「1ha당, 100본당」)도 밑수가 둘이라 안 받는다.
_RE_BARE_UNIT_LINE = re.compile(r"^\(\s*(?:인\s*/\s*)?(?:[\d.,]+\s*)?([^\s/,()]+?)\s*당?\s*\)\s*$")
#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」.
#: ⚠ 등호와 단위 사이에 **수가 끼는 표기**가 있다 — 「A = 77.7 ㎡/시간」(9-17-1 비탈면 다짐).
#: 그 수를 건너뛰지 않으면 그 절이 통째로 안 읽힌다.
_RE_FORMULA_UNIT = re.compile(r"=\s*(?:[\d.,]+\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) or _RE_BARE_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)
#: **다른 품셈에서 빌려 온 밑수** — 산림품셈이 안 적은 자리를 사용자가 정해 준 것.
#:
#: ⚠ **임도 전용 품이 있는데 건설품셈을 쓰는 것**이라 화면이 그 사실을 밝혀야 한다
#: (교차 참조 표시 — 프로젝트 규칙). 그래서 값만 두지 않고 **근거 문구를 함께** 든다.
#: ⚠ 여기 적는 것은 **사용자가 정한 것뿐**이다. 우리가 골라 채우는 자리가 아니다.
#: 빌려 온 밑수가 **몇 단위당**인가 — 「1,000㎡당」이면 1000.
#: ⚠ **이 나눗셈을 빠뜨리면 1,000 배 틀린다.** 단위만 「㎡」로 바꿔 달고 값을 그대로 두면
#: 금액이 천 배로 선다. 그래서 단위와 배수를 **한 자리에** 둔다.
BORROWED_BASIS_PER: dict[str, int] = {"FP-09-21": 1000}
BORROWED_UNITS: dict[str, tuple[str, str]] = {
# 2026-09-09 사용자 확정 5차 6번 — 제근 밑수를 면적 축으로.
"FP-09-21": (
"㎡",
"밑수는 **건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」**에서 빌려 왔습니다"
"(2026-09-09 사용자 확정) — ⚠ **교차 참조**입니다. 산림품셈 9-21 은 표 머리·제목·[주] "
"어디에도 밑수를 안 적었고(앞 절 9-20-1 「10주당」·9-20-2 「단위: 10주당」과 대비), "
"실무도 ㎡(영월)와 ㎥(교본 예시)로 갈려 있었습니다. "
"⚠ 그 표가 **1,000㎡당**이라 **㎡당으로 환산(÷1,000)**해 싣습니다. "
"⚠ **자릿수가 견준 값과 어긋납니다** — 이대로면 굴착기 0.7㎥·소림 기준 55.4원/㎡ "
"(대상 17,873.8㎡ 이면 약 99만원)인데, 실무 영월 「뿌리뽑기」는 475원/㎡ 이고 "
"빌려 온 건설품셈 3-9-2 는 같은 1,000㎡당에 굴착기(0.2㎥) 3.76hr 로 산림품셈 "
"9-21 의 0.80hr 보다 4.7배 큽니다. **100㎡당으로 보면 실무와 자릿수가 맞습니다.** "
"값은 확정대로 두되 이 어긋남을 함께 보입니다 — 다시 볼 자리입니다.",
),
}
def borrowed_basis_per(work_item_code: str) -> int:
"""빌려 온 밑수의 배수 — 「1,000㎡당」이면 1000. 아니면 1."""
plain = str(work_item_code or "").split("#")[0]
for code, per in BORROWED_BASIS_PER.items():
if plain.startswith(code):
return per
return 1
def borrowed_unit_note(work_item_code: str) -> str:
"""빌려 온 밑수라면 그 근거 한 줄. 아니면 빈 문자열."""
plain = str(work_item_code or "").split("#")[0]
for code, (_unit, note) in BORROWED_UNITS.items():
if plain.startswith(code):
return note
return ""
def unit_of(work_item_code: str) -> str | None:
"""그 공종 단가의 기준 단위. **원문에 없으면 `None`** — 짐작으로 채우지 않는다.
⚠ 예외는 **사용자가 다른 품셈에서 빌려 오라고 정한 자리**뿐이다(`BORROWED_UNITS`).
그때도 화면이 **어디서 빌렸는지**를 함께 보인다.
"""
plain = str(work_item_code or "").split("#")[0]
for code, (unit, _note) in BORROWED_UNITS.items():
if plain.startswith(code):
return unit
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]
# ⚠ **상위 절은 스스로 단위를 안 적고 하위 절만 적는 경우가 많다** — 「9-5. 발파암」은
# 빈 제목이고 9-5-1·9-5-2·9-5-3 이 각각 ㎥ 를 밝힌다(암절취 9-4·노체 9-16·덤프운반
# 10-12 도 같다). **하위가 모두 같은 단위일 때만** 그 단위를 상위에 준다 —
# 갈리면 안 준다(한 단위로 뭉뚱그리면 어느 하위가 틀렸는지 못 가린다).
#
# ⚠ **둘 이상이 말할 때만 듣는다.** 하나만 말한 것을 상위에 주면 장(章) 전체가
# 그 하나의 단위가 된다 — 실제로 「13. 돌공사」가 하위 한 절 때문에 **ton** 으로
# 나왔다(2026-09-08). 그래서 ㉠ 절 번호에 하이픈이 있고(장 자체가 아니고)
# ㉡ **말한 하위가 둘 이상**이며 ㉢ 그 값이 하나로 모일 때만 준다.
declared = [
value
for key, value in units.items()
if key.startswith(f"{section}-") and key.count("-") == section.count("-") + 1
]
if "-" in section and len(declared) >= 2 and len(set(declared)) == 1:
return declared[0]
parent = section.rsplit("-", 1)[0]
return units.get(parent) if parent != section and "-" in section else None