브레인 판정(코덱스 PDF 확인). ⚠ **사용자가 확정해 둔 값을 뒤집는 것**이라 기록을 남김. 근거 — PDF 인쇄 142면 9-21 제근 표 **우측 상단에 「(단위: 100㎡당)」** 이 있음(표 머리의 독립 단위줄). 제목·표·[주]가 한 면에 다 있어 쪽넘김·다른 표 혼입이 없고 1,000㎡ 로 읽힐 여지가 없음. 이웃도 1,000㎡ 꼴이 아님(9-20-1·2 10주당 · 9-19 ㎡당 · 9-22 공식). ⇒ 빌려 온 까닭이 「산림 원문에 밑수가 없어서」였는데 **원문에 있었음** — 산림 우선(고시 총칙 7). **금액 영향 — 열 배 오름** 제근 호표(굴착기 0.7㎥·소림) **55원/㎡ → 555원/㎡** 대상 17,873.8㎡ 기준이면 약 99만원 → 약 992만원 ⭐ 코드에 진작 적혀 있던 「100㎡당으로 보면 실무와 자릿수가 맞다」가 맞았음 — 실무 영월 「뿌리뽑기」 475원/㎡ 과 자릿수가 붙음(1,000㎡당일 때는 열 배 어긋나 있었음) - `BORROWED_BASIS_PER` 1000 → 100 · **옛 값을 `BORROWED_BASIS_PER_WAS` 로 함께 남김** - 화면에 뜨는 근거 문구도 고침(옛 값·옛 금액·바뀐 금액을 다 적음) - 시험에 못박음 — 호표 값 540~570원/㎡(앞서 50~60) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
237 lines
12 KiB
Python
237 lines
12 KiB
Python
"""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 배 틀린다.** 단위만 「㎡」로 바꿔 달고 값을 그대로 두면
|
||
#: 금액이 천 배로 선다. 그래서 단위와 배수를 **한 자리에** 둔다.
|
||
#: ⚠⚠ 2026-09-18 **1000 → 100** — 사용자 확정(1,000㎡당)을 **원문으로 물렸다.**
|
||
#: 코덱스 PDF 확인: 인쇄 142면 9-21 제근 표 **우측 상단에 「(단위: 100㎡당)」** 이 있다.
|
||
#: 제목·표·[주]가 한 면에 다 있어 쪽넘김·다른 표 혼입이 없고 1,000㎡ 로 읽힐 여지가 없다.
|
||
#: 이웃도 1,000㎡ 꼴이 아니다(9-20-1·2 10주당 · 9-19 ㎡당 · 9-22 공식).
|
||
#: ⇒ 빌려 온 까닭이 「산림 원문에 밑수가 없어서」였는데 **원문에 있었다** — 산림 우선(고시 총칙 7).
|
||
#: ⚠ **금액이 열 배 오른다** — 굴착기 0.7㎥·소림 기준 55원/㎡ → 554원/㎡.
|
||
#: 아래 문구가 진작 적어 둔 「100㎡당으로 보면 실무와 자릿수가 맞다」가 맞았다
|
||
#: (실무 영월 「뿌리뽑기」 475원/㎡ 과 자릿수가 붙는다).
|
||
#: 옛 값 — 2026-09-09 사용자 확정 5차 6번 「1,000㎡당」(건설 3-9-2 교차 참조) · 되짚을 때 볼 것.
|
||
BORROWED_BASIS_PER_WAS: dict[str, int] = {"FP-09-21": 1000}
|
||
BORROWED_BASIS_PER: dict[str, int] = {"FP-09-21": 100}
|
||
|
||
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주당」과 대비), "
|
||
"실무도 ㎡(영월)와 ㎥(교본 예시)로 갈려 있었습니다. "
|
||
"⚠ 2026-09-18 **원문으로 물렸습니다** — 산림품셈 9-21 표 우측 상단에 「(단위: 100㎡당)」이 "
|
||
"있습니다(코덱스 PDF 인쇄 142면 확인). 그래서 **㎡당으로 환산(÷100)**해 싣습니다. "
|
||
"옛 값은 사용자 확정 「1,000㎡당」(건설 3-9-2 교차 참조 · 2026-09-09)이었고 그때는 "
|
||
"55원/㎡ 였습니다 — 바꾼 뒤 554원/㎡ 로 **열 배** 오릅니다. "
|
||
"⚠ **자릿수가 견준 값과 어긋납니다** — 이대로면 굴착기 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
|