Files
Aislo/B09_Estimation/B09_Estimation_LaborSurcharge.py
T
eomsangdonandClaude Opus 5 f6f51f33f5 feat(B09): 품의 할인·할증 26계열 칸 — 고른 것만 품에 붙음
산림품셈 1-4 가 26표로 품을 할인·할증하는데 한 계열도 안 붙고 있었음(자재 할증만 있었음).

- 붙는 자리 = 일위대가의 품(인력) 줄. 물량에 곱하면 자재·기계까지 부풀어 이중계상임
  (원문 제목이 「품의 할인·할증」이고 1-6 라 표가 직접노무비를 품셈으로 냄).
- 기본은 「안 고름」 — 한 계열도 안 고르면 금액이 한 원도 안 움직임.
- 각 계열에 [주] 원문을 그대로 띄움 — 26계열의 [주] 가 대개 조림·숲가꾸기·방제를
  지목하고 임도 토공에 붙이라는 지시가 원문에 없어, 켜는 것은 사용자 판단임.
- 여럿 고를 때 합산인지 곱인지 원문에 없음 — 합산으로 두되 한 곳(COMBINE_RULE)에서
  갈아 끼울 수 있게 하고 그 사실을 화면 근거에 적음.
- 품이 늘면 제잡비 밑수도 함께 늘도록 줄 차례를 지킴(할증 줄이 제잡비보다 먼저).
- ⚠ 율 표는 24개임 — 1-4-24(장비품의 -20%)·1-4-26(적용시공량 규칙)은 성격이 달라 뺌.

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

186 lines
7.8 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 「숲가꾸기 및 병해충방제
작업로」…). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
각 계열의 **[주] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
㉡ **여럿을 고를 때 합산인가 곱인가** — 원문에 없다. 지금은 **합산**으로 두고 그 사실을
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
**기본은 「안 고름」** — 한 계열도 안 고르면 금액이 한 원도 안 움직인다.
⚠ **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*(.+)$")
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 1 = 15.5%
COMBINE_RULE = "sum"
COMBINE_NOTE = (
"⚠ 여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
" (사용자 확정 대기)."
)
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