"""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 밤에 드러남).", ], }, "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