Files
Aislo/B09_Estimation/B09_Estimation_FactorChoices.py
eomsangdonandClaude Opus 5 4a6254ba98 fix(B09): 성토·성토면다짐에 단가가 서게 함 — 금액 없이 내역에 서 있던 두 줄
물량은 인계로 오는데 단가가 없어 성토 13,150.2㎥·성토면다짐 12,350.3㎡ 가 금액 없이
서 있었음. 원문에는 값이 다 있고 막힌 까닭만 서로 달랐음.

- 노체포설(9-16-1): 기종이 [주]에만 있어 못 골랐음 → 흙깎기와 같은 장비 칸을 둠
  (무한궤도 굴착기 0.7㎥, [주]① 그대로. 화면에서 바꿀 수 있음).
- 비탈면 다짐(9-17-1): 표가 한 장도 없고 값이 절 제목에 있음(A = 77.7 ㎡/시간)
  → 제목·[주]에만 있는 작업량을 싣는 자리를 두고 원문 문구를 줄에 남김.
- 부착 콤팩터(0240-0007)는 카탈로그에 이름이 비어 있어 손료가 안 붙고 있었음 —
  이름 대신 분류번호로도 가림(조합 사용 넷은 그대로, 목록을 넓히지 않음).

금액: 성토 59,477,858원 · 성토면다짐 17,827,251원 (합 77,305,109원)이 새로 섬.

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

245 lines
12 KiB
Python
Raw Permalink 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 원가계산 — **품셈이 범위로 준 계수**를 사용자가 고르는 자리 (2026-09-09 확정 ①).
품셈이 계수를 **한 값으로 안 주고 범위로 주는 자리**가 있다. 그 자리는 우리가 임의로
못 정한다 — 그런데 값이 없으면 그 공종은 금액이 통째로 안 선다(흙깎기가 그랬다).
9-3-2 흙깎기(기계) E = 0.550.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.550.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.550.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 밤에 드러남).",
],
},
"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