"""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