feat(b08): 원단위·자재총괄·인계도 양식 값으로 — build_table 이 양식 있는 종류를 갈음

- build_table 이 양식 있는 종류(찰쌓기) 성분을 양식 풀이 값으로 갈음. 양식 없는 종류는 전개 그대로
- 구조물에 library_item — 원단위 탭 「양식」·구조물도 머리 「양식 있음/없음」으로 가림
- 뒤 단계가 아직 이름으로 찾으므로 돌 줄은 종류 이름으로 넘김(1장 코드 잇기 뒤 걷을 자리) ·
  자료 출처·근거 문구는 전개 것을 그대로 둠
- 검증 프로젝트 전·후 대조: 구조물 5 중 찰쌓기 2 갈음 · 움직인 값 없음 · 자재총괄 최대 차 7e-15 ·
  채집석 공제 58.674·잔토 91.75 그대로. Node 가 안 돌면 전개 값 + 사유

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 17:39:36 +09:00
co-authored by Claude Opus 5
parent 5a619e8a20
commit 6f7ed9d09c
6 changed files with 219 additions and 4 deletions
@@ -109,6 +109,100 @@ def _library_rows(
return rows
def _downstream_name(result: dict[str, Any]) -> str:
"""뒤 단계(자재총괄·인계)가 찾는 이름 — 돌 줄만 종류 이름으로.
⚠ 명세 13장 Ⓒ 는 이름 고정 「돌」 + `spec` 이지만, 자재총괄·할증표·인계가 아직 **이름으로**
찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). 그 일감이 끝나면 이 갈음을 걷을 것.
"""
if result["name"] == "" and result.get("spec"):
return str(result["spec"])
return str(result["name"])
def replace_with_templates(
quantities: list[Any],
inputs: list[dict[str, Any]],
section_modes: dict[float, str] | None,
rubble_base_thickness_m: float | None,
) -> None:
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
⚠ 전개가 성분을 하나도 못 낸 구조물(표에 없는 뒷길이 등)은 그대로 둠 — 전개 사유가 이미 드러냄.
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게(브레인 챙길 것 ②).
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
"""
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
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
for quantity, item in zip(quantities, inputs):
template = load_template(quantity.type_id)
if template is None or not quantity.components:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role(
_section_mode_at(item, section_modes), options.get("side")
)
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": quantity.length_m,
"options": options,
}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
targets.append((quantity, template_sheet(template, values)))
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 = load_template(quantity.type_id) or {}
by_seq = {row["seq"]: row for row in template.get("rows") or []}
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
# 양식이냐는 구조물 단위(`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
name = _downstream_name(result)
components.append(
Component(
name,
str(source.get("unit") or ""),
float(result["amount"]),
str(source.get("destination") or ""),
engine_basis.get(name) or str(source.get("formula_text") or ""),
source=engine_source.get(name, ""),
spec="" if name != result["name"] else str(result.get("spec") or ""),
)
)
quantity.components = components
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id)
def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = None) -> None:
"""구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침).
@@ -468,6 +468,8 @@ class StructureQuantity:
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
ground_type: str | None = None
ground_type_basis: str = ""
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
library_item: str = ""
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
@@ -1507,8 +1509,14 @@ def build_table(
section_modes: dict[float, str] | None = None,
ground_types: dict[float, str] | None = None,
rubble_base_thickness_m: float | None = None,
use_templates: bool = True,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
⭐ 2026-09-13(PLAN 3장 ④-2) — **양식이 있는 종류는 양식 풀이 값으로 성분을 갈음**
(`B08_Quantity_Engine_StructureTemplate.replace_with_templates`). 원단위·자재총괄·인계가
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
"""
observed = load_observed_table()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
expanded_inputs: list[dict[str, Any]] = []
@@ -1524,6 +1532,11 @@ def build_table(
if rubble is not None:
quantity.components.append(rubble)
quantities.append(quantity)
if use_templates:
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import replace_with_templates
replace_with_templates(quantities, expanded_inputs, section_modes, rubble_base_thickness_m)
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
@@ -1559,6 +1572,8 @@ def build_table(
# ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다.
"ground_type": item.ground_type,
"ground_type_basis": item.ground_type_basis,
# 양식 있음/없음 — 화면이 가림(비면 지금 전개).
"library_item": item.library_item,
"notes": item.notes,
"components": [
{
+8 -1
View File
@@ -90,6 +90,8 @@ export interface UnitQuantityStructure {
start_m?: number | null;
end_m?: number | null;
notes: string[];
/** 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값. */
library_item?: string;
components: {
name: string;
unit: string;
@@ -506,7 +508,12 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
tr.append(textCell(component.basis, "b08-grid__note"));
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개";
// 양식으로 선 구조물은 「양식」 — 양식 있음/없음이 조용히 섞이지 않게(PLAN 3장 ④-2).
const kind = structure.library_item
? "양식"
: component.basis_kind === "observed"
? "실무 관측"
: "치수 전개";
tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}` : kind));
markRow(tr, UNIT_QUANTITY_KEYS);
body.append(tr);
@@ -91,6 +91,8 @@ export interface StructureSheet extends StandardSheetSpec {
}[];
notes: string[];
unpriced_rows: string[];
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
library_item?: { type_id: string; name: string };
}
export interface StructureSheetsResponse {
@@ -209,7 +211,13 @@ function sheetBody(sheet: StructureSheet): HTMLElement {
? ` · 합 ${num(sheet.billing_total, 2)}${sheet.billing_unit}`
: "";
head.append(
el("span", "", `${sheet.title}${sheet.member_count}개소${total}`),
el(
"span",
"",
`${sheet.title}${sheet.member_count}개소${total} · ` +
// 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음.
(sheet.library_item ? `양식 「${sheet.library_item.name}` : "양식 없음(지금 전개)"),
),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
@@ -0,0 +1,88 @@
"""`build_table` 이 양식 있는 종류를 양식으로 갈음 — 값은 전개와 같고 흐름은 그대로.
2026-09-13, PLAN 3 -2. 겨누는
찰쌓기 성분이 양식 풀이 값으로 · 구조물에 `library_item` 붙음(양식 있음/없음)
·이름·규격· 곳이 전개와 같음 자재총괄·토공집계 갈래가 바뀜(브레인 챙길 )
양식 없는 종류(메쌓기) 전개 그대로 · `library_item`
배합 성분이 자재총괄로 새지 않음(명세 6·15)
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
build_table as build_material_table,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS, build_table # noqa: E402
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _structures() -> list[dict]:
return [
{
"structure_id": "wet",
"type_id": "masonry_wet",
"start_m": 80.0,
"end_m": 90.0,
"options": {
"height_m": 2.5,
"length_m": 10,
"foundation": "기초유",
"stone_kind": "야면석·호박돌",
},
},
{
"structure_id": "dry",
"type_id": "masonry_dry",
"start_m": 140.0,
"end_m": 150.0,
"options": {"height_m": 2, "length_m": 10, "back_len_cm": 35},
},
]
def test_양식_갈음은_값을_안_움직인다() -> None:
names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"}
before = build_table(_structures(), names, None, None, 0.2, use_templates=False)
after = build_table(_structures(), names, None, None, 0.2)
wet_before, dry_before = before["structures"]
wet_after, dry_after = after["structures"]
assert wet_after["library_item"] == "돌쌓기(찰)"
assert dry_after["library_item"] == "" # 양식 없음 — 전개 그대로
assert dry_after["components"] == dry_before["components"]
assert len(wet_after["components"]) == len(wet_before["components"])
for got, want in zip(wet_after["components"], wet_before["components"]):
assert (got["name"], got["unit"], got["destination"], got["spec"]) == (
want["name"],
want["unit"],
want["destination"],
want["spec"],
)
assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"]
# 돌 줄은 뒤 단계가 찾는 종류 이름으로(1장 코드 잇기 끝나기 전) · 관측 출처는 그대로.
stone = next(c for c in wet_after["components"] if c["name"] == "야면석·호박돌")
assert stone["source"] == "uljin_library"
def test_자재총괄_갈래와_배합_성분() -> None:
names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"}
before = build_material_table(
build_table(_structures(), names, None, None, 0.2, use_templates=False)
)
after = build_material_table(build_table(_structures(), names, None, None, 0.2))
rows = lambda table: {(r["name"], r["unit"]): r["total_amount"] for r in table["rows"]} # noqa: E731
assert rows(after).keys() == rows(before).keys()
for key, value in rows(before).items():
assert rows(after)[key] == pytest.approx(value, rel=1e-9), key
assert not any(name in MIX_COMPONENTS for name, _unit in rows(after))
@@ -82,7 +82,10 @@ def test_양식_값이_전개와_같다(case: dict) -> None:
length = case.pop("length")
structure = _structure(height, length, **case)
engine = build_table([structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble)
# ⚠ 전개만 봄 — `build_table` 은 이제 양식으로 갈음하므로(④-2) 끄지 않으면 자기와 대조가 됨.
engine = build_table(
[structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble, use_templates=False
)
components = engine["structures"][0]["components"]
template = load_template("masonry_wet")