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:
2026-09-09 06:27:24 +09:00
co-authored by Claude Opus 5
parent 62f7e0eccb
commit 5780eb9359
7 changed files with 679 additions and 14 deletions
@@ -0,0 +1,232 @@
"""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
@@ -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.550.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
+174 -4
View File
@@ -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));
}
+23
View File
@@ -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);
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
+59 -9
View File
@@ -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")