Files
Aislo/B09_Estimation/B09_Estimation_WorkItemUnit.py
T
eomsangdonandClaude Opus 5 5063bd376b feat(B09): 「단위:」 없이 괄호만 적는 절도 읽음 — 단위 빈 일위대가 61 → 27
- 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」처럼 「단위:」를 뺀 절이 있음.
  씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 미확보였음(메인이 물은 아홉 중 하나).
- 「인/」 앞머리는 품의 단위라 건너뜀 — 「인/㎡당」은 「㎡당 몇 인」이라 공종 단위가 ㎡.
- ⚠ 「㎥/1대, 1일」·「대/ton」은 안 받음 — 그건 시공량이지 소요량이 아니라
  공종 단위로 읽으면 뜻이 뒤집힘(역수). 밑수가 둘인 「1ha당, 100본당」도 안 받음.
- 절 단위 128 → 185, 단위 빈 일위대가 61 → 27. 내역 금액 변동 없음.

시험 2건 추가(받을 것·막을 것 짝), 254건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 14:15:33 +09:00

169 lines
7.3 KiB
Python
Raw 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)
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]
# ⚠ **상위 절은 스스로 단위를 안 적고 하위 절만 적는 경우가 많다** — 「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