Files
Aislo/B09_Estimation/B09_Estimation_LaborSurcharge.py
T
eomsangdonandClaude Opus 5 8dddc76839 feat(b09): 672 품 할증 여럿 고르면 합산 — 근거를 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 로(교차 참조)
· 산림품셈 1-4 에 겹침 규정이 없어 건설 공통 1-4-2 「할증의 중복가산요령」 을 따름 — 「우리가 둔 합산 · 확정 대기」 문구를 원문 근거로 바꿈
· 같은 조 단서 「동일성격의 품할증요소의 이중적용은 불가」 — 어느 계열끼리 동일성격인지 원문이 안 정해 막지 않고 화면 근거에 단서로 보임
· 셈법(합산) 자체는 그대로라 금액 변화 없음

잴 시험 둘 빨강→초록 · 전체 시험 1695 + 332 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 22:55:45 +09:00

189 lines
8.2 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 원가계산 — **품의 할인·할증 26계열** (산림품셈 1-4).
**무엇이 빠져 있었나** — 품셈 1-4 가 작업시기·경사도·이동거리 등 **26개 표**로 품을 할인·할증
하는데 한 계열도 안 붙고 있었다(자재 할증만 있었다).
**어디에 붙나 — 품(인력) 줄이다.**
원문 제목이 「1-4. **품**의 할인·할증」이고, 1-6 라 표가 직접노무비를 산림품셈으로 낸다.
⚠ **B08 물량에 곱하면 안 된다** — 자재·기계까지 함께 부풀어 그것이 곧 이중계상이다.
**⚠ 우리가 안 정하는 것 둘 — 원문이 안 정해 사용자에게 남긴다**
㉠ **어느 공종에 붙나** — 26계열의 [주]가 거의 다 조림·숲가꾸기·방제 전용을 지목한다
(1-4-1 「어린나무가꾸기에 한하여」 · 1-4-2 「줄베기」 · 1-4-9 「숲가꾸기 및 병해충방제
작업로」…). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
각 계열의 **[주] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
㉡ **여럿을 고를 때 합산인가 곱인가** — 산림품셈 1-4 에는 없고 **건설 공통 1-4-2 「할증의
중복가산요령」** 이 정함(교차 참조 · 2026-09-14 브레인 672): 「W = 기본품 × (1 + a1 + … + an)
· 단, 동일성격의 품할증요소의 이중적용은 불가」 → **합산**. 「동일성격」이 어느 계열끼리인지는
원문이 안 정해 막지 않고 단서를 화면에 보임(설계자가 가림).
**기본은 「안 고름」** — 한 계열도 안 고르면 금액이 한 원도 안 움직인다.
⚠ **26표 가운데 율 표는 24개**다. 둘은 성격이 달라 여기 안 담는다(버린 것이 아니라 다른 것).
1-4-24 방제장비 규격 — 「**장비품의** -20%」라 인력 품이 아니고 표기도 율이 아니다.
1-4-26 소규모 작업물량 제한 — 율이 아니라 **적용시공량 규칙**(`Q = B/2`)이다.
"""
from __future__ import annotations
import os
import re
from decimal import Decimal
from functools import lru_cache
from typing import Any
_DATASET = ("resources", "data_cost_input_value", "coef_2026.json")
_FOREST_SPEC = (
"resources",
"knowledge",
"original",
"행정규칙",
"임도 품셈 적용기준 (현 산림사업 표준품셈)",
"첨부",
"(산림청고시 제2025-82호) 산림사업 표준품셈.md",
)
#: 표 아래 [주] 를 몇 줄까지 읽나. [주] 는 표 바로 밑에 붙는다.
_NOTE_LOOKAHEAD = 12
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
#: 여럿을 고를 때 셈법 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 합산(교차 참조 · 672).
#: **여기 한 곳**이 정한다(코드 깊이 박지 않는다).
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 1 = 15.5%
COMBINE_RULE = "sum"
COMBINE_NOTE = (
"여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
"(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
)
SEAT_NOTE = (
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
" 이중계상이 됩니다(산림품셈 1-4 「품의 할인·할증」·1-6 라)."
)
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(*parts: str) -> Any:
import json
with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle:
return json.load(handle)
@lru_cache(maxsize=1)
def _source_lines() -> tuple[str, ...]:
path = os.path.join(_project_root(), *_FOREST_SPEC)
if not os.path.isfile(path):
return ()
with open(path, encoding="utf-8") as handle:
return tuple(handle.read().splitlines())
def _note_of(line_no: int) -> str:
"""표 바로 아래 [주] 원문. 없으면 빈 문자열 — **지어내지 않는다.**"""
lines = _source_lines()
if not lines or line_no <= 0:
return ""
picked: list[str] = []
for index in range(line_no, min(len(lines), line_no + _NOTE_LOOKAHEAD)):
text = lines[index].strip()
if text.startswith("###"):
break
if text.startswith("[주]") or (picked and text.startswith("-")):
picked.append(text)
return " ".join(picked)
def _percent_of(cells: list[str]) -> Decimal | None:
"""행에서 율을 읽는다. **`%` 가 붙은 칸만** 율로 본다(경계값·면적과 헷갈리지 않게)."""
for cell in reversed(cells):
text = str(cell or "").strip()
if _RE_PERCENT.match(text):
return Decimal(text.rstrip("%"))
return None
@lru_cache(maxsize=1)
def load_series() -> tuple[dict[str, Any], ...]:
"""26계열 — 원문 행을 **그대로** 선택지로 낸다. 값을 만들지 않는다."""
tables = _read_json(*_DATASET)["variables"]["surcharge_labor"]["tables"]
series: list[dict[str, Any]] = []
seen: set[str] = set()
for table in tables:
section = str(table.get("section") or "").strip()
found = _RE_SECTION.match(section)
if not found:
continue
key, title = found.group(1), found.group(2).strip()
# ⚠ 원문에 **같은 번호가 두 번** 나오는 자리가 있다(1-4-18). 뒤엣것을 버리지 않고
# 번호에 꼬리를 달아 둘 다 보인다 — 원문에 있는 표를 우리가 지우지 않는다.
if key in seen:
key = f"{key}b"
seen.add(key)
options: list[dict[str, Any]] = []
for row in table.get("rows") or []:
cells = [str(c or "").strip() for c in row]
percent = _percent_of(cells)
if percent is None:
continue
label = " · ".join(c for c in cells if c and not _RE_PERCENT.match(c))
options.append(
{"key": f"{key}:{len(options)}", "label": label or "(원문 행)", "percent": percent}
)
if not options:
continue
series.append(
{
"key": key,
"title": title,
"section": section,
"headers": list(table.get("headers") or []),
"options": options,
"source_note": _note_of(int(table.get("line") or 0)),
}
)
return tuple(series)
def _option_map() -> dict[str, tuple[dict[str, Any], dict[str, Any]]]:
return {option["key"]: (item, option) for item in load_series() for option in item["options"]}
def parse_choices(raw: Any) -> dict[str, str]:
"""설정에 저장된 선택 — **원문에 있는 선택지만** 받는다."""
known = _option_map()
picked: dict[str, str] = {}
for series_key, option_key in dict(raw or {}).items():
option = known.get(str(option_key))
if option is not None and option[0]["key"] == str(series_key):
picked[str(series_key)] = str(option_key)
return picked
def total_percent(choices: dict[str, str]) -> tuple[Decimal, list[str]]:
"""(합계 %, 줄에 남길 근거들). 아무것도 안 고르면 `(0, [])` — 줄이 안 선다.
⚠ 셈하는 법은 `COMBINE_RULE` **한 곳**이 정한다 — 원문이 안 정한 자리라 갈아 끼울 수
있어야 한다.
"""
known = _option_map()
picked: list[tuple[dict[str, Any], dict[str, Any]]] = []
for option_key in choices.values():
found = known.get(str(option_key))
if found is not None:
picked.append(found)
reasons = [
f"{item['section']} {option['label']} {option['percent']:g}%" for item, option in picked
]
if COMBINE_RULE == "product":
factor = Decimal(1)
for _item, option in picked:
factor *= Decimal(1) + option["percent"] / Decimal(100)
return (factor - Decimal(1)) * Decimal(100), reasons
return sum((option["percent"] for _item, option in picked), Decimal(0)), reasons