From 6f7ed9d09c7a6eeb6040da745685c08390a78c45 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 17:39:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EC=9B=90=EB=8B=A8=EC=9C=84=C2=B7?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EC=B4=9D=EA=B4=84=C2=B7=EC=9D=B8=EA=B3=84?= =?UTF-8?q?=EB=8F=84=20=EC=96=91=EC=8B=9D=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20build=5Ftable=20=EC=9D=B4=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EC=A2=85=EB=A5=98=EB=A5=BC=20=EA=B0=88?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_table 이 양식 있는 종류(찰쌓기) 성분을 양식 풀이 값으로 갈음. 양식 없는 종류는 전개 그대로 - 구조물에 library_item — 원단위 탭 「양식」·구조물도 머리 「양식 있음/없음」으로 가림 - 뒤 단계가 아직 이름으로 찾으므로 돌 줄은 종류 이름으로 넘김(1장 코드 잇기 뒤 걷을 자리) · 자료 출처·근거 문구는 전개 것을 그대로 둠 - 검증 프로젝트 전·후 대조: 구조물 5 중 찰쌓기 2 갈음 · 움직인 값 없음 · 자재총괄 최대 차 7e-15 · 채집석 공제 58.674·잔토 91.75 그대로. Node 가 안 돌면 전개 값 + 사유 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureTemplate.py | 94 +++++++++++++++++++ .../B08_Quantity_Engine_UnitQuantity.py | 17 +++- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 9 +- .../B08_Quantity_UI_StructureSheet.ts | 10 +- .../tester/test_b08_build_table_templates.py | 88 +++++++++++++++++ .../test_b08_structure_template_parity.py | 5 +- 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_b08_build_table_templates.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 515e7ddf..7770e503 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -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: """구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침). diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 53e8c44e..c3a8b5ed 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -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": [ { diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 6ce32804..ce71a8cf 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -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); diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index 108fa636..a68f1dd7 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -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), ); diff --git a/resources/tester/test_b08_build_table_templates.py b/resources/tester/test_b08_build_table_templates.py new file mode 100644 index 00000000..5fb24a33 --- /dev/null +++ b/resources/tester/test_b08_build_table_templates.py @@ -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)) diff --git a/resources/tester/test_b08_structure_template_parity.py b/resources/tester/test_b08_structure_template_parity.py index 25d91c29..c7524e3c 100644 --- a/resources/tester/test_b08_structure_template_parity.py +++ b/resources/tester/test_b08_structure_template_parity.py @@ -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")