Files
Aislo/B09_Estimation/B09_Estimation_FactorChoices.py
T
eomsangdonandClaude Opus 5 5780eb9359 feat(B09): 흙깎기가 처음으로 금액이 섬 — 범위 계수·장비 규격을 고르는 칸 (확정 ①)
사용자 확정 ① — 작업효율 0.50(두 끝의 평균). 딸림 지시 「값을 코드에 박고 끝내지 말 것 ·
화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」를 그대로 구현.

막혔던 자리 둘
- 품셈 9-3-2 가 작업효율을 「0.55∼0.45」 범위로 줌 → 확정값이 아니라 계수가 안 섬
- 그 표에 장비가 없음 → [주]① 「무한궤도 굴착기(0.7㎥)」가 마스터에 안 실려 기종을 못 고름
⇒ 두 자리를 채워 흙깎기 단가가 처음으로 섬: 2,515.1원/㎥ × 2,355.84㎥ ≒ 592만원

구현
- `B09_Estimation_FactorChoices` 신설 — 범위 칸을 품셈에서 훑어 모으고(코드 안 박음),
  고를 수 있는 것은 원문 두 끝과 그 평균 셋뿐. 기본은 평균
- 장비 규격도 같은 결로: 흙깎기는 [주] 에만 있는 값을 채우는 칸, 층따기는 원문 0.7㎥ 를
  기본으로 두고 실무(영월 0.2㎥)로 바꿀 수 있는 칸
- 고른 값은 프로젝트 설정 `estimation` 구획에 저장 — 프로젝트마다 갈림
- `cached_build` 를 고른 값별로 캐시 (전역 한 벌이면 한 프로젝트가 남의 금액을 흔듦)
- `GET/PUT /{project_id}/estimation/factors` · 기초자료 탭 맨 위에 칸과 근거 표시

⚠ 짓다 잡은 것 — 「0.45-0.05」를 범위로 잘못 읽고 있었음. 그건 뺄셈(=0.40)이라
품셈이 이미 정한 값인데 「고를 것」으로 둔갑했음. 물결(∼) 일 때만 범위로 봄

검증: pytest 279 통과(신규 7) · tsc 통과 · 평균 2,515.1 / 하한 2,794.6 으로
고른 값이 단가에 실제로 닿는 것 확인
⚠ 화면 확인은 못 함 — 내 창 브라우저 세션이 만료됐고 자격 파일이 이 폴더에 없음

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

233 lines
11 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 원가계산 — **품셈이 범위로 준 계수**를 사용자가 고르는 자리 (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-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