From 6f7ed9d09c7a6eeb6040da745685c08390a78c45 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 17:39:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(b08):=20=EC=9B=90=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=C2=B7=EC=9E=90=EC=9E=AC=EC=B4=9D=EA=B4=84=C2=B7=EC=9D=B8?= =?UTF-8?q?=EA=B3=84=EB=8F=84=20=EC=96=91=EC=8B=9D=20=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20build=5Ftable=20=EC=9D=B4=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EC=9E=88=EB=8A=94=20=EC=A2=85=EB=A5=98=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=88=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") From eb12eec35affdb94b6e39984c6291d0461bd432f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 17:52:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EC=8B=9D=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=A6=89=EC=8B=9C=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=C2=B7=20=EC=A0=80=EC=9E=A5=EC=9D=80=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=8B=9C=20=ED=92=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 양식 장의 식 칸을 화면에서 고치면 같은 풀이기(TS)로 왕복 없이 즉시 다시 풂 - [식 저장]은 식만 보내고 서버가 Node 로 다시 푼 값을 돌려줌 — 브라우저 값을 받아 적지 않음 - 고친 식은 프로젝트 장 단위(산출 조건 structure_formula_overrides) · 양식 식과 같거나 비면 지움 - 원단위·자재총괄·인계·유토 입력(build_table)도 같은 고친 식을 봄 · 고친 줄 출처 user(「사용자 식」) - 틀린 식도 막지 않고 저장하되 줄마다 오류를 돌려줌 · 양식에 없는 줄 차례는 400 - 개인 라이브러리 저장 단추는 4장(라이브러리 저장소) 몫으로 남김 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureTemplate.py | 108 +++++++-- .../B08_Quantity_Engine_UnitQuantity.py | 11 +- B08_Quantity/B08_Quantity_Router_Material.py | 7 +- .../B08_Quantity_Router_StructureSheet.py | 77 ++++++ .../B08_Quantity_UI_StructureSheet.ts | 229 +++++++++++++++++- .../tester/test_b08_structure_sheet_router.py | 70 ++++++ 6 files changed, 477 insertions(+), 25 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 7770e503..fa25553b 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -5,6 +5,8 @@ ⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이 수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」). +⚠ 사용자가 고친 식(PLAN 3장 ⑤)은 **프로젝트 장 단위** — 산출 조건 `quantity` 구획의 + `structure_formula_overrides` = `{장 이름: {차례: {"formula": 식}}}`. 고친 줄은 출처 `user`. """ from __future__ import annotations @@ -16,6 +18,9 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] 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` — 그 종류는 지금 전개(고정형 모양)로 섬.""" @@ -65,10 +70,26 @@ def template_vars( return values -def template_sheet(template: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: - """식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로, 제원만 끼움.""" +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 []: + edit = (overrides or {}).get(str(row["seq"])) or {} + formula = str(edit.get("formula") or "").strip() + if formula and formula != row.get("formula"): + row = {**row, "formula": formula, "source": "user", "default_formula": row["formula"]} + 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": template.get("rows") or [], + "rows": overridden_rows(template, overrides), "vars": values, "tables": template.get("tables") or {}, } @@ -79,10 +100,10 @@ _PER_LENGTH_UNITS = frozenset({"m"}) def _library_rows( - template: dict[str, Any], solved: list[dict[str, Any]], billing: float + body: dict[str, Any], solved: list[dict[str, Any]], billing: float ) -> list[dict[str, Any]]: - """풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬까지 실음(명세 13장).""" - by_seq = {row["seq"]: row for row in template.get("rows") or []} + """풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬·출처까지 실음(명세 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 {} @@ -94,6 +115,8 @@ def _library_rows( "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 "", @@ -113,7 +136,7 @@ def _downstream_name(result: dict[str, Any]) -> str: """뒤 단계(자재총괄·인계)가 찾는 이름 — 돌 줄만 종류 이름으로. ⚠ 명세 13장 Ⓒ 는 이름 고정 「돌」 + `spec` 이지만, 자재총괄·할증표·인계가 아직 **이름으로** - 찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). 그 일감이 끝나면 이 갈음을 걷을 것. + 찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). ⏳ 부채 — 걷는 시점은 PLAN 10장. """ if result["name"] == "돌" and result.get("spec"): return str(result["spec"]) @@ -125,15 +148,17 @@ def replace_with_templates( inputs: list[dict[str, Any]], section_modes: dict[float, str] | None, rubble_base_thickness_m: float | None, + formula_overrides: dict[str, Any] | None = None, ) -> None: """`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서). ⚠ 전개가 성분을 하나도 못 낸 구조물(표에 없는 뒷길이 등)은 그대로 둠 — 전개 사유가 이미 드러냄. - ⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게(브레인 챙길 것 ②). + ⚠ 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_StructureSheet import sheet_key, 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 @@ -160,23 +185,24 @@ def replace_with_templates( "options": options, } values = template_vars(template, structure, slope_of(sheet)[0], settings) - targets.append((quantity, template_sheet(template, values))) + overrides = (formula_overrides or {}).get(sheet_key(sheet)) + 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): + 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 []} + 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]: @@ -187,14 +213,20 @@ def replace_with_templates( quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}") continue name = _downstream_name(result) + user = source.get("source") == "user" + basis = ( + f"사용자 식 = {source.get('formula')} (양식 식 {source.get('default_formula')})" + 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"]), str(source.get("destination") or ""), - engine_basis.get(name) or str(source.get("formula_text") or ""), - source=engine_source.get(name, ""), + basis, + source="user" if user else engine_source.get(name, ""), spec="" if name != result["name"] else str(result.get("spec") or ""), ) ) @@ -209,10 +241,12 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N ⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `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 = load_template(str(sheet.get("type_id") or "")) @@ -227,22 +261,60 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N "options": sheet.get("options"), } values = template_vars(template, structure, slope_of(sheet)[0], settings) - targets.append((sheet, template_sheet(template, values))) + body = template_sheet(template, values, all_overrides.get(sheet.get("key"))) + targets.append((sheet, body)) if not targets: return solved = evaluate_sheets([body for _sheet, body in targets]) - for index, (sheet, _body) in enumerate(targets): + for index, (sheet, body) in enumerate(targets): template = load_template(str(sheet.get("type_id") or "")) 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 - sheet["rows"] = _library_rows(template, solved[index], billing) + sheet["rows"] = _library_rows(body, solved[index], billing) sheet["unpriced_rows"] = [ row["name"] for row in sheet["rows"] if row["unit_amount"] is None and not row["skipped"] ] sheet["library_item"] = {"type_id": template.get("type_id"), "name": template.get("name")} + sheet["formula_sheet"] = body + + +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]: + """장 하나의 고친 식을 갈아 끼운 **새 전체 값**과 바뀐 줄 수. + + ⚠ 빈 식·양식 식과 같은 식은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면 + 양식이 바뀌어도 그 장만 옛 식으로 남음. + ⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게. + """ + 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}") + formula = str(edit.get("formula") or "").strip() + before = (sheet.get(str(seq)) or {}).get("formula") + if not formula or formula == known[seq].get("formula"): + if sheet.pop(str(seq), None) is not None: + changed += 1 + continue + if before != formula: + changed += 1 + sheet[str(seq)] = {"formula": formula} + if sheet: + merged[key] = sheet + else: + merged.pop(key, None) + return merged, changed diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index c3a8b5ed..49924320 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -1510,12 +1510,15 @@ def build_table( ground_types: dict[float, str] | None = None, rubble_base_thickness_m: float | None = None, use_templates: bool = True, + structure_formulas: dict[str, Any] | None = None, ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다. ⭐ 2026-09-13(PLAN 3장 ④-2) — **양식이 있는 종류는 양식 풀이 값으로 성분을 갈음** (`B08_Quantity_Engine_StructureTemplate.replace_with_templates`). 원단위·자재총괄·인계가 구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀. + ⭐ `structure_formulas` — 사용자가 장마다 고친 식(산출 조건 `structure_formula_overrides`, + PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림. """ observed = load_observed_table() # 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다. @@ -1536,7 +1539,13 @@ def build_table( # 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림. from B08_Quantity.B08_Quantity_Engine_StructureTemplate import replace_with_templates - replace_with_templates(quantities, expanded_inputs, section_modes, rubble_base_thickness_m) + replace_with_templates( + quantities, + expanded_inputs, + section_modes, + rubble_base_thickness_m, + structure_formulas, + ) violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 344d1500..fe4e28b8 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -155,6 +155,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: await _section_modes(project_id), await _ground_types(project_id), settings.get("rubble_base_thickness_m"), + # 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤). + structure_formulas=settings.get("structure_formula_overrides"), ) material_table = build_material_table( unit_table, @@ -200,12 +202,14 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]: stored_path = await run_with_connection(get_project_storage_relative_path, project_id) project_root = resolve_stored_project_path(stored_path) structures, names, _skipped = _collect_structures(project_root) + settings = quantity_settings(project_root) unit_table = build_unit_table( structures, names, await _section_modes(project_id), await _ground_types(project_id), - quantity_settings(project_root).get("rubble_base_thickness_m"), + settings.get("rubble_base_thickness_m"), + structure_formulas=settings.get("structure_formula_overrides"), ) except Exception: logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id) @@ -247,6 +251,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: await _section_modes(project_id), await _ground_types(project_id), settings.get("rubble_base_thickness_m"), + structure_formulas=settings.get("structure_formula_overrides"), ) material_table = build_material_table( unit_table, diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index f2d19561..f6ac31c9 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -65,6 +65,7 @@ def project_structure_sheets( section_modes, ground_types, settings.get("rubble_base_thickness_m"), + structure_formulas=settings.get("structure_formula_overrides"), ) payload = build_standard_sheets(unit_table, section_modes) # 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장). @@ -112,6 +113,82 @@ class StandardSheetSpecRequest(BaseModel): blinding_concrete: str | None = None +class FormulaEdit(BaseModel): + """고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**.""" + + model_config = ConfigDict(extra="forbid") + + seq: int = Field(ge=1) + formula: str | None = Field(default=None, max_length=2000) + + +class StructureSheetFormulaRequest(BaseModel): + """구조물도 장 하나에서 고친 식들(PLAN 3장 ⑤ · 프로젝트 장 단위).""" + + model_config = ConfigDict(extra="forbid") + + sheet_key: str + rows: list[FormulaEdit] = Field(max_length=200) + + +@router.put("/{project_id}/quantity/structure-sheets/formulas") +async def put_structure_sheet_formulas( + project_id: UUID, payload: StructureSheetFormulaRequest +) -> JSONResponse: + """장 하나의 고친 식을 **산출 조건에 저장**하고 **서버가 같은 풀이기로 다시 푼 값**을 돌려줌. + + ⚠ 브라우저가 푼 값을 받아 적지 않음 — 식만 받고 값은 Node 로 다시 냄(판정 Ⓐ · 데이터 3층). + ⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장). + ⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임. + """ + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( + OVERRIDES_KEY, + load_template, + save_sheet_overrides, + ) + from common_util.common_util_project_settings import quantity_settings, save_section + + project_root = await _project_root(project_id) + if project_root is None: + return _not_found() + sheets = (await _sheets_of(project_id, project_root)).get("sheets") or [] + picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None) + template = load_template(str((picked or {}).get("type_id") or "")) if picked else None + if picked is None or template is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."}, + ) + try: + current = quantity_settings(project_root).get(OVERRIDES_KEY) or {} + merged, changed = save_sheet_overrides( + current, payload.sheet_key, template, [row.model_dump() for row in payload.rows] + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + # ⚠ 통째로 갈아 끼움 — 병합이면 되돌린(지운) 식이 남음(`save_section` replace_keys). + await asyncio.to_thread( + save_section, + project_root, + "quantity", + {OVERRIDES_KEY: merged}, + replace_keys=[OVERRIDES_KEY], + ) + + sheets = (await _sheets_of(project_id, project_root)).get("sheets") or [] + fresh = next((s for s in sheets if s.get("key") == payload.sheet_key), None) or {} + rows = fresh.get("rows") or [] + return JSONResponse( + content={ + "status": "success", + "changed": changed, + "rows": rows, + # 막지 않고 알림 — 어느 줄이 왜 안 섰는지. + "errors": [f"{row['name']}: {row['error']}" for row in rows if row.get("error")], + } + ) + + @router.put("/{project_id}/quantity/structure-sheets/spec") async def put_structure_sheet_spec( project_id: UUID, payload: StandardSheetSpecRequest diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index a68f1dd7..46abb19d 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -7,10 +7,13 @@ * ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음. * ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만. * ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로. + * ⚠ 식 고치기(PLAN 3장 ⑤) — 칸에서 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂(왕복 없음). + * [식 저장] 때는 식만 보내고 **값은 서버가 Node 로 다시 냄**(판정 Ⓐ). 저장은 프로젝트 장 단위. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula"; import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"; import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; import { @@ -32,6 +35,8 @@ export interface StructureSheetRow { source: string; /** 양식 줄만 — 기계가 푸는 식(명세 13장). 비면 고정형(지금 전개). */ formula?: string; + /** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */ + default_formula?: string; destination?: string; rounding?: { mode: string; digits: number } | null; /** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */ @@ -67,8 +72,15 @@ function amountText(row: StructureSheetRow): string { function noteText(row: StructureSheetRow): string { if (row.skipped) return row.reason ?? ""; if (row.error) return `⚠ ${row.error}`; + // 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③). const origin = - row.source === "library" ? "양식" : row.basis_kind === "observed" ? "실무 관측" : "치수 전개"; + row.source === "user" + ? "사용자 식" + : row.source === "library" + ? "양식" + : row.basis_kind === "observed" + ? "실무 관측" + : "치수 전개"; const mode = row.rounding?.mode; return mode && mode !== "none" ? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리` @@ -93,6 +105,8 @@ export interface StructureSheet extends StandardSheetSpec { unpriced_rows: string[]; /** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */ library_item?: { type_id: string; name: string }; + /** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */ + formula_sheet?: FormulaSheet; } export interface StructureSheetsResponse { @@ -114,6 +128,14 @@ const CSS = ` /* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */ .b08-sheet__rows td:nth-child(3) { white-space: pre-line; min-width: 16rem; } .b08-sheet__rows td:nth-child(7) { white-space: normal; max-width: 18rem; } +/* 식 칸 — 고칠 수 있는 칸은 입력으로, 고친 칸은 표시가 남음. */ +.b08-sheet__formula { display: flex; gap: 4px; margin-top: 2px; } +.b08-sheet__formula input { flex: 1 1 auto; min-width: 12rem; font: 12px var(--font-mono, monospace); + padding: 1px 4px; color: var(--color-text); background: var(--color-surface); + border: 1px solid var(--color-border); } +.b08-sheet__formula input.is-changed { border-color: var(--color-accent, #6c8ebf); } +.b08-sheet__formula button { font-size: 11px; padding: 0 6px; cursor: pointer; } +.b08-sheet__actions { display: flex; gap: 8px; align-items: center; } @media (max-width: 900px) { .b08-sheet { flex-direction: column; } .b08-sheet__aside { flex-basis: auto; width: 100%; } @@ -161,6 +183,175 @@ async function putStructureSheetSpec( return { changed: payload.changed ?? 0, notes: payload.notes ?? [] }; } +/** 고친 식 저장 — 식만 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */ +async function putStructureSheetFormulas( + projectId: string, + sheetKey: string, + rows: { seq: number; formula: string | null }[], +): Promise<{ changed: number; errors: string[] }> { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sheet_key: sheetKey, rows }), + }, + ); + const payload = (await response.json().catch(() => ({}))) as { + changed?: number; + errors?: string[]; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return { changed: payload.changed ?? 0, errors: payload.errors ?? [] }; +} + +/** + * 양식 장의 원단위 수량표 — 식 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂. + * `onSave` 는 고친 식만 받음 — 값은 서버가 다시 냄. 되돌린 줄은 빈 식(= 양식 식)으로 감. + */ +function formulaTable( + sheet: StructureSheet, + onSave: (rows: { seq: number; formula: string | null }[]) => Promise, + onDirty: (dirty: boolean) => void, +): HTMLElement { + const body = sheet.formula_sheet as FormulaSheet; + const saved = new Map(sheet.rows.map((row) => [row.no, row.formula ?? ""])); + const defaults = new Map(sheet.rows.map((row) => [row.no, row.default_formula ?? ""])); + const edits = new Map(); + const cells = new Map< + number, + { amount: HTMLElement; note: HTMLElement; input: HTMLInputElement } + >(); + + const wrap = el("div", "b08-grid"); + const scroller = el("div", "b08-grid__scroll"); + const grid = el("table", "b08-grid__table b08-grid__table--summary b08-sheet__rows"); + const headRow = document.createElement("tr"); + for (const label of ["공종", "규격", "산출 근거 · 식", "수량", "단위", "갈 곳", "비고"]) { + headRow.append(el("th", "", label)); + } + const thead = document.createElement("thead"); + thead.append(headRow); + const tbody = document.createElement("tbody"); + + const status = el("span", "b08-grid__caption"); + const saveButton = el("button", "b08-spec__save", "식 저장"); + saveButton.type = "button"; + const discardButton = el("button", "b08-quantity__tab", "고친 것 버리기"); + discardButton.type = "button"; + + // 고친 식으로 장을 다시 풀어 **모든 줄**을 고침 — 앞 줄을 고치면 뒷줄도 따라 바뀜. + const recompute = (): void => { + const results = evaluateSheet({ + ...body, + rows: body.rows.map((row) => + edits.has(row.seq) ? { ...row, formula: edits.get(row.seq), source: "user" } : row, + ), + }); + for (const result of results) { + const cell = cells.get(result.seq); + const row = sheet.rows.find((item) => item.no === result.seq); + if (!cell || !row) continue; + const formula = cell.input.value.trim(); + const local: StructureSheetRow = { + ...row, + unit_amount: result.amount === null ? null : Number(result.amount), + skipped: result.skipped, + reason: result.reason ?? "", + error: result.error ?? "", + source: formula && formula !== defaults.get(result.seq) ? "user" : "library", + }; + cell.amount.textContent = amountText(local); + cell.note.textContent = noteText(local); + cell.input.classList.toggle("is-changed", formula !== saved.get(result.seq)); + } + const dirty = [...cells.entries()].some( + ([seq, cell]) => cell.input.value.trim() !== saved.get(seq), + ); + saveButton.disabled = !dirty; + discardButton.disabled = !dirty; + status.textContent = dirty + ? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈)" + : ""; + onDirty(dirty); + }; + + for (const row of sheet.rows) { + const tr = document.createElement("tr"); + const basis = el("td", "", row.basis.replace(/\*\*/g, "")); + const line = el("div", "b08-sheet__formula"); + const input = document.createElement("input"); + input.type = "text"; + input.value = row.formula ?? ""; + input.title = `양식 식: ${row.default_formula ?? ""}`; + input.addEventListener("input", () => { + const value = input.value.trim(); + if (value === saved.get(row.no)) edits.delete(row.no); + else edits.set(row.no, value || (defaults.get(row.no) ?? "")); + recompute(); + }); + const revert = el("button", "", "양식 식으로"); + revert.type = "button"; + revert.title = "이 줄을 양식 원래 식으로 되돌림"; + revert.addEventListener("click", () => { + input.value = defaults.get(row.no) ?? ""; + input.dispatchEvent(new Event("input")); + }); + line.append(input, revert); + basis.append(line); + const amount = el("td", "", amountText(row)); + const note = el("td", "", noteText(row)); + tr.append( + el("td", "", row.name), + el("td", "", row.spec), + basis, + amount, + el("td", "", row.unit), + el("td", "", DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? ""), + note, + ); + cells.set(row.no, { amount, note, input }); + tbody.append(tr); + } + grid.append(thead, tbody); + scroller.append(grid); + + saveButton.addEventListener("click", () => { + void (async () => { + saveButton.disabled = true; + status.textContent = "저장 중…"; + const rows = [...cells.entries()] + .filter(([seq, cell]) => cell.input.value.trim() !== saved.get(seq)) + .map(([seq, cell]) => { + const value = cell.input.value.trim(); + // 양식 식과 같거나 비면 「고친 적 없음」으로 되돌림. + return { seq, formula: value && value !== defaults.get(seq) ? value : null }; + }); + try { + const errors = await onSave(rows); + status.textContent = errors.length ? `⚠ 저장했으나 안 서는 줄: ${errors.join(" · ")}` : ""; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "식을 저장하지 못함"; + saveButton.disabled = false; + } + })(); + }); + discardButton.addEventListener("click", () => { + edits.clear(); + for (const [seq, cell] of cells) cell.input.value = saved.get(seq) ?? ""; + recompute(); + }); + + const actions = el("div", "b08-sheet__actions"); + actions.append(saveButton, discardButton, status); + saveButton.disabled = true; + discardButton.disabled = true; + wrap.append(scroller, actions); + return wrap; +} + function el( tag: K, className: string, @@ -203,8 +394,8 @@ function table(head: string[], rows: string[][], extraClass = ""): HTMLElement { return scroller; } -/** 장 한 벌의 가운데 — 머리 · 원단위 수량표 · 막힌 사유 · 개소 목록. */ -function sheetBody(sheet: StructureSheet): HTMLElement { +/** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */ +function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement { const main = el("div", "b08-sheet__main"); const head = el("p", "b08-sheet__head"); const total = sheet.billing_total @@ -226,6 +417,8 @@ function sheetBody(sheet: StructureSheet): HTMLElement { main.append( el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"), ); + } else if (editor) { + main.append(editor); } else { main.append( table( @@ -281,6 +474,13 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { wrap.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것")); return wrap; } + // 저장 안 한 식이 조용히 사라지지 않게 — 나갈 때 묻기(자동저장은 안 만듦, CLAUDE.md 5장). + let dirty = false; + window.addEventListener("beforeunload", (event) => { + if (!dirty || !wrap.isConnected) return; + event.preventDefault(); + event.returnValue = "저장 안 한 식이 있음"; + }); const paint = (response: StructureSheetsResponse, memberId: string | null, notes: string[]) => { const sheets = response.sheets ?? []; @@ -341,12 +541,31 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { initialNotes, ), ); - pane.replaceChildren(sheetBody(sheet), aside); + const editor = sheet.formula_sheet + ? formulaTable( + sheet, + async (rows) => { + const result = await putStructureSheetFormulas(projectId, sheet.key, rows); + // 서버가 다시 푼 값으로 **다시 받아** 그림 — 화면 계산을 정본으로 적지 않음. + await load(sheet.members[0]?.structure_id ?? null); + return result.errors; + }, + (next) => { + dirty = next; + }, + ) + : null; + pane.replaceChildren(sheetBody(sheet, editor), aside); }; sheets.forEach((sheet, index) => { const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`); button.type = "button"; - button.addEventListener("click", () => show(index)); + button.addEventListener("click", () => { + // 다른 장으로 가면 고친 식이 사라짐 — 조용히 버리지 않고 물음. + if (dirty && !window.confirm("저장 안 한 식이 있음 — 버리고 다른 장으로 갈까요?")) return; + dirty = false; + show(index); + }); buttons.push(button); tabs.append(button); }); diff --git a/resources/tester/test_b08_structure_sheet_router.py b/resources/tester/test_b08_structure_sheet_router.py index c551f7e1..b361217c 100644 --- a/resources/tester/test_b08_structure_sheet_router.py +++ b/resources/tester/test_b08_structure_sheet_router.py @@ -112,6 +112,76 @@ def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None: assert rows[name]["reason"], rows[name] +def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다( + client: TestClient, project: Path +) -> None: + """PLAN 3장 ⑤ — 프로젝트 장 단위 저장 · 값은 서버가 다시 냄 · 고친 줄은 출처 user.""" + sheet = _sheet(client) + rows = {row["name"]: row for row in sheet["rows"]} + 원래 = rows["모르터"] + saved = client.put( + f"{SHEETS}/formulas", + json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": "A*0.018"}]}, + ) + assert saved.status_code == 200, saved.text + assert saved.json()["changed"] == 1 and saved.json()["errors"] == [] + + after = {row["name"]: row for row in _sheet(client)["rows"]} + assert after["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"] * 2) + assert after["모르터"]["source"] == "user" + assert after["모르터"]["default_formula"] == 원래["formula"] + assert after["돌쌓기"]["source"] == "library" # 안 고친 줄은 그대로 양식 + stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) + assert stored["quantity"]["structure_formula_overrides"][sheet["key"]] == { + str(원래["no"]): {"formula": "A*0.018"} + } + + # 원단위·자재총괄(build_table)도 같은 고친 식을 봄 — 구조물도와 안 갈림. + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table + from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + + structures, names, _ = _collect_structures(str(project)) + table = build_table( + structures, + names, + None, + None, + None, + structure_formulas=stored["quantity"]["structure_formula_overrides"], + ) + mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터") + assert mortar["amount"] == pytest.approx(after["모르터"]["unit_amount"] * 10.0) + assert mortar["source"] == "user" and "사용자 식" in mortar["basis"] + + # 되돌리기 — 빈 식이면 양식 식으로, 저장 칸에서도 지워짐. + reverted = client.put( + f"{SHEETS}/formulas", + json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": None}]}, + ) + assert reverted.status_code == 200 and reverted.json()["changed"] == 1 + back = {row["name"]: row for row in _sheet(client)["rows"]} + assert back["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"]) + assert back["모르터"]["source"] == "library" + stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) + assert stored["quantity"]["structure_formula_overrides"] == {} + + +def test_틀린_식도_막지_않고_저장하되_오류를_돌려준다(client: TestClient) -> None: + sheet = _sheet(client) + seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터") + saved = client.put( + f"{SHEETS}/formulas", + json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*없는칸"}]}, + ) + assert saved.status_code == 200, saved.text + assert any("모르터" in error and "모르는 이름" in error for error in saved.json()["errors"]) + bad = client.put( + f"{SHEETS}/formulas", + json={"sheet_key": sheet["key"], "rows": [{"seq": 999, "formula": "1"}]}, + ) + assert bad.status_code == 400 + + def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None: 기본 = _unit_amount(_sheet(client), "기초잡석") (project / "project_settings.json").write_text(