refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""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", "master_data", "old", "3_품셈_산림_계수_2026-01-01.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
|
||||
Reference in New Issue
Block a user