- 마스터 부모 94곳에 parent_mode — 기본 choose_one, 합산형은 사람이 적은 셋(9-4·9-5·12-38)만 단계·가중치 - 9-4 암절취 = 암파쇄(연암·보통암·경암, 브레이커+굴착기 조합 1/Q) + 집토(굴착기 0.7 공식) 합으로 조립 - 표에 없는 암질은 9-4-1 [주]① 평균으로만 섬 · 9-5 발파암은 발파 단계(착암기 층 없음) 사유로 막힘 - B08 인계가 흙깎기 암 갈래 이름을 갈래 값으로 넘김 · 갈래 고르기형 부모를 가리키는 줄은 오류로 막음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
264 lines
13 KiB
Python
264 lines
13 KiB
Python
"""B09 원가계산 — **품셈이 범위로 준 계수**를 사용자가 고르는 자리 (2026-09-09 확정 ①).
|
||
|
||
품셈이 계수를 **한 값으로 안 주고 범위로 주는 자리**가 있다. 그 자리는 우리가 임의로
|
||
못 정한다 — 그런데 값이 없으면 그 공종은 금액이 통째로 안 선다(흙깎기가 그랬다).
|
||
|
||
9-3-2 흙깎기(기계) E = 0.55∼0.45 ← 지금 유일한 범위 칸
|
||
|
||
**사용자 확정 ①(2026-09-09) — E = 0.50 (두 끝의 평균).** 다만 사용자 지시가 한 줄 더
|
||
붙었다: **「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수
|
||
있게 할 것」.** 그래서 이 파일은 **값을 정하는 곳이 아니라 고를 것을 차리는 곳**이다.
|
||
|
||
고를 수 있는 것은 **원문에 적힌 두 끝과 그 평균 셋뿐**이다 — 그 밖의 수는 만들지 않는다.
|
||
|
||
**왜 평균이 기본인가** (화면이 그대로 보여 준다)
|
||
① 품셈 자신이 같은 두 값을 **다른 절에서 평균으로 쓴다** — 9-13-4 용수토사가
|
||
`(0.55+0.45)/2-0.05`, 9-12-1 토사가 `(0.7+0.6)/2-0.05` 서식이다. 범위 표기와
|
||
평균 표기가 **같은 품셈 안에 섞여 있다.**
|
||
② 9-3-2 [주]③ 이 「사질토+점성토」 **둘 다** 걸라 한다. 건설품셈 8-2-3 작업효율표의
|
||
자연상태·불량 칸이 「모래·사질토 0.55 / 자갈섞인흙·점성토 0.45」라, 그 둘을 다
|
||
걸면 평균이 된다.
|
||
③ 실무 넷 중 **현행 산림품셈 인자(K 0.9 · f 1/1.30 · ㎝ 20)와 전부 맞는 것은 영월**
|
||
하나이고, 영월이 `E = (0.55+0.45)/2 = 0.50` 을 쓴다. 울진 둘은 건설품셈 옛 조항을
|
||
근거로 달아 K·f 까지 다르다(K 0.7·f 1/1.25).
|
||
|
||
⚠ **이 층은 프로젝트마다 갈린다** — 저장은 프로젝트 설정의 `estimation` 구획이다.
|
||
⚠ **범위가 아닌 계수는 여기 오지 않는다.** `(0.7+0.6)/2-0.05` 같은 **식**은 품셈이 이미
|
||
값을 정한 것이라 그대로 계산한다 — 고를 것이 아니다.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
#: 범위 칸 — 「0.55∼0.45」.
|
||
#: ⚠ **가운뎃점이 물결(∼·~·〜)일 때만 범위다.** 그냥 붙임표(`-`)는 **뺄셈**이다 —
|
||
#: 품셈 9-12-3 의 「0.45-0.05」는 「0.45 에서 0.05 를 뺀 0.40」이지 0.45~0.05 범위가
|
||
#: 아니다. 붙임표를 범위로 읽으면 **품셈이 이미 정한 값이 「고를 것」으로 둔갑한다**
|
||
#: (2026-09-09 실측: 세 자리가 그렇게 잡혔다).
|
||
_RANGE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*[∼~〜]\s*(\d+(?:\.\d+)?)\s*$")
|
||
|
||
#: 계수 이름 — 표 첫 칸이 이 중 하나일 때만 본다.
|
||
_FACTOR_HEADS = {
|
||
"k": "K",
|
||
"f": "f",
|
||
"e": "E",
|
||
"cm": "Cm",
|
||
"㎝": "Cm",
|
||
"cm(sec)": "Cm",
|
||
"㎝(sec)": "Cm",
|
||
}
|
||
|
||
#: 고르는 방법 셋. **원문 두 끝과 그 평균뿐** — 다른 수는 만들지 않는다.
|
||
CHOICE_KEYS = ("high", "mid", "low")
|
||
DEFAULT_CHOICE = "mid"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RangeFactor:
|
||
"""품셈이 범위로 준 계수 한 자리."""
|
||
|
||
work_item_code: str
|
||
work_item_name: str
|
||
pum_table_id: str
|
||
factor: str
|
||
low: Decimal
|
||
high: Decimal
|
||
raw_cell: str
|
||
|
||
@property
|
||
def key(self) -> str:
|
||
return f"{self.work_item_code}:{self.factor}"
|
||
|
||
def value_of(self, choice: str) -> Decimal:
|
||
if choice == "high":
|
||
return self.high
|
||
if choice == "low":
|
||
return self.low
|
||
return (self.high + self.low) / Decimal(2)
|
||
|
||
def options(self) -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"key": "high",
|
||
"value": str(self.high),
|
||
"label": f"상한 {self.high}",
|
||
"note": "원문 범위의 큰 쪽 — 모래·사질토 자리",
|
||
},
|
||
{
|
||
"key": "mid",
|
||
"value": str(self.value_of("mid")),
|
||
"label": f"평균 {self.value_of('mid')}",
|
||
"note": "두 끝의 평균 — 품셈 자신이 다른 절에서 쓰는 서식이고 실무(영월)도 이 값",
|
||
},
|
||
{
|
||
"key": "low",
|
||
"value": str(self.low),
|
||
"label": f"하한 {self.low}",
|
||
"note": "원문 범위의 작은 쪽 — 자갈섞인흙·점성토 자리",
|
||
},
|
||
]
|
||
|
||
|
||
def _normalize_head(cell: Any) -> str:
|
||
return str(cell or "").strip().lower().replace(" ", "")
|
||
|
||
|
||
def scan_range_factors(master: dict[str, Any]) -> list[RangeFactor]:
|
||
"""품셈 전체에서 **범위로 적힌 계수 칸**을 모은다.
|
||
|
||
⚠ 공종 코드를 박아 두지 않는다 — 품셈이 개정되면 범위 칸이 늘거나 줄 수 있고,
|
||
코드로 잡으면 새로 생긴 자리를 조용히 놓친다.
|
||
"""
|
||
found: list[RangeFactor] = []
|
||
for node in master.get("work_items", []):
|
||
for table in node.get("tables", []):
|
||
for row in table.get("raw_row") or []:
|
||
cells = [str(cell).strip() for cell in row]
|
||
if not cells:
|
||
continue
|
||
factor = _FACTOR_HEADS.get(_normalize_head(cells[0]))
|
||
if factor is None:
|
||
continue
|
||
for cell in cells[1:]:
|
||
matched = _RANGE.match(str(cell))
|
||
if not matched:
|
||
continue
|
||
first, second = Decimal(matched.group(1)), Decimal(matched.group(2))
|
||
found.append(
|
||
RangeFactor(
|
||
work_item_code=str(node.get("work_item_code", "")),
|
||
work_item_name=str(node.get("name", "")),
|
||
pum_table_id=str(table.get("pum_table_id", "")),
|
||
factor=factor,
|
||
low=min(first, second),
|
||
high=max(first, second),
|
||
raw_cell=str(cell).strip(),
|
||
)
|
||
)
|
||
break
|
||
return found
|
||
|
||
|
||
def chosen_values(
|
||
factors: list[RangeFactor], settings: dict[str, Any] | None = None
|
||
) -> dict[tuple[str, str], Decimal]:
|
||
"""(공종코드, 계수) → 쓸 값. 저장분이 없으면 **평균**이 기본이다."""
|
||
stored = ((settings or {}).get("range_factor_choices") or {}) if settings else {}
|
||
values: dict[tuple[str, str], Decimal] = {}
|
||
for item in factors:
|
||
choice = str(stored.get(item.key) or DEFAULT_CHOICE)
|
||
if choice not in CHOICE_KEYS:
|
||
choice = DEFAULT_CHOICE
|
||
values[(item.work_item_code, item.factor)] = item.value_of(choice)
|
||
return values
|
||
|
||
|
||
#: 화면이 그대로 띄우는 근거. **왜 이 값인지**를 표가 스스로 말해야 한다(PLAN 8-13).
|
||
BASIS_NOTES: dict[str, list[str]] = {
|
||
"FP-09-03-02:E": [
|
||
"산림사업 표준품셈(고시 2025-82) 9-3-2 가 작업효율을 「0.55∼0.45」 **범위**로 줍니다 — "
|
||
"한 값이 아니라 범위라 프로그램이 임의로 정하지 않습니다.",
|
||
"그 두 값의 정체는 건설공사 표준품셈 8-2-3 작업효율표의 **자연상태·불량** 칸입니다 — "
|
||
"「모래·사질토 0.55 / 자갈섞인흙·점성토 0.45」. 9-3-2 [주]③ 이 「사질토+점성토」 둘 다 "
|
||
"걸라 하므로 두 값이 함께 걸립니다.",
|
||
"품셈 자신이 같은 두 값을 다른 절에서는 평균으로 씁니다 — 9-13-4 용수토사 "
|
||
"「(0.55+0.45)/2-0.05」. 범위 표기와 평균 표기가 한 품셈 안에 섞여 있습니다.",
|
||
"실무 넷 중 현행 산림품셈 인자(K 0.9 · f 1/1.30 · ㎝ 20(135°))와 전부 맞는 것은 "
|
||
"영월 하나이고, 영월이 「E=(0.55+0.45)/2=0.50」 을 씁니다. 울진 둘은 건설품셈 옛 "
|
||
"조항(11-3 · 8-2-3)을 근거로 달아 K 0.7·f 1/1.25 까지 다릅니다.",
|
||
"⚠ 이 한 칸이 흙깎기 단가를 좌우합니다 — 0.45 면 약 659만원, 0.50 이면 약 593만원, "
|
||
"0.55 면 약 539만원(수량 2,355.84㎥ 기준).",
|
||
]
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 장비 규격 — 사용자 확정 ①에 딸려 온 지시(2026-09-09)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# ⚠ **두 종류가 섞여 있다. 섞어 다루면 안 된다.**
|
||
# ㉠ **표에 장비가 없는 자리** — 9-3-2 흙깎기가 그렇다. 장비는 [주]① 「장비는 무한궤도
|
||
# 굴착기(0.7㎥)를 적용한다」에 있는데 **마스터가 [주] 를 아직 안 싣는다.** 그래서
|
||
# 공식이 다 있어도 기종을 못 골라 금액이 통째로 안 섰다.
|
||
# ㉡ **표에 장비가 있는 자리** — 9-18 층따기는 표머리가 「굴착기 (무한궤도, 0.7㎥)」다.
|
||
# **원문이 정한 값**이라 기본은 그대로 두되, 실무가 다른 규격을 쓰는 것이 확인돼
|
||
# (영월 BACK-HOE 0.2㎥) 사용자가 바꿀 수 있어야 한다.
|
||
#
|
||
# ⚠ **㉠ 은 마스터가 [주] 를 실으면 이 표에서 지운다** — 두 곳에 같은 값을 두면 나중에
|
||
# 한쪽만 고쳐진다. 그때까지만 여기서 든다.
|
||
|
||
#: 기종 코드 → 화면에 보일 이름. 카탈로그가 정본이고 여기는 고르는 목록일 뿐이다.
|
||
MACHINE_OPTION_CODES = ("0201-0020", "0201-0070")
|
||
|
||
MACHINE_CHOICES: dict[str, dict[str, Any]] = {
|
||
"FP-09-03-02": {
|
||
"work_item_name": "흙깎기(기계)",
|
||
"default_code": "0201-0070",
|
||
"source": "note",
|
||
"basis": [
|
||
"산림사업 표준품셈 9-3-2 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
|
||
"표가 아니라 [주] 에 있어 공종 마스터가 아직 못 싣는 값입니다.",
|
||
"[주]⑤ 가 그 까닭도 적습니다 — 「소규모공사(10,000㎥ 미만, 0.4㎥ 적용)이나 "
|
||
"암절취 깎기를 고려하여 0.7㎥ 적용한다」.",
|
||
],
|
||
},
|
||
"FP-09-16-01": {
|
||
"work_item_name": "노체포설",
|
||
"default_code": "0201-0070",
|
||
"source": "note",
|
||
"basis": [
|
||
"산림사업 표준품셈 9-16-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
|
||
"흙깎기(9-3-2)와 같이 표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.",
|
||
"⚠ 이 칸이 비어 있던 동안 **노체포설 단가가 아예 안 서고 있었습니다** — "
|
||
"계수표(K·f·E·㎝)는 있는데 기종을 못 골라 시공능력 공식이 안 돌았습니다 "
|
||
"(2026-09-09 밤에 드러남).",
|
||
],
|
||
},
|
||
# 암절취·발파암의 집토 — 흙깎기와 같이 기종이 [주] 에만 있다(2026-09-13 축 C Ⓐ, 단계 합산형 부모의 잎).
|
||
"FP-09-04-02": {
|
||
"work_item_name": "암절취 집토",
|
||
"default_code": "0201-0070",
|
||
"source": "note",
|
||
"basis": [
|
||
"산림사업 표준품셈 9-4-2 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
|
||
"표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.",
|
||
],
|
||
},
|
||
"FP-09-05-03": {
|
||
"work_item_name": "발파암 집토",
|
||
"default_code": "0201-0070",
|
||
"source": "note",
|
||
"basis": [
|
||
"산림사업 표준품셈 9-5-3 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
|
||
"표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.",
|
||
],
|
||
},
|
||
"FP-09-18": {
|
||
"work_item_name": "층따기",
|
||
"default_code": "0201-0070",
|
||
"source": "table",
|
||
"basis": [
|
||
"산림사업 표준품셈 9-18 표머리가 「굴착기 (무한궤도, 0.7㎥)」로 장비를 정합니다 — "
|
||
"원문이 정한 값이라 기본은 이것입니다.",
|
||
"⚠ 실무는 더 작은 장비를 씁니다 — 영월 산출근거가 「층따기 BACK-HOE 0.2㎥ · "
|
||
"㎝ 20 sec(180°) · E 0.7」입니다. 현장이 좁은 자리라 실무가 달리 잡은 것으로 "
|
||
"보이며, 바꾸면 시간당 작업량이 줄어 단가가 오릅니다.",
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def machine_choices(settings: dict[str, Any] | None = None) -> dict[str, str]:
|
||
"""공종코드 → 쓸 기종 코드. 저장분이 없으면 위 표의 기본값(원문 값)이다."""
|
||
stored = ((settings or {}).get("machine_choices") or {}) if settings else {}
|
||
picked: dict[str, str] = {}
|
||
for code, entry in MACHINE_CHOICES.items():
|
||
chosen = str(stored.get(code) or entry["default_code"])
|
||
picked[code] = chosen if chosen in MACHINE_OPTION_CODES else str(entry["default_code"])
|
||
return picked
|