Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1

This commit is contained in:
2026-09-09 07:08:26 +09:00
10 changed files with 975 additions and 16 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,9 @@ 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,
sources: dict[str, str] | None = None,
) -> Decimal:
"""시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
@@ -327,7 +358,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
@@ -352,7 +383,13 @@ def attach_machine_share(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
note=factors.formula_text,
# 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.**
note=factors.formula_text
+ (
f" · {(sources or {}).get(work_item_code, '')}"
if (sources or {}).get(work_item_code)
else ""
),
)
)
cycle_factors[work_item_code] = factors
@@ -0,0 +1,195 @@
"""B09 원가계산 — **「다른 절과 동일」 참조**를 따라가 계수를 잇는다 (2026-09-09).
품셈은 같은 계수를 되풀이 적지 않고 **다른 절을 가리킨다.**
9-13-1 육상토사(01m) 장비(90%) 유압식백호우 | k 0.9 | f 0.77 | E 0.60 | ㎝ 20(135°)
9-13-2 육상토사(12m) 장비(90%) 유압식백호우 | **「육상토사(0~1m)와 동일」**
9-13-10 용수 암절취(01m) 들어내기 … | k 0.55 「**육상과동일**」
그 자리를 안 따라가면 **장비 몫 90%가 통째로 안 붙고 인력 10%만 선다** — 2026-09-09
실측으로 구조물터파기 여덟 갈래가 전부 그 모양이었다(「단가가 일부만 섰습니다 — 붙은 몫 10%」).
⚠ **값을 옮겨 적지 않는다.** 가리키는 절의 계수를 **그때그때 읽어** 쓴다. 옮겨 적으면
품셈이 개정될 때 한쪽만 고쳐진다.
⚠ **어디서 온 값인지 남긴다.** 화면이 「9-13-1 과 동일(품셈 원문)」을 그대로 보여야
나중에 누가 봐도 근거를 되짚을 수 있다(오늘 규칙).
⚠ **못 따라가는 참조는 따라간 척하지 않는다.**
· **자기 자신을 가리키는 것** — 9-13-14 가 「육상 발파암(1~2m)와 동일」이라 적었는데
그 절이 곧 육상 발파암(1~2m)이다(원문 오기로 보이나 **고쳐 읽지 않는다**).
· **가리키는 절을 못 찾는 것 · 그 절도 계수가 없는 것.**
이 셋은 사유를 남기고 **빈 채로 둔다.**
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
#: 「…와 동일」 — 앞의 이름이 가리키는 절이다.
_NAMED = re.compile(r"^(?P<name>.+?)\s*(?:와|과)\s*동일$")
#: 「육상과동일」 — 이름이 아니라 **한 낱말만 바꾸라**는 지시다(용수 → 육상).
_SWAP_WORDS = (("용수", "육상"),)
#: 이 이름들만 계수로 본다. 참조가 가리키는 것도 결국 이 넷이다.
_FACTOR_HEADS = {
"k": "K",
"f": "f",
"e": "E",
"cm": "Cm",
"": "Cm",
"cm(sec)": "Cm",
"㎝(sec)": "Cm",
}
def _clean(cell: Any) -> str:
return " ".join(str(cell or "").split())
def _normalize_name(text: str) -> str:
"""절 이름 비교용 — 공백과 물결표기 차이를 지운다(「0~1m」·「0-1m」)."""
return re.sub(r"[\s~〜–—-]", "", str(text))
def _row_has_machine(cells: list[str]) -> bool:
"""그 줄이 **기계 줄**인가 — 계수가 와야 할 자리인지 가른다."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
return any(resolve_machine(cell) is not None for cell in cells)
def _find_reference(node: dict[str, Any]) -> tuple[str, str] | None:
"""이 절이 가리키는 이름과 그 원문 문구. 참조가 없으면 `None`.
⚠ **기계 줄에 붙은 참조만 본다.** 한 표 안에 참조가 둘 이상 있고 **가리키는 곳이
서로 다르다** — 9-13-11 은 「치즐소모량 … 육상과동일」과 「들어내기 유압식백호우 …
용수 암절취(0~1m)와 동일」을 함께 적는다. 아무 줄에서나 주우면 **용수 자리에 육상
계수**가 붙어 작업효율이 0.375 대신 0.50 으로 서고 금액이 조용히 틀린다
(2026-09-09 실측으로 잡았다).
"""
own_name = str(node.get("name", ""))
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not _row_has_machine(cells):
continue
for cell in cells:
text = _clean(cell)
if not text or len(text) > 40:
continue
# ⚠ **낱말 바꾸기를 먼저 본다.** 「육상과동일」은 「육상」이라는 절을
# 가리키는 것이 아니라 **제 이름에서 용수를 육상으로 바꾸라**는 뜻이다.
# 이름 규칙(「…와 동일」)을 먼저 태우면 「육상」이라는 없는 절을 찾다가
# 놓친다(2026-09-09 실측: 네 갈래가 그렇게 빠졌다).
for source, target in _SWAP_WORDS:
# 문구에 적힌 낱말은 **가리키는 쪽**(육상)이고, 제 이름에 있는 낱말이
# **바꿀 쪽**(용수)이다. 둘을 뒤집어 보면 영영 못 찾는다.
if text in (f"{target}과동일", f"{target}과 동일") and source in own_name:
return own_name.replace(source, target), text
matched = _NAMED.match(text)
if matched:
return matched.group("name").strip(), text
return None
def _factor_values(node: dict[str, Any]) -> dict[str, Decimal]:
"""그 절이 **스스로 적어 둔** 계수들. 참조는 안 따라간다(한 걸음만 간다)."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
values: dict[str, Decimal] = {}
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not cells:
continue
for index, cell in enumerate(cells):
factor = _FACTOR_HEADS.get(cell.lower().replace(" ", ""))
if factor is None or factor in values:
continue
for candidate in cells[index + 1 :]:
parsed = parse_measure(candidate)
if parsed is not None:
values[factor] = parsed
break
return values
def reference_factor_values(
master: dict[str, Any],
) -> tuple[dict[tuple[str, str], Decimal], dict[str, str], dict[str, str], dict[str, str]]:
"""참조를 따라가 얻은 계수들.
돌려주는 것 넷 — (공종코드, 계수) → 값 · 공종코드 → 근거 한 줄 · 공종코드 → 못 따라간
사유 · 공종코드 → **원문 참조 문구 그대로**(그 줄을 「못 붙은 줄」 목록에서 빼는 데 쓴다).
"""
nodes = {str(n.get("work_item_code", "")): n for n in master.get("work_items", [])}
by_name: dict[str, list[str]] = {}
for code, node in nodes.items():
by_name.setdefault(_normalize_name(node.get("name", "")), []).append(code)
values: dict[tuple[str, str], Decimal] = {}
provenance: dict[str, str] = {}
failures: dict[str, str] = {}
raw_texts: dict[str, str] = {}
def resolve(code: str, seen: tuple[str, ...]) -> tuple[dict[str, Decimal], list[str], str]:
"""그 절의 계수를 푼다 — 스스로 적은 것 + 참조를 따라간 것.
⚠ **참조는 사슬로 이어진다** — 9-13-11(용수 암절취 1~2m)은 「육상과동일」로
9-13-8 을 가리키고, 그 절은 다시 「육상 암절취(0~1m)와 동일」로 9-13-7 을
가리킨다. 한 걸음만 가면 가운데서 멈춘다(2026-09-09 실측).
⚠ **돈 자리는 멈춘다** — 자기 자신이나 이미 지나온 절로 돌아가면 사슬이 도는
것이라 따라간 척하지 않는다.
"""
node = nodes.get(code)
if node is None:
return {}, [], f"공종 {code} 을 못 찾았습니다"
own = _factor_values(node)
if len(own) >= 4:
return own, [], ""
found = _find_reference(node)
if found is None:
return own, [], ""
target_name, raw_text = found
matches = [m for m in by_name.get(_normalize_name(target_name), []) if m != code]
if not matches:
return own, [], f"{raw_text}」가 가리키는 절을 못 찾았습니다"
if len(matches) > 1:
return own, [], f"{raw_text}」가 가리키는 절이 여럿입니다 — 하나로 못 좁혔습니다"
target = matches[0]
if target in seen:
return own, [], f"{raw_text}」가 이미 지나온 절을 다시 가리킵니다 — 사슬이 돕니다"
borrowed, path, why = resolve(target, (*seen, code))
if why:
return own, [], f"{raw_text}」를 따라갔으나 {why}"
merged = {**borrowed, **own}
missing = [key for key in ("K", "f", "E", "Cm") if key not in merged]
if missing:
return own, [], f"{raw_text}」를 따라갔으나 계수가 없습니다 — {', '.join(missing)}"
step = f"{raw_text}」 → {nodes[target].get('name', target)}"
return merged, [step, *path], ""
for code, node in nodes.items():
if _find_reference(node) is None:
continue
own = _factor_values(node)
if len(own) >= 4:
continue # 스스로 다 적어 둔 절 — 참조는 곁말이다
merged, path, why = resolve(code, ())
if why:
failures[code] = why
continue
for key, value in merged.items():
if key not in own:
values[(code, key)] = value
provenance[code] = "계수 출처: " + " · ".join(path) + " (품셈 원문 표기 그대로)"
own_ref = _find_reference(node)
if own_ref:
raw_texts[code] = own_ref[1]
return values, provenance, failures, raw_texts
@@ -411,6 +411,11 @@ def match_table(
if match_packed_rows(node, table, catalog, result, basis_quantity, unit):
return
# ⚠ **묶음 배분율은 다음 줄로 이어진다.** 품셈 표는 묶음 머리를 **병합해** 적는다 —
# 「인력(10%) | 할석공 2.0」 다음 줄이 「보통인부 1.0」이라 그 줄엔 딱지가 없다.
# 이어 주지 않으면 그 줄만 **100%로 서서** 조용히 열 배가 된다(2026-09-09 실측:
# 구조물터파기 보통인부가 0.1 대신 1.0 으로 서 단가가 224,305원/㎥ 이었다).
carried_ratio: Decimal | None = None
for index, row in enumerate(table.get("raw_row", [])):
cells = [str(c) for c in row]
if not cells:
@@ -435,6 +440,10 @@ def match_table(
group_ratio = _group_ratio_of(name_cell)
name_cell = cells[1]
value_cells = cells[2:]
# 새 묶음 머리를 만났다 — 여기서부터 이 배분율이 이어진다(없으면 끊는다).
carried_ratio = group_ratio
else:
group_ratio = carried_ratio
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
if "제잡비" in _normalize(name_cell):
+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));
}
+37
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";
@@ -105,6 +108,9 @@ interface UnitPriceDetailDto {
expense: string;
total: string;
sum_matches: boolean;
/** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */
unattached: string[];
unattached_note: string;
rows: UnitPriceDetailRow[];
}
@@ -352,6 +358,17 @@ function buildUnitPriceDetail(
` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`;
wrap.append(caption);
// ⚠ **일부만 선 단가는 반드시 말한다.** 안 말하면 조용히 싼 값이 내역서에 그대로 든다
// (2026-09-09 실측: 일위대가가 선 141 공종 중 70 공종이 이 자리 — 초류종자살포는
// 자재 다섯·장비 셋이 빠진 채 인력 둘만으로 서 있었다).
if (detail.unattached_note) {
const gap = document.createElement("div");
gap.className = "b09-hint";
gap.style.fontWeight = "600";
gap.textContent = detail.unattached_note;
wrap.append(gap);
}
// 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.**
// 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다.
if (detail.precise_total !== detail.total) {
@@ -785,6 +802,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 +1178,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);
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
+114 -10
View File
@@ -92,6 +92,16 @@ class UnitPriceBuild:
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
component_gaps: dict[str, str] = field(default_factory=dict)
#: 공종코드 → **표에 있는데 못 붙은 줄 이름들**.
#: ⚠ 값이 있다는 것은 그 단가가 **표의 일부만으로 서 있다**는 뜻이다 — 대개 자재·기계
#: 카탈로그가 없어서다. 막지는 않지만(막으면 정상 공종이 무더기로 멈춘다) **화면이
#: 반드시 말해야 한다.** 안 말하면 조용히 싼 단가가 내역서에 그대로 든다
#: (2026-09-09 실측: 일위대가가 선 141 공종 중 **70 공종**이 이 자리였다).
unattached: dict[str, list[str]] = field(default_factory=dict)
#: 공종코드 → **계수를 어디서 가져왔는지** 한 줄. 「…와 동일」 참조를 따라간 자리다.
#: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에
#: 「이 숫자 어디서 왔지」로 되짚을 길이 없다.
factor_sources: dict[str, str] = field(default_factory=dict)
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
@@ -323,20 +333,80 @@ 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,
)
from B09_Estimation.B09_Estimation_MachineProductivity_Reference import (
reference_factor_values,
)
master = load_work_item_master()
if factor_choices is None:
factor_choices = chosen_values(scan_range_factors(master))
# 「…와 동일」 참조로 이어 온 계수 — **사용자가 고른 값이 있으면 그것이 이긴다.**
borrowed, borrow_note, borrow_fail, borrow_text = reference_factor_values(master)
factor_choices = {**borrowed, **factor_choices}
if machine_picks is None:
machine_picks = machine_choices()
if axis is None:
axis = build_resource_axis(master, load_combined_catalog())
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
build = UnitPriceBuild()
build.factor_sources = dict(borrow_note)
for failed_code, why in borrow_fail.items():
build.factor_sources.setdefault(failed_code, f"{why}")
build.component_gaps = dict(axis.partial_items)
# ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이
# 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다.
resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note}
for unmatched_row in axis.unmatched:
# ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면
# 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측).
labels = build.unattached.setdefault(unmatched_row.work_item_code, [])
label = " ".join(str(unmatched_row.cell).split())
reference_text = resolved_rows.get(unmatched_row.work_item_code)
if reference_text and reference_text in label:
continue # 그 줄은 참조를 따라가 값을 얻었다
if label and label not in labels:
labels.append(label)
# ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어
# **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다
# (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다.
solved_codes = {
code
for code in borrow_note
if code in axis.partial_items and not build.unattached.get(code)
}
for code in solved_codes:
build.component_gaps.pop(code, None)
# 참조는 풀렸는데 **다른 줄이 남은** 공종은 막힌 채로 두되 **사유를 고쳐 적는다** —
# 「백호우 줄을 못 읽었다」는 이미 푼 이야기라 그대로 두면 사람을 엉뚱한 데로 보낸다.
for code in borrow_note:
remaining = build.unattached.get(code)
if remaining and code in build.component_gaps:
build.component_gaps[code] = (
f"{', '.join(remaining[:3])} 줄이 아직 안 붙었습니다 (계수 참조는 풀렸습니다)"
)
missing_basis = load_basis_missing()
wages = load_operator_wages()
_add_labor_titles(build.book, wages)
@@ -368,7 +438,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 +460,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 +528,9 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
master,
work_item_code,
title_code,
factor_choices,
machine_picks,
build.factor_sources,
)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
@@ -461,7 +538,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
# 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥
# 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다.
# 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다.
if work_item_code in axis.partial_items:
if work_item_code in axis.partial_items and work_item_code not in solved_codes:
build.partial_ratio.setdefault(work_item_code, _ZERO)
# ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어
@@ -481,7 +558,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 +572,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 +651,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
@@ -165,6 +165,9 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
title = build.book.title(code)
money = build.book.resolve(code)
# 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다.
work_item_code = code[2:].split("#")[0] if code.startswith("B-") else ""
unattached = list(build.unattached.get(work_item_code, []))
rows: list[dict] = []
for detail in build.book.details.get(code, []):
if detail.percent_of_labor is not None:
@@ -255,4 +258,16 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
"precise_total": _money_text(money.total),
"rows": rows,
# ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다.
# 안 보이면 조용히 싼 단가가 내역서에 그대로 든다.
"unattached": unattached,
"unattached_note": (
f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — "
f"{', '.join(unattached[:4])}"
+ ("" if len(unattached) > 4 else "")
+ ". 자재·기계 카탈로그가 서면 채워집니다. 그때까지 이 단가는 "
"**붙은 줄만의 값**입니다."
)
if unattached
else "",
}
@@ -210,6 +210,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")