diff --git a/B09_Estimation/B09_Estimation_FactorChoices.py b/B09_Estimation/B09_Estimation_FactorChoices.py new file mode 100644 index 00000000..8625214d --- /dev/null +++ b/B09_Estimation/B09_Estimation_FactorChoices.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 5a328f03..7aa3bb02 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index df07b24a..2d66db13 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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: diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index 56f6d507..b55f76b5 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -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 { + 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; machine_choices?: Record }, +): Promise { + 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)); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 7052d984..1a2d3a5d 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -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 { 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 { 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); // 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고 // 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다. diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index cceb5ae2..538a4989 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -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 diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 921d9d8e..793882ac 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -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")