Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py
T
2026-09-14 17:27:53 +09:00

493 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물도 **양식형 항목** 읽기 — 양식 파일 + 구조물 제원 → 식 풀이기에 넘길 장 한 벌.
양식은 `resources/library_structure/<type_id>.json`(4장 프로그램 기본 자리 · 명세 13장 식 칸 계약).
풀이는 여기 없음 — `B08_Quantity_Engine_Formula.evaluate_sheets`(TS 한 벌을 Node 로)가 풂.
⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이
수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」).
⚠ 사용자가 고친 식(PLAN 3장 ⑤)은 **양식 + 프로젝트 단위** — 산출 조건 `quantity` 구획의
`structure_formula_overrides` = `{양식 type_id: {차례: {"formula": 식}}}`. 고친 줄은 출처 `user`.
장 이름(제원 조합)에 묶지 않음 — 높이를 고치면 식이 사라짐. 제원별로 달리 쓰려면 `when` 칸.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import standard_back_length
ROOT = Path(__file__).resolve().parents[1]
#: 양식 칸 `default_from` — 빈 뒷길이를 뒷길이 표준표 하한으로(전개와 한 벌).
STANDARD_BACK_LENGTH = "standard_back_length"
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
#: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`).
OVERRIDES_KEY = "structure_formula_overrides"
def load_template(type_id: str) -> dict[str, Any] | None:
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
path = TEMPLATE_DIR / f"{type_id}.json"
if not path.is_file():
return None
return json.loads(path.read_text(encoding="utf-8"))
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
"""프로젝트에 박힌 양식(4장 가져오기) → 없으면 프로그램 기본.
⛔ 개인·회사 단은 여기서 안 읽음 — 가져온 것만 `templates` 로 옴(여는 사람마다 안 갈리게).
"""
return (templates or {}).get(type_id) or load_template(type_id)
def _typed(value: Any, default: Any) -> Any:
"""제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글."""
if isinstance(default, (int, float)) and not isinstance(default, bool):
try:
return float(value)
except (TypeError, ValueError):
return default
return str(value)
def template_vars(
template: dict[str, Any],
structure: dict[str, Any],
judged_slope: float,
settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""양식이 적은 제원 칸을 구조물·산출 조건에서 채움. 빈 칸은 양식 기본값.
⚠ 전면 기울기는 **전개가 판정한 값**을 받음 — 표준경사 판정(성절토·직고)은 식 언어 밖이라
판정 한 벌(`face_slope_ratio`)을 그대로 씀.
"""
options = structure.get("options") or {}
values: dict[str, Any] = {}
for name, spec in (template.get("vars") or {}).items():
default = spec.get("default")
source = spec.get("source")
if source == "judged_face_slope":
values[name] = float(judged_slope)
continue
if source:
values[name] = float(structure.get(source) or 0.0)
continue
if "option" in spec:
raw = options.get(spec["option"])
else:
raw = (settings or {}).get(spec.get("setting"))
if raw in (None, "") and spec.get("default_from") == STANDARD_BACK_LENGTH:
# 안 고른 뒷길이 — 전개와 같은 표준표 하한(`standard_back_length`, 브레인 판정 ㉮).
wet = template.get("type_id") != "masonry_dry"
height = float(structure.get("height_m") or 0.0)
values[name] = float(standard_back_length(wet=wet, height_m=height)[0])
continue
values[name] = default if raw in (None, "") else _typed(raw, default)
return values
#: 반올림 갈래 — 엑셀 대응(명세 13장 대응표). 사유·근거 문구에 엑셀 이름으로 보임.
ROUNDING_WORDS = {
"none": "안 함",
"floor": "INT",
"trunc": "ROUNDDOWN",
"round": "ROUND",
"ceil_away": "ROUNDUP",
"ceil": "위로(엑셀 없음)",
"round_half_even": "짝수 반올림(엑셀 없음)",
}
def rounding_key(rounding: dict[str, Any] | None) -> tuple[str, int]:
"""같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나."""
mode = str((rounding or {}).get("mode") or "none")
return (mode, 0) if mode == "none" else (mode, int((rounding or {}).get("digits") or 0))
def _edit_of(row: dict[str, Any], edit: dict[str, Any]) -> dict[str, Any]:
"""저장된 고침 한 줄에서 **양식과 다른 칸만** — 식·반올림. 같으면 뺌(「고친 적 없음」)."""
changes: dict[str, Any] = {}
formula = str(edit.get("formula") or "").strip()
if formula and formula != row.get("formula"):
changes["formula"] = formula
rounding = edit.get("rounding")
if rounding and rounding_key(rounding) != rounding_key(row.get("rounding")):
mode, digits = rounding_key(rounding)
changes["rounding"] = {"mode": mode, "digits": digits}
return changes
def overridden_rows(
template: dict[str, Any], overrides: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""양식 줄에 **사용자가 고친 식·반올림**을 얹음 — 고친 줄은 출처 `user`, 원래 값은 따로."""
rows: list[dict[str, Any]] = []
for row in template.get("rows") or []:
changes = _edit_of(row, (overrides or {}).get(str(row["seq"])) or {})
if changes:
row = {
**row,
**changes,
"source": "user",
"default_formula": row.get("formula"),
"default_rounding": row.get("rounding"),
}
rows.append(row)
return rows
def template_sheet(
template: dict[str, Any], values: dict[str, Any], overrides: dict[str, Any] | None = None
) -> dict[str, Any]:
"""식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로(고친 식은 얹음), 제원만 끼움."""
return {
"rows": overridden_rows(template, overrides),
"vars": values,
"tables": template.get("tables") or {},
}
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
_PER_LENGTH_UNITS = frozenset({"m"})
#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음).
#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(6,037,712원 실측).
#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음).
#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) ·
#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것.
KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석")
KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)"
def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]:
"""목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로."""
names = {str(row.get("name") or "") for row in template_rows}
return [
row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names
]
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
MISMATCH_HEAD = "⚠ 규격 다름"
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
def _is_fixed(template: dict[str, Any]) -> bool:
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
rows = template.get("rows") or []
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
def fixed_notes(
template: dict[str, Any], engine_notes: list[str], height: Any
) -> tuple[list[str], dict[str, float] | None]:
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
전개 사유(뒷길이 표준·돌 종류…)는 고정형 줄과 안 맞아 거짓 사유 → 걷음. 남긴 줄(터파기·버림…)을
말하는 것만 머리를 달아 둠. 두 번 불려도(원단위 → 구조물도) 같은 값이 나오게 함.
"""
item = [part for part in str(template.get("note") or "").split(" · ") if part]
kept: list[str] = []
for note in engine_notes:
if note in item or note.startswith(MISMATCH_HEAD):
continue
if note.startswith(KEPT_NOTE_HEAD):
kept.append(note)
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
kept.append(KEPT_NOTE_HEAD + note)
notes = list(dict.fromkeys(item + kept))
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
return notes, None
item_h, sheet_h = float(found.group(1)), float(height)
notes.insert(
0,
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
)
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
def _library_rows(
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
) -> list[dict[str, Any]]:
"""풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬·출처까지 실음(명세 13장)."""
by_seq = {row["seq"]: row for row in body.get("rows") or []}
rows: list[dict[str, Any]] = []
for result in solved:
source = by_seq.get(result["seq"]) or {}
unit_amount = float(result["amount"]) if result.get("amount") is not None else None
rows.append(
{
"no": result["seq"],
"name": result["name"],
"spec": result.get("spec") or "",
"basis": source.get("formula_text") or "",
"formula": source.get("formula") or "",
# 되돌릴 자리 — 고친 줄만 원래 양식 식이 따로 옴.
"default_formula": source.get("default_formula") or source.get("formula") or "",
"unit_amount": unit_amount,
"amount": None if unit_amount is None else unit_amount * billing,
"unit": source.get("unit") or "",
"basis_kind": "derived",
"source": source.get("source") or "library",
"destination": source.get("destination") or "",
"rounding": source.get("rounding"),
"default_rounding": source.get("default_rounding", source.get("rounding")),
"skipped": bool(result.get("skipped")),
"reason": result.get("reason") or "",
"error": result.get("error") or "",
}
)
return rows
def _user_basis(row: dict[str, Any]) -> str:
"""사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로."""
parts = []
if row.get("formula") != row.get("default_formula"):
parts.append(f"사용자 식 = {row.get('formula')} (양식 식 {row.get('default_formula')})")
if rounding_key(row.get("rounding")) != rounding_key(row.get("default_rounding")):
parts.append(
f"사용자 반올림 = {_rounding_words(row.get('rounding'))}"
f" (양식 {_rounding_words(row.get('default_rounding'))})"
)
return " · ".join(parts)
def _rounding_words(rounding: dict[str, Any] | None) -> str:
mode, digits = rounding_key(rounding)
word = ROUNDING_WORDS.get(mode, mode)
return word if mode == "none" else f"{word} {digits}자리"
def replace_with_templates(
quantities: list[Any],
inputs: list[dict[str, Any]],
section_modes: dict[float, str] | None,
rubble_base_thickness_m: float | None,
formula_overrides: dict[str, Any] | None = None,
templates: dict[str, Any] | None = None,
) -> None:
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
⚠ 전개가 성분을 하나도 못 낸 구조물(표에 없는 뒷길이 등)은 그대로 둠 — 전개 사유가 이미 드러냄.
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게.
⚠ **L=1(m당)로 풀고 연장을 곱함** — 실무 구조물도는 m당 값을 줄마다 반올림하고 뒤 줄이 그
반올림 값을 참조함. 실제 연장으로 풀면 합계를 반올림하게 되어 구조물도 m당 × 연장과 갈림.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role_of
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
for quantity, item in zip(quantities, inputs):
template = template_of(quantity.type_id, templates)
if template is None or not quantity.components:
continue
# 구조물도와 같은 조건 — m당 양식만(`apply_templates` 의 `_PER_LENGTH_UNITS`).
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options)
sheet = {
"type_id": quantity.type_id,
"height_m": quantity.height_m,
"options": options,
"face": face,
"face_reason": face_reason,
}
structure = {"height_m": quantity.height_m, "length_m": 1.0, "options": options}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
overrides = (formula_overrides or {}).get(quantity.type_id)
targets.append((quantity, template_sheet(template, values, overrides)))
if not targets:
return
solved = evaluate_sheets([body for _quantity, body in targets])
for index, (quantity, body) in enumerate(targets):
if solved is None:
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
template = template_of(quantity.type_id, templates) or {}
by_seq = {row["seq"]: row for row in body["rows"]}
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
# 양식이냐는 구조물 단위(`library_item`)로 따로 둠.
engine_source = {c.name: c.source for c in quantity.components}
# 근거 문구도 전개 것을 씀 — 「강도 210 — 안 정해 기본값」·「판정 1:0.3 성토」처럼
# **그 구조물에서 왜 그 값인지**가 들어 있어 양식의 붙박이 설명보다 많이 말함(값은 같음).
# ⚠ 사용자가 식을 고친 줄은 전개 문구가 틀리므로 「사용자 식」 문구로 감.
engine_basis = {c.name: c.basis for c in quantity.components}
components = []
for result in solved[index]:
if result.get("skipped"):
continue
source = by_seq.get(result["seq"]) or {}
if result.get("error"):
quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}")
continue
# 돌 줄도 이름 「돌」 + 규격에 종류 그대로(명세 13장 Ⓒ) — 자재총괄이 규격까지 보고 묶음.
name = str(result["name"])
user = source.get("source") == "user"
basis = (
_user_basis(source)
if user
else engine_basis.get(name) or str(source.get("formula_text") or "")
)
components.append(
Component(
name,
str(source.get("unit") or ""),
float(result["amount"]) * quantity.length_m,
str(source.get("destination") or ""),
basis,
source="user" if user else engine_source.get(name, ""),
spec=str(result.get("spec") or ""),
)
)
quantity.components = components + _kept(
quantity.components, body["rows"], lambda component: component.name
)
if _is_fixed(template):
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id)
def apply_templates(
payload: dict[str, Any],
settings: dict[str, Any] | None = None,
templates: dict[str, Any] | None = None,
) -> None:
"""구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침).
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장).
⚠ Node 풀이가 안 돌면 전개 줄을 두고 **그 사실을 장 사유에 적음** — 조용히 넘기지 않음.
⚠ 장은 제원 조합 하나라 **L=1(m당)** 으로 풂 — 연장은 제원이 아니고 모든 줄이 L 에 비례.
⚠ 화면이 조작 중 **왕복 없이** 다시 풀 수 있게 풀이 장 한 벌(`formula_sheet`)을 함께 실음.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
all_overrides = (settings or {}).get(OVERRIDES_KEY) or {}
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
for sheet in payload.get("sheets") or []:
template = template_of(str(sheet.get("type_id") or ""), templates)
if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS:
for row in sheet.get("rows") or []:
row.setdefault("formula", "")
row["source"] = row.get("source") or "auto"
continue
structure = {
"height_m": sheet.get("height_m"),
"length_m": 1.0,
"options": sheet.get("options"),
}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
body = template_sheet(template, values, all_overrides.get(sheet.get("type_id")))
targets.append((sheet, body))
if not targets:
return
solved = evaluate_sheets([body for _sheet, body in targets])
for index, (sheet, body) in enumerate(targets):
template = template_of(str(sheet.get("type_id") or ""), templates) or {}
if solved is None:
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
members = sheet.get("members") or []
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
library_rows = _library_rows(body, solved[index], billing)
start = max((row["no"] for row in library_rows), default=0)
kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name"))
sheet["rows"] = library_rows + [
{
**row,
"no": start + offset,
"formula": "",
"default_formula": "",
"rounding": None,
"default_rounding": None,
"skipped": False,
"reason": KEPT_ROW_REASON,
"error": "",
}
for offset, row in enumerate(kept, start=1)
]
sheet["unpriced_rows"] = [
row["name"]
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
if _is_fixed(template):
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
template, sheet.get("notes") or [], sheet.get("height_m")
)
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
sheet["library_item"] = {
"type_id": template.get("type_id"),
"name": template.get("name"),
"code": template.get("code"),
"imported_from": template.get("imported_from"),
}
sheet["formula_sheet"] = body
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
sheet["var_candidates"] = [
{
"name": name,
"label": spec.get("label") or name,
"value": body["vars"].get(name),
"candidates": spec["candidates"],
}
for name, spec in (template.get("vars") or {}).items()
if spec.get("candidates")
]
def save_sheet_overrides(
current: dict[str, Any] | None,
key: str,
template: dict[str, Any],
edits: list[dict[str, Any]],
) -> tuple[dict[str, Any], int]:
"""양식 하나(`key` = type_id)의 고친 식·반올림을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
한 줄 고침은 **그 줄의 뜻한 상태 전부**(식·반올림) — 빠진 칸은 양식대로.
⚠ 빈 칸·양식과 같은 값은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
양식이 바뀌어도 그 장만 옛 값으로 남음.
⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게.
"""
known = {row["seq"]: row for row in template.get("rows") or []}
merged = {name: dict(rows) for name, rows in (current or {}).items()}
sheet = merged.get(key, {})
changed = 0
for edit in edits:
seq = int(edit["seq"])
if seq not in known:
raise ValueError(f"양식에 없는 줄 차례: {seq}")
entry = _edit_of(known[seq], edit)
before = sheet.get(str(seq))
if not entry:
if sheet.pop(str(seq), None) is not None:
changed += 1
continue
if before != entry:
changed += 1
sheet[str(seq)] = entry
if sheet:
merged[key] = sheet
else:
merged.pop(key, None)
return merged, changed