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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""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-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
|
||||
@@ -196,11 +196,18 @@ def _capacity_token(inside: str) -> str:
|
||||
def extract_cycle_factors(
|
||||
work_item_code: str,
|
||||
table: dict[str, Any],
|
||||
choices: dict[tuple[str, str], Decimal] | None = None,
|
||||
machines: dict[str, str] | None = None,
|
||||
) -> CycleFactors | FactorGap | None:
|
||||
"""표 하나에서 계수를 뽑는다.
|
||||
|
||||
공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면
|
||||
`FactorGap`, 다 있으면 `CycleFactors`.
|
||||
|
||||
⚠ `choices` — 품셈이 **범위로 준 계수**(9-3-2 의 `E = 0.55∼0.45`)에 사용자가 고른 값을
|
||||
끼워 넣는다. **범위가 아닌 칸은 절대 안 덮는다** — 품셈이 값을 정한 자리를 사용자
|
||||
설정이 밀어내면 그것이 곧 임의 수치다. 고를 수 있는 것도 원문 두 끝과 그 평균뿐이다
|
||||
(`B09_Estimation_FactorChoices`).
|
||||
"""
|
||||
rows = table.get("raw_row") or []
|
||||
values: dict[str, Decimal] = {}
|
||||
@@ -255,8 +262,29 @@ def extract_cycle_factors(
|
||||
if not saw_key and machine is None:
|
||||
return None
|
||||
|
||||
# 범위 칸이라 못 읽은 자리에만 **고른 값**을 끼운다 — 읽힌 칸은 손대지 않는다.
|
||||
for key in ("K", "f", "E", "Cm"):
|
||||
if values.get(key) is None and choices:
|
||||
picked = choices.get((work_item_code, key))
|
||||
if picked is not None:
|
||||
values[key] = picked
|
||||
saw_key = True
|
||||
|
||||
missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None]
|
||||
capacity = bucket_from_machine_row
|
||||
|
||||
# 고른 기종이 있으면 그것을 쓴다. 표가 장비를 말한 자리(층따기)에서는 **바꾸는 것**이고,
|
||||
# 표에 장비가 없는 자리(흙깎기)에서는 [주] 에만 있는 값을 **채우는 것**이다.
|
||||
# 어느 쪽이든 화면이 근거와 함께 보이고 사용자가 되돌릴 수 있다(확정 ① 딸림 지시).
|
||||
picked_code = (machines or {}).get(work_item_code)
|
||||
if picked_code:
|
||||
chosen = load_machine_catalog().machines.get(picked_code)
|
||||
if chosen is not None:
|
||||
machine = (picked_code, chosen.name)
|
||||
spec = parse_measure(chosen.specification)
|
||||
if spec is not None:
|
||||
capacity = spec
|
||||
|
||||
if machine is None:
|
||||
missing.append("기계")
|
||||
if capacity is None:
|
||||
@@ -308,6 +336,8 @@ def attach_machine_share(
|
||||
master: dict[str, Any],
|
||||
work_item_code: str,
|
||||
title_code: str,
|
||||
choices: dict[tuple[str, str], Decimal] | None = None,
|
||||
machines: dict[str, str] | None = None,
|
||||
) -> Decimal:
|
||||
"""시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
|
||||
|
||||
@@ -327,7 +357,7 @@ def attach_machine_share(
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||
|
||||
for table in node.get("tables", []):
|
||||
factors = extract_cycle_factors(work_item_code, table)
|
||||
factors = extract_cycle_factors(work_item_code, table, choices, machines)
|
||||
if not isinstance(factors, CycleFactors):
|
||||
if isinstance(factors, FactorGap):
|
||||
factor_gaps[work_item_code] = factors
|
||||
|
||||
@@ -201,7 +201,7 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
||||
알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬).
|
||||
"""
|
||||
try:
|
||||
build = cached_build()
|
||||
build = await _build_for(project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
@@ -217,6 +217,39 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def _project_root_of(project_id: UUID) -> str | None:
|
||||
"""프로젝트 저장 폴더. 못 찾으면 `None` — 그때는 확정 기본값으로 돈다."""
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
|
||||
try:
|
||||
stored = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
return str(resolve_stored_project_path(stored))
|
||||
except Exception:
|
||||
logger.warning("B09 프로젝트 폴더를 못 찾았습니다 — 기본값으로 돕니다: %s", project_id)
|
||||
return None
|
||||
|
||||
|
||||
async def _build_for(project_id: UUID):
|
||||
"""그 프로젝트가 **고른 값**으로 조립한 일위대가.
|
||||
|
||||
⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로
|
||||
돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
|
||||
"""
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
ranges = tuple(
|
||||
sorted((str(k), str(v)) for k, v in (settings.get("range_factor_choices") or {}).items())
|
||||
)
|
||||
machines = tuple(
|
||||
sorted((str(k), str(v)) for k, v in (settings.get("machine_choices") or {}).items())
|
||||
)
|
||||
return cached_build(ranges, machines)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/base-data")
|
||||
async def get_base_data_lists(project_id: UUID) -> JSONResponse:
|
||||
"""**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번).
|
||||
@@ -227,7 +260,9 @@ async def get_base_data_lists(project_id: UUID) -> JSONResponse:
|
||||
from B09_Estimation.B09_Estimation_Lists import all_lists
|
||||
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **all_lists(cached_build())})
|
||||
return JSONResponse(
|
||||
content={"status": "success", **all_lists(await _build_for(project_id))}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
@@ -249,7 +284,7 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
build = cached_build()
|
||||
build = await _build_for(project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
@@ -265,11 +300,146 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/factors")
|
||||
async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
||||
"""**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시).
|
||||
|
||||
「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
|
||||
라는 지시대로, **지금 값 · 고를 수 있는 것 · 왜 그 값인지**를 함께 낸다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||
BASIS_NOTES,
|
||||
DEFAULT_CHOICE,
|
||||
MACHINE_CHOICES,
|
||||
MACHINE_OPTION_CODES,
|
||||
machine_choices,
|
||||
scan_range_factors,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import load_work_item_master
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
try:
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
stored = settings.get("range_factor_choices") or {}
|
||||
|
||||
ranges = []
|
||||
for item in scan_range_factors(load_work_item_master()):
|
||||
choice = str(stored.get(item.key) or DEFAULT_CHOICE)
|
||||
ranges.append(
|
||||
{
|
||||
"key": item.key,
|
||||
"work_item_code": item.work_item_code,
|
||||
"work_item_name": item.work_item_name,
|
||||
"factor": item.factor,
|
||||
"raw_cell": item.raw_cell,
|
||||
"chosen": choice,
|
||||
"value": str(item.value_of(choice)),
|
||||
"is_default": choice == DEFAULT_CHOICE,
|
||||
"options": item.options(),
|
||||
"basis": BASIS_NOTES.get(item.key, []),
|
||||
}
|
||||
)
|
||||
|
||||
catalog = load_machine_catalog()
|
||||
picked = machine_choices(settings)
|
||||
machines = []
|
||||
for code, entry in MACHINE_CHOICES.items():
|
||||
options = []
|
||||
for machine_code in MACHINE_OPTION_CODES:
|
||||
machine = catalog.machines.get(machine_code)
|
||||
if machine is None:
|
||||
continue
|
||||
options.append(
|
||||
{
|
||||
"key": machine_code,
|
||||
"label": f"{machine.name} {machine.specification}".strip(),
|
||||
}
|
||||
)
|
||||
machines.append(
|
||||
{
|
||||
"work_item_code": code,
|
||||
"work_item_name": entry["work_item_name"],
|
||||
"chosen": picked.get(code, entry["default_code"]),
|
||||
"default": entry["default_code"],
|
||||
"is_default": picked.get(code) == entry["default_code"],
|
||||
"source": entry["source"],
|
||||
"options": options,
|
||||
"basis": entry["basis"],
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"ranges": ranges,
|
||||
"machines": machines,
|
||||
"notes": [
|
||||
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
|
||||
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
|
||||
],
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 산출 조건 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "산출 조건을 못 불러왔습니다."},
|
||||
)
|
||||
|
||||
|
||||
class FactorChoiceBody(BaseModel):
|
||||
"""고른 값 — 안 보낸 칸은 그대로 둔다."""
|
||||
|
||||
range_factor_choices: dict[str, str] | None = None
|
||||
machine_choices: dict[str, str] | None = None
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation/factors")
|
||||
async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONResponse:
|
||||
"""산출 조건을 이 프로젝트에 저장한다. **다른 구획은 손대지 않는다.**"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES
|
||||
from common_util.common_util_project_settings import save_section
|
||||
|
||||
root = await _project_root_of(project_id)
|
||||
if root is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||
)
|
||||
values: dict[str, Any] = {}
|
||||
if body.range_factor_choices is not None:
|
||||
# ⚠ 모르는 값은 안 받는다 — 원문에 없는 수가 설정으로 들어오면 그것이 임의 수치다.
|
||||
values["range_factor_choices"] = {
|
||||
str(key): str(value)
|
||||
for key, value in body.range_factor_choices.items()
|
||||
if str(value) in CHOICE_KEYS
|
||||
}
|
||||
if body.machine_choices is not None:
|
||||
values["machine_choices"] = {
|
||||
str(key): str(value)
|
||||
for key, value in body.machine_choices.items()
|
||||
if str(value) in MACHINE_OPTION_CODES
|
||||
}
|
||||
try:
|
||||
save_section(root, "estimation", values, replace_keys=tuple(values))
|
||||
return JSONResponse(content={"status": "success", **values})
|
||||
except Exception:
|
||||
logger.exception("B09 산출 조건 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/unit-prices/{code}")
|
||||
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
||||
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)})
|
||||
return JSONResponse(
|
||||
content={"status": "success", **detail_of(await _build_for(project_id), code)}
|
||||
)
|
||||
except PriceBookError as error:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
|
||||
except Exception:
|
||||
|
||||
@@ -446,3 +446,158 @@ export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDt
|
||||
export function drawPriceSourcesPending(body: HTMLElement): void {
|
||||
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09)
|
||||
*
|
||||
* 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
|
||||
* 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를
|
||||
* 칸 밑에 그대로 적는다.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface FactorOption {
|
||||
key: string;
|
||||
value?: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface RangeFactorRow {
|
||||
key: string;
|
||||
work_item_code: string;
|
||||
work_item_name: string;
|
||||
factor: string;
|
||||
raw_cell: string;
|
||||
chosen: string;
|
||||
value: string;
|
||||
is_default: boolean;
|
||||
options: FactorOption[];
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
export interface MachineChoiceRow {
|
||||
work_item_code: string;
|
||||
work_item_name: string;
|
||||
chosen: string;
|
||||
default: string;
|
||||
is_default: boolean;
|
||||
source: string;
|
||||
options: FactorOption[];
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
export interface FactorChoicesDto {
|
||||
status: string;
|
||||
ranges: RangeFactorRow[];
|
||||
machines: MachineChoiceRow[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export async function fetchFactorChoices(projectId: string): Promise<FactorChoicesDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`factors ${response.status}`);
|
||||
return (await response.json()) as FactorChoicesDto;
|
||||
}
|
||||
|
||||
export async function saveFactorChoices(
|
||||
projectId: string,
|
||||
body: { range_factor_choices?: Record<string, string>; machine_choices?: Record<string, string> },
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`factors save ${response.status}`);
|
||||
}
|
||||
|
||||
function picker(
|
||||
label: string,
|
||||
options: FactorOption[],
|
||||
chosen: string,
|
||||
onPick: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b09-hint";
|
||||
wrap.style.display = "flex";
|
||||
wrap.style.alignItems = "center";
|
||||
wrap.style.gap = "8px";
|
||||
wrap.style.flexWrap = "wrap";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.style.fontWeight = "600";
|
||||
name.textContent = label;
|
||||
|
||||
const select = document.createElement("select");
|
||||
for (const option of options) {
|
||||
const item = document.createElement("option");
|
||||
item.value = option.key;
|
||||
item.textContent = option.label;
|
||||
item.selected = option.key === chosen;
|
||||
select.append(item);
|
||||
}
|
||||
select.addEventListener("change", () => onPick(select.value));
|
||||
wrap.append(name, select);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 산출 조건 구역 — 기초자료 탭 맨 위에 선다.
|
||||
*
|
||||
* ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이
|
||||
* 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리).
|
||||
*/
|
||||
export function drawFactorChoices(
|
||||
body: HTMLElement,
|
||||
data: FactorChoicesDto,
|
||||
projectId: string,
|
||||
reload: () => void,
|
||||
): void {
|
||||
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
|
||||
|
||||
for (const row of data.ranges) {
|
||||
const title = `${row.work_item_name} 작업효율(${row.factor})`;
|
||||
body.append(
|
||||
picker(title, row.options, row.chosen, (key) => {
|
||||
void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then(
|
||||
reload,
|
||||
);
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
note(
|
||||
`품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` +
|
||||
(row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"),
|
||||
),
|
||||
);
|
||||
for (const line of row.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
for (const row of data.machines) {
|
||||
body.append(
|
||||
picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => {
|
||||
void saveFactorChoices(projectId, {
|
||||
machine_choices: { [row.work_item_code]: key },
|
||||
}).then(reload);
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
note(
|
||||
row.source === "note"
|
||||
? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다."
|
||||
: "품셈 표가 정한 장비입니다." +
|
||||
(row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."),
|
||||
),
|
||||
);
|
||||
for (const line of row.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
for (const line of data.notes) body.append(note(line));
|
||||
}
|
||||
|
||||
@@ -18,12 +18,15 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_eleme
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
drawBaseDataTab,
|
||||
drawFactorChoices,
|
||||
drawMachineTab,
|
||||
drawPriceSourcesPending,
|
||||
drawPriceSourcesSections,
|
||||
fetchBaseData,
|
||||
fetchFactorChoices,
|
||||
fetchPriceSources,
|
||||
type BaseDataDto,
|
||||
type FactorChoicesDto,
|
||||
type PriceSourcesDto,
|
||||
} from "./B09_Estimation_UI_BaseData";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
@@ -785,6 +788,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
let activeTab = "cost_sheet";
|
||||
let baseData: BaseDataDto | null = null;
|
||||
let priceSources: PriceSourcesDto | null = null;
|
||||
let factorChoices: FactorChoicesDto | null = null;
|
||||
let sheet: CostSheetDto | null = null;
|
||||
let unitPriceList: UnitPriceListDto | null = null;
|
||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||
@@ -1160,6 +1164,25 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
drawMachineTab(body, baseData);
|
||||
return;
|
||||
}
|
||||
// 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면
|
||||
// 사용자가 「바꿀 수 있는 것」을 못 본다.
|
||||
if (factorChoices && projectId) {
|
||||
drawFactorChoices(body, factorChoices, projectId, () => {
|
||||
factorChoices = null;
|
||||
baseData = null;
|
||||
priceSources = null;
|
||||
drawBody();
|
||||
});
|
||||
} else if (projectId) {
|
||||
void fetchFactorChoices(projectId)
|
||||
.then((data) => {
|
||||
factorChoices = data;
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => {
|
||||
/* 못 받아도 아래 표는 그대로 선다. */
|
||||
});
|
||||
}
|
||||
drawBaseDataTab(body, baseData);
|
||||
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
|
||||
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
|
||||
|
||||
@@ -323,13 +323,30 @@ def _numbers_of(text: str) -> list[Decimal]:
|
||||
return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)]
|
||||
|
||||
|
||||
def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
def build_unit_prices(
|
||||
axis: AxisResult | None = None,
|
||||
factor_choices: dict[tuple[str, str], Decimal] | None = None,
|
||||
machine_picks: dict[str, str] | None = None,
|
||||
) -> UnitPriceBuild:
|
||||
"""자원 축을 일위대가(`B`)로 조립한다.
|
||||
|
||||
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
|
||||
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
|
||||
|
||||
⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면
|
||||
**평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||
chosen_values,
|
||||
machine_choices,
|
||||
scan_range_factors,
|
||||
)
|
||||
|
||||
master = load_work_item_master()
|
||||
if factor_choices is None:
|
||||
factor_choices = chosen_values(scan_range_factors(master))
|
||||
if machine_picks is None:
|
||||
machine_picks = machine_choices()
|
||||
if axis is None:
|
||||
axis = build_resource_axis(master, load_combined_catalog())
|
||||
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
|
||||
@@ -368,7 +385,9 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
by_item.setdefault((code, label), [])
|
||||
continue
|
||||
for table in node.get("tables", []):
|
||||
if isinstance(extract_cycle_factors(code, table), CycleFactors):
|
||||
if isinstance(
|
||||
extract_cycle_factors(code, table, factor_choices, machine_picks), CycleFactors
|
||||
):
|
||||
by_item[(code, "")] = []
|
||||
break
|
||||
|
||||
@@ -388,7 +407,9 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
for row in rows
|
||||
]
|
||||
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
|
||||
if not attachable and not _has_full_formula(master, work_item_code):
|
||||
if not attachable and not _has_full_formula(
|
||||
master, work_item_code, factor_choices, machine_picks
|
||||
):
|
||||
# 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지).
|
||||
build.skipped.append(work_item_code)
|
||||
continue
|
||||
@@ -454,6 +475,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
master,
|
||||
work_item_code,
|
||||
title_code,
|
||||
factor_choices,
|
||||
machine_picks,
|
||||
)
|
||||
|
||||
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
|
||||
@@ -481,7 +504,12 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
return build
|
||||
|
||||
|
||||
def _has_full_formula(master: dict, work_item_code: str) -> bool:
|
||||
def _has_full_formula(
|
||||
master: dict,
|
||||
work_item_code: str,
|
||||
choices: dict[tuple[str, str], Decimal] | None = None,
|
||||
machines: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""그 공종에 **온전한 시공능력 공식**이 있는가 (기계만 쓰는 공종용)."""
|
||||
node = next(
|
||||
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
|
||||
@@ -490,7 +518,7 @@ def _has_full_formula(master: dict, work_item_code: str) -> bool:
|
||||
if node is None:
|
||||
return False
|
||||
return any(
|
||||
isinstance(extract_cycle_factors(work_item_code, table), CycleFactors)
|
||||
isinstance(extract_cycle_factors(work_item_code, table, choices, machines), CycleFactors)
|
||||
or isinstance(extract_dozer_factors(work_item_code, table), dict)
|
||||
for table in node.get("tables", [])
|
||||
)
|
||||
@@ -569,10 +597,32 @@ SOURCE_LABEL: dict[PriceKind, str] = {
|
||||
DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS})
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def cached_build() -> UnitPriceBuild:
|
||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다."""
|
||||
return build_unit_prices()
|
||||
@lru_cache(maxsize=8)
|
||||
def cached_build(
|
||||
range_choices: tuple[tuple[str, str], ...] = (),
|
||||
machine_picks: tuple[tuple[str, str], ...] = (),
|
||||
) -> UnitPriceBuild:
|
||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
||||
|
||||
⚠ 인자는 **프로젝트가 고른 값**이다(확정 ①). 아무것도 안 주면 확정 기본값 —
|
||||
범위 계수는 평균, 장비는 원문 값이다. 고른 값이 다르면 **다른 벌로 캐시된다** —
|
||||
한 벌만 들면 프로젝트마다 다른 값이 서로 덮어쓴다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||
chosen_values,
|
||||
machine_choices,
|
||||
scan_range_factors,
|
||||
)
|
||||
|
||||
settings = {
|
||||
"range_factor_choices": dict(range_choices),
|
||||
"machine_choices": dict(machine_picks),
|
||||
}
|
||||
master = load_work_item_master()
|
||||
return build_unit_prices(
|
||||
factor_choices=chosen_values(scan_range_factors(master), settings),
|
||||
machine_picks=machine_choices(settings),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -198,6 +198,11 @@ def quantity_settings(project_root: str | Path) -> dict[str, Any]:
|
||||
return load_settings(project_root).get("quantity") or {}
|
||||
|
||||
|
||||
def estimation_settings(project_root: str | Path) -> dict[str, Any]:
|
||||
"""B09 구획만 꺼낸다 — 범위 계수·장비 규격처럼 **사용자가 고른 단가 조건**이 여기 산다."""
|
||||
return load_settings(project_root).get("estimation") or {}
|
||||
|
||||
|
||||
def rock_classes(settings: dict[str, Any]) -> list[str]:
|
||||
"""이 프로젝트의 암 갈래 목록. 세트 이름이 낯설면 저장된 목록을 그대로 쓴다."""
|
||||
stored = settings.get("rock_classes")
|
||||
|
||||
Reference in New Issue
Block a user