Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""B09 원가계산 — **품셈이 범위로 준 계수**를 사용자가 고르는 자리 (2026-09-09 확정 ①).
|
||||
|
||||
품셈이 계수를 **한 값으로 안 주고 범위로 주는 자리**가 있다. 그 자리는 우리가 임의로
|
||||
못 정한다 — 그런데 값이 없으면 그 공종은 금액이 통째로 안 선다(흙깎기가 그랬다).
|
||||
|
||||
9-3-2 흙깎기(기계) E = 0.55∼0.45 ← 지금 유일한 범위 칸
|
||||
|
||||
**사용자 확정 ①(2026-09-09) — E = 0.50 (두 끝의 평균).** 다만 사용자 지시가 한 줄 더
|
||||
붙었다: **「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수
|
||||
있게 할 것」.** 그래서 이 파일은 **값을 정하는 곳이 아니라 고를 것을 차리는 곳**이다.
|
||||
|
||||
고를 수 있는 것은 **원문에 적힌 두 끝과 그 평균 셋뿐**이다 — 그 밖의 수는 만들지 않는다.
|
||||
|
||||
**왜 평균이 기본인가** (화면이 그대로 보여 준다)
|
||||
① 품셈 자신이 같은 두 값을 **다른 절에서 평균으로 쓴다** — 9-13-4 용수토사가
|
||||
`(0.55+0.45)/2-0.05`, 9-12-1 토사가 `(0.7+0.6)/2-0.05` 서식이다. 범위 표기와
|
||||
평균 표기가 **같은 품셈 안에 섞여 있다.**
|
||||
② 9-3-2 [주]③ 이 「사질토+점성토」 **둘 다** 걸라 한다. 건설품셈 8-2-3 작업효율표의
|
||||
자연상태·불량 칸이 「모래·사질토 0.55 / 자갈섞인흙·점성토 0.45」라, 그 둘을 다
|
||||
걸면 평균이 된다.
|
||||
③ 실무 넷 중 **현행 산림품셈 인자(K 0.9 · f 1/1.30 · ㎝ 20)와 전부 맞는 것은 영월**
|
||||
하나이고, 영월이 `E = (0.55+0.45)/2 = 0.50` 을 쓴다. 울진 둘은 건설품셈 옛 조항을
|
||||
근거로 달아 K·f 까지 다르다(K 0.7·f 1/1.25).
|
||||
|
||||
⚠ **이 층은 프로젝트마다 갈린다** — 저장은 프로젝트 설정의 `estimation` 구획이다.
|
||||
⚠ **범위가 아닌 계수는 여기 오지 않는다.** `(0.7+0.6)/2-0.05` 같은 **식**은 품셈이 이미
|
||||
값을 정한 것이라 그대로 계산한다 — 고를 것이 아니다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
#: 범위 칸 — 「0.55∼0.45」.
|
||||
#: ⚠ **가운뎃점이 물결(∼·~·〜)일 때만 범위다.** 그냥 붙임표(`-`)는 **뺄셈**이다 —
|
||||
#: 품셈 9-12-3 의 「0.45-0.05」는 「0.45 에서 0.05 를 뺀 0.40」이지 0.45~0.05 범위가
|
||||
#: 아니다. 붙임표를 범위로 읽으면 **품셈이 이미 정한 값이 「고를 것」으로 둔갑한다**
|
||||
#: (2026-09-09 실측: 세 자리가 그렇게 잡혔다).
|
||||
_RANGE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*[∼~〜]\s*(\d+(?:\.\d+)?)\s*$")
|
||||
|
||||
#: 계수 이름 — 표 첫 칸이 이 중 하나일 때만 본다.
|
||||
_FACTOR_HEADS = {
|
||||
"k": "K",
|
||||
"f": "f",
|
||||
"e": "E",
|
||||
"cm": "Cm",
|
||||
"㎝": "Cm",
|
||||
"cm(sec)": "Cm",
|
||||
"㎝(sec)": "Cm",
|
||||
}
|
||||
|
||||
#: 고르는 방법 셋. **원문 두 끝과 그 평균뿐** — 다른 수는 만들지 않는다.
|
||||
CHOICE_KEYS = ("high", "mid", "low")
|
||||
DEFAULT_CHOICE = "mid"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RangeFactor:
|
||||
"""품셈이 범위로 준 계수 한 자리."""
|
||||
|
||||
work_item_code: str
|
||||
work_item_name: str
|
||||
pum_table_id: str
|
||||
factor: str
|
||||
low: Decimal
|
||||
high: Decimal
|
||||
raw_cell: str
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.work_item_code}:{self.factor}"
|
||||
|
||||
def value_of(self, choice: str) -> Decimal:
|
||||
if choice == "high":
|
||||
return self.high
|
||||
if choice == "low":
|
||||
return self.low
|
||||
return (self.high + self.low) / Decimal(2)
|
||||
|
||||
def options(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"key": "high",
|
||||
"value": str(self.high),
|
||||
"label": f"상한 {self.high}",
|
||||
"note": "원문 범위의 큰 쪽 — 모래·사질토 자리",
|
||||
},
|
||||
{
|
||||
"key": "mid",
|
||||
"value": str(self.value_of("mid")),
|
||||
"label": f"평균 {self.value_of('mid')}",
|
||||
"note": "두 끝의 평균 — 품셈 자신이 다른 절에서 쓰는 서식이고 실무(영월)도 이 값",
|
||||
},
|
||||
{
|
||||
"key": "low",
|
||||
"value": str(self.low),
|
||||
"label": f"하한 {self.low}",
|
||||
"note": "원문 범위의 작은 쪽 — 자갈섞인흙·점성토 자리",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _normalize_head(cell: Any) -> str:
|
||||
return str(cell or "").strip().lower().replace(" ", "")
|
||||
|
||||
|
||||
def scan_range_factors(master: dict[str, Any]) -> list[RangeFactor]:
|
||||
"""품셈 전체에서 **범위로 적힌 계수 칸**을 모은다.
|
||||
|
||||
⚠ 공종 코드를 박아 두지 않는다 — 품셈이 개정되면 범위 칸이 늘거나 줄 수 있고,
|
||||
코드로 잡으면 새로 생긴 자리를 조용히 놓친다.
|
||||
"""
|
||||
found: list[RangeFactor] = []
|
||||
for node in master.get("work_items", []):
|
||||
for table in node.get("tables", []):
|
||||
for row in table.get("raw_row") or []:
|
||||
cells = [str(cell).strip() for cell in row]
|
||||
if not cells:
|
||||
continue
|
||||
factor = _FACTOR_HEADS.get(_normalize_head(cells[0]))
|
||||
if factor is None:
|
||||
continue
|
||||
for cell in cells[1:]:
|
||||
matched = _RANGE.match(str(cell))
|
||||
if not matched:
|
||||
continue
|
||||
first, second = Decimal(matched.group(1)), Decimal(matched.group(2))
|
||||
found.append(
|
||||
RangeFactor(
|
||||
work_item_code=str(node.get("work_item_code", "")),
|
||||
work_item_name=str(node.get("name", "")),
|
||||
pum_table_id=str(table.get("pum_table_id", "")),
|
||||
factor=factor,
|
||||
low=min(first, second),
|
||||
high=max(first, second),
|
||||
raw_cell=str(cell).strip(),
|
||||
)
|
||||
)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def chosen_values(
|
||||
factors: list[RangeFactor], settings: dict[str, Any] | None = None
|
||||
) -> dict[tuple[str, str], Decimal]:
|
||||
"""(공종코드, 계수) → 쓸 값. 저장분이 없으면 **평균**이 기본이다."""
|
||||
stored = ((settings or {}).get("range_factor_choices") or {}) if settings else {}
|
||||
values: dict[tuple[str, str], Decimal] = {}
|
||||
for item in factors:
|
||||
choice = str(stored.get(item.key) or DEFAULT_CHOICE)
|
||||
if choice not in CHOICE_KEYS:
|
||||
choice = DEFAULT_CHOICE
|
||||
values[(item.work_item_code, item.factor)] = item.value_of(choice)
|
||||
return values
|
||||
|
||||
|
||||
#: 화면이 그대로 띄우는 근거. **왜 이 값인지**를 표가 스스로 말해야 한다(PLAN 8-13).
|
||||
BASIS_NOTES: dict[str, list[str]] = {
|
||||
"FP-09-03-02:E": [
|
||||
"산림사업 표준품셈(고시 2025-82) 9-3-2 가 작업효율을 「0.55∼0.45」 **범위**로 줍니다 — "
|
||||
"한 값이 아니라 범위라 프로그램이 임의로 정하지 않습니다.",
|
||||
"그 두 값의 정체는 건설공사 표준품셈 8-2-3 작업효율표의 **자연상태·불량** 칸입니다 — "
|
||||
"「모래·사질토 0.55 / 자갈섞인흙·점성토 0.45」. 9-3-2 [주]③ 이 「사질토+점성토」 둘 다 "
|
||||
"걸라 하므로 두 값이 함께 걸립니다.",
|
||||
"품셈 자신이 같은 두 값을 다른 절에서는 평균으로 씁니다 — 9-13-4 용수토사 "
|
||||
"「(0.55+0.45)/2-0.05」. 범위 표기와 평균 표기가 한 품셈 안에 섞여 있습니다.",
|
||||
"실무 넷 중 현행 산림품셈 인자(K 0.9 · f 1/1.30 · ㎝ 20(135°))와 전부 맞는 것은 "
|
||||
"영월 하나이고, 영월이 「E=(0.55+0.45)/2=0.50」 을 씁니다. 울진 둘은 건설품셈 옛 "
|
||||
"조항(11-3 · 8-2-3)을 근거로 달아 K 0.7·f 1/1.25 까지 다릅니다.",
|
||||
"⚠ 이 한 칸이 흙깎기 단가를 좌우합니다 — 0.45 면 약 659만원, 0.50 이면 약 593만원, "
|
||||
"0.55 면 약 539만원(수량 2,355.84㎥ 기준).",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 장비 규격 — 사용자 확정 ①에 딸려 온 지시(2026-09-09)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ **두 종류가 섞여 있다. 섞어 다루면 안 된다.**
|
||||
# ㉠ **표에 장비가 없는 자리** — 9-3-2 흙깎기가 그렇다. 장비는 [주]① 「장비는 무한궤도
|
||||
# 굴착기(0.7㎥)를 적용한다」에 있는데 **마스터가 [주] 를 아직 안 싣는다.** 그래서
|
||||
# 공식이 다 있어도 기종을 못 골라 금액이 통째로 안 섰다.
|
||||
# ㉡ **표에 장비가 있는 자리** — 9-18 층따기는 표머리가 「굴착기 (무한궤도, 0.7㎥)」다.
|
||||
# **원문이 정한 값**이라 기본은 그대로 두되, 실무가 다른 규격을 쓰는 것이 확인돼
|
||||
# (영월 BACK-HOE 0.2㎥) 사용자가 바꿀 수 있어야 한다.
|
||||
#
|
||||
# ⚠ **㉠ 은 마스터가 [주] 를 실으면 이 표에서 지운다** — 두 곳에 같은 값을 두면 나중에
|
||||
# 한쪽만 고쳐진다. 그때까지만 여기서 든다.
|
||||
|
||||
#: 기종 코드 → 화면에 보일 이름. 카탈로그가 정본이고 여기는 고르는 목록일 뿐이다.
|
||||
MACHINE_OPTION_CODES = ("0201-0020", "0201-0070")
|
||||
|
||||
MACHINE_CHOICES: dict[str, dict[str, Any]] = {
|
||||
"FP-09-03-02": {
|
||||
"work_item_name": "흙깎기(기계)",
|
||||
"default_code": "0201-0070",
|
||||
"source": "note",
|
||||
"basis": [
|
||||
"산림사업 표준품셈 9-3-2 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
|
||||
"표가 아니라 [주] 에 있어 공종 마스터가 아직 못 싣는 값입니다.",
|
||||
"[주]⑤ 가 그 까닭도 적습니다 — 「소규모공사(10,000㎥ 미만, 0.4㎥ 적용)이나 "
|
||||
"암절취 깎기를 고려하여 0.7㎥ 적용한다」.",
|
||||
],
|
||||
},
|
||||
"FP-09-18": {
|
||||
"work_item_name": "층따기",
|
||||
"default_code": "0201-0070",
|
||||
"source": "table",
|
||||
"basis": [
|
||||
"산림사업 표준품셈 9-18 표머리가 「굴착기 (무한궤도, 0.7㎥)」로 장비를 정합니다 — "
|
||||
"원문이 정한 값이라 기본은 이것입니다.",
|
||||
"⚠ 실무는 더 작은 장비를 씁니다 — 영월 산출근거가 「층따기 BACK-HOE 0.2㎥ · "
|
||||
"㎝ 20 sec(180°) · E 0.7」입니다. 현장이 좁은 자리라 실무가 달리 잡은 것으로 "
|
||||
"보이며, 바꾸면 시간당 작업량이 줄어 단가가 오릅니다.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def machine_choices(settings: dict[str, Any] | None = None) -> dict[str, str]:
|
||||
"""공종코드 → 쓸 기종 코드. 저장분이 없으면 위 표의 기본값(원문 값)이다."""
|
||||
stored = ((settings or {}).get("machine_choices") or {}) if settings else {}
|
||||
picked: dict[str, str] = {}
|
||||
for code, entry in MACHINE_CHOICES.items():
|
||||
chosen = str(stored.get(code) or entry["default_code"])
|
||||
picked[code] = chosen if chosen in MACHINE_OPTION_CODES else str(entry["default_code"])
|
||||
return picked
|
||||
@@ -196,11 +196,18 @@ def _capacity_token(inside: str) -> str:
|
||||
def extract_cycle_factors(
|
||||
work_item_code: str,
|
||||
table: dict[str, Any],
|
||||
choices: dict[tuple[str, str], Decimal] | None = None,
|
||||
machines: dict[str, str] | None = None,
|
||||
) -> CycleFactors | FactorGap | None:
|
||||
"""표 하나에서 계수를 뽑는다.
|
||||
|
||||
공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면
|
||||
`FactorGap`, 다 있으면 `CycleFactors`.
|
||||
|
||||
⚠ `choices` — 품셈이 **범위로 준 계수**(9-3-2 의 `E = 0.55∼0.45`)에 사용자가 고른 값을
|
||||
끼워 넣는다. **범위가 아닌 칸은 절대 안 덮는다** — 품셈이 값을 정한 자리를 사용자
|
||||
설정이 밀어내면 그것이 곧 임의 수치다. 고를 수 있는 것도 원문 두 끝과 그 평균뿐이다
|
||||
(`B09_Estimation_FactorChoices`).
|
||||
"""
|
||||
rows = table.get("raw_row") or []
|
||||
values: dict[str, Decimal] = {}
|
||||
@@ -255,8 +262,29 @@ def extract_cycle_factors(
|
||||
if not saw_key and machine is None:
|
||||
return None
|
||||
|
||||
# 범위 칸이라 못 읽은 자리에만 **고른 값**을 끼운다 — 읽힌 칸은 손대지 않는다.
|
||||
for key in ("K", "f", "E", "Cm"):
|
||||
if values.get(key) is None and choices:
|
||||
picked = choices.get((work_item_code, key))
|
||||
if picked is not None:
|
||||
values[key] = picked
|
||||
saw_key = True
|
||||
|
||||
missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None]
|
||||
capacity = bucket_from_machine_row
|
||||
|
||||
# 고른 기종이 있으면 그것을 쓴다. 표가 장비를 말한 자리(층따기)에서는 **바꾸는 것**이고,
|
||||
# 표에 장비가 없는 자리(흙깎기)에서는 [주] 에만 있는 값을 **채우는 것**이다.
|
||||
# 어느 쪽이든 화면이 근거와 함께 보이고 사용자가 되돌릴 수 있다(확정 ① 딸림 지시).
|
||||
picked_code = (machines or {}).get(work_item_code)
|
||||
if picked_code:
|
||||
chosen = load_machine_catalog().machines.get(picked_code)
|
||||
if chosen is not None:
|
||||
machine = (picked_code, chosen.name)
|
||||
spec = parse_measure(chosen.specification)
|
||||
if spec is not None:
|
||||
capacity = spec
|
||||
|
||||
if machine is None:
|
||||
missing.append("기계")
|
||||
if capacity is None:
|
||||
@@ -308,6 +336,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 육상토사(0~1m) 장비(90%) 유압식백호우 | k 0.9 | f 0.77 | E 0.60 | ㎝ 20(135°)
|
||||
9-13-2 육상토사(1~2m) 장비(90%) 유압식백호우 | **「육상토사(0~1m)와 동일」**
|
||||
9-13-10 용수 암절취(0~1m) 들어내기 … | 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):
|
||||
|
||||
@@ -201,7 +201,7 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
||||
알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬).
|
||||
"""
|
||||
try:
|
||||
build = cached_build()
|
||||
build = await _build_for(project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
@@ -217,6 +217,39 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def _project_root_of(project_id: UUID) -> str | None:
|
||||
"""프로젝트 저장 폴더. 못 찾으면 `None` — 그때는 확정 기본값으로 돈다."""
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
|
||||
try:
|
||||
stored = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
return str(resolve_stored_project_path(stored))
|
||||
except Exception:
|
||||
logger.warning("B09 프로젝트 폴더를 못 찾았습니다 — 기본값으로 돕니다: %s", project_id)
|
||||
return None
|
||||
|
||||
|
||||
async def _build_for(project_id: UUID):
|
||||
"""그 프로젝트가 **고른 값**으로 조립한 일위대가.
|
||||
|
||||
⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로
|
||||
돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
|
||||
"""
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
ranges = tuple(
|
||||
sorted((str(k), str(v)) for k, v in (settings.get("range_factor_choices") or {}).items())
|
||||
)
|
||||
machines = tuple(
|
||||
sorted((str(k), str(v)) for k, v in (settings.get("machine_choices") or {}).items())
|
||||
)
|
||||
return cached_build(ranges, machines)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/base-data")
|
||||
async def get_base_data_lists(project_id: UUID) -> JSONResponse:
|
||||
"""**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번).
|
||||
@@ -227,7 +260,9 @@ async def get_base_data_lists(project_id: UUID) -> JSONResponse:
|
||||
from B09_Estimation.B09_Estimation_Lists import all_lists
|
||||
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **all_lists(cached_build())})
|
||||
return JSONResponse(
|
||||
content={"status": "success", **all_lists(await _build_for(project_id))}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
@@ -249,7 +284,7 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
build = cached_build()
|
||||
build = await _build_for(project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
@@ -265,11 +300,146 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/factors")
|
||||
async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
||||
"""**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시).
|
||||
|
||||
「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
|
||||
라는 지시대로, **지금 값 · 고를 수 있는 것 · 왜 그 값인지**를 함께 낸다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||
BASIS_NOTES,
|
||||
DEFAULT_CHOICE,
|
||||
MACHINE_CHOICES,
|
||||
MACHINE_OPTION_CODES,
|
||||
machine_choices,
|
||||
scan_range_factors,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import load_work_item_master
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
try:
|
||||
root = await _project_root_of(project_id)
|
||||
settings = estimation_settings(root) if root else {}
|
||||
stored = settings.get("range_factor_choices") or {}
|
||||
|
||||
ranges = []
|
||||
for item in scan_range_factors(load_work_item_master()):
|
||||
choice = str(stored.get(item.key) or DEFAULT_CHOICE)
|
||||
ranges.append(
|
||||
{
|
||||
"key": item.key,
|
||||
"work_item_code": item.work_item_code,
|
||||
"work_item_name": item.work_item_name,
|
||||
"factor": item.factor,
|
||||
"raw_cell": item.raw_cell,
|
||||
"chosen": choice,
|
||||
"value": str(item.value_of(choice)),
|
||||
"is_default": choice == DEFAULT_CHOICE,
|
||||
"options": item.options(),
|
||||
"basis": BASIS_NOTES.get(item.key, []),
|
||||
}
|
||||
)
|
||||
|
||||
catalog = load_machine_catalog()
|
||||
picked = machine_choices(settings)
|
||||
machines = []
|
||||
for code, entry in MACHINE_CHOICES.items():
|
||||
options = []
|
||||
for machine_code in MACHINE_OPTION_CODES:
|
||||
machine = catalog.machines.get(machine_code)
|
||||
if machine is None:
|
||||
continue
|
||||
options.append(
|
||||
{
|
||||
"key": machine_code,
|
||||
"label": f"{machine.name} {machine.specification}".strip(),
|
||||
}
|
||||
)
|
||||
machines.append(
|
||||
{
|
||||
"work_item_code": code,
|
||||
"work_item_name": entry["work_item_name"],
|
||||
"chosen": picked.get(code, entry["default_code"]),
|
||||
"default": entry["default_code"],
|
||||
"is_default": picked.get(code) == entry["default_code"],
|
||||
"source": entry["source"],
|
||||
"options": options,
|
||||
"basis": entry["basis"],
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"ranges": ranges,
|
||||
"machines": machines,
|
||||
"notes": [
|
||||
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
|
||||
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
|
||||
],
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B09 산출 조건 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "산출 조건을 못 불러왔습니다."},
|
||||
)
|
||||
|
||||
|
||||
class FactorChoiceBody(BaseModel):
|
||||
"""고른 값 — 안 보낸 칸은 그대로 둔다."""
|
||||
|
||||
range_factor_choices: dict[str, str] | None = None
|
||||
machine_choices: dict[str, str] | None = None
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation/factors")
|
||||
async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONResponse:
|
||||
"""산출 조건을 이 프로젝트에 저장한다. **다른 구획은 손대지 않는다.**"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES
|
||||
from common_util.common_util_project_settings import save_section
|
||||
|
||||
root = await _project_root_of(project_id)
|
||||
if root is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||
)
|
||||
values: dict[str, Any] = {}
|
||||
if body.range_factor_choices is not None:
|
||||
# ⚠ 모르는 값은 안 받는다 — 원문에 없는 수가 설정으로 들어오면 그것이 임의 수치다.
|
||||
values["range_factor_choices"] = {
|
||||
str(key): str(value)
|
||||
for key, value in body.range_factor_choices.items()
|
||||
if str(value) in CHOICE_KEYS
|
||||
}
|
||||
if body.machine_choices is not None:
|
||||
values["machine_choices"] = {
|
||||
str(key): str(value)
|
||||
for key, value in body.machine_choices.items()
|
||||
if str(value) in MACHINE_OPTION_CODES
|
||||
}
|
||||
try:
|
||||
save_section(root, "estimation", values, replace_keys=tuple(values))
|
||||
return JSONResponse(content={"status": "success", **values})
|
||||
except Exception:
|
||||
logger.exception("B09 산출 조건 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/unit-prices/{code}")
|
||||
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
||||
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)})
|
||||
return JSONResponse(
|
||||
content={"status": "success", **detail_of(await _build_for(project_id), code)}
|
||||
)
|
||||
except PriceBookError as error:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
|
||||
except Exception:
|
||||
|
||||
@@ -446,3 +446,158 @@ export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDt
|
||||
export function drawPriceSourcesPending(body: HTMLElement): void {
|
||||
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09)
|
||||
*
|
||||
* 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
|
||||
* 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를
|
||||
* 칸 밑에 그대로 적는다.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface FactorOption {
|
||||
key: string;
|
||||
value?: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface RangeFactorRow {
|
||||
key: string;
|
||||
work_item_code: string;
|
||||
work_item_name: string;
|
||||
factor: string;
|
||||
raw_cell: string;
|
||||
chosen: string;
|
||||
value: string;
|
||||
is_default: boolean;
|
||||
options: FactorOption[];
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
export interface MachineChoiceRow {
|
||||
work_item_code: string;
|
||||
work_item_name: string;
|
||||
chosen: string;
|
||||
default: string;
|
||||
is_default: boolean;
|
||||
source: string;
|
||||
options: FactorOption[];
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
export interface FactorChoicesDto {
|
||||
status: string;
|
||||
ranges: RangeFactorRow[];
|
||||
machines: MachineChoiceRow[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export async function fetchFactorChoices(projectId: string): Promise<FactorChoicesDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`factors ${response.status}`);
|
||||
return (await response.json()) as FactorChoicesDto;
|
||||
}
|
||||
|
||||
export async function saveFactorChoices(
|
||||
projectId: string,
|
||||
body: { range_factor_choices?: Record<string, string>; machine_choices?: Record<string, string> },
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`factors save ${response.status}`);
|
||||
}
|
||||
|
||||
function picker(
|
||||
label: string,
|
||||
options: FactorOption[],
|
||||
chosen: string,
|
||||
onPick: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b09-hint";
|
||||
wrap.style.display = "flex";
|
||||
wrap.style.alignItems = "center";
|
||||
wrap.style.gap = "8px";
|
||||
wrap.style.flexWrap = "wrap";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.style.fontWeight = "600";
|
||||
name.textContent = label;
|
||||
|
||||
const select = document.createElement("select");
|
||||
for (const option of options) {
|
||||
const item = document.createElement("option");
|
||||
item.value = option.key;
|
||||
item.textContent = option.label;
|
||||
item.selected = option.key === chosen;
|
||||
select.append(item);
|
||||
}
|
||||
select.addEventListener("change", () => onPick(select.value));
|
||||
wrap.append(name, select);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 산출 조건 구역 — 기초자료 탭 맨 위에 선다.
|
||||
*
|
||||
* ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이
|
||||
* 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리).
|
||||
*/
|
||||
export function drawFactorChoices(
|
||||
body: HTMLElement,
|
||||
data: FactorChoicesDto,
|
||||
projectId: string,
|
||||
reload: () => void,
|
||||
): void {
|
||||
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
|
||||
|
||||
for (const row of data.ranges) {
|
||||
const title = `${row.work_item_name} 작업효율(${row.factor})`;
|
||||
body.append(
|
||||
picker(title, row.options, row.chosen, (key) => {
|
||||
void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then(
|
||||
reload,
|
||||
);
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
note(
|
||||
`품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` +
|
||||
(row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"),
|
||||
),
|
||||
);
|
||||
for (const line of row.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
for (const row of data.machines) {
|
||||
body.append(
|
||||
picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => {
|
||||
void saveFactorChoices(projectId, {
|
||||
machine_choices: { [row.work_item_code]: key },
|
||||
}).then(reload);
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
note(
|
||||
row.source === "note"
|
||||
? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다."
|
||||
: "품셈 표가 정한 장비입니다." +
|
||||
(row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."),
|
||||
),
|
||||
);
|
||||
for (const line of row.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
for (const line of data.notes) body.append(note(line));
|
||||
}
|
||||
|
||||
@@ -18,12 +18,15 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_eleme
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
drawBaseDataTab,
|
||||
drawFactorChoices,
|
||||
drawMachineTab,
|
||||
drawPriceSourcesPending,
|
||||
drawPriceSourcesSections,
|
||||
fetchBaseData,
|
||||
fetchFactorChoices,
|
||||
fetchPriceSources,
|
||||
type BaseDataDto,
|
||||
type FactorChoicesDto,
|
||||
type PriceSourcesDto,
|
||||
} from "./B09_Estimation_UI_BaseData";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
@@ -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);
|
||||
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
|
||||
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user