From 42909a01b9420fd87c2427873acba89ed06dba85 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 17:27:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B3=A0=EC=A0=95=ED=98=95=20?= =?UTF-8?q?=EC=9E=A5=20=EC=82=AC=EC=9C=A0(=E3=89=B0)=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=EB=8B=A4=EB=A6=84(=E3=89=B1)=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=84=EA=B0=9C=20=EC=82=AC=EC=9C=A0=EB=A5=BC=20=EA=B1=B7?= =?UTF-8?q?=EA=B3=A0=20=ED=95=AD=EB=AA=A9=20=EC=82=AC=EC=9C=A0=EB=A5=BC=20?= =?UTF-8?q?=EB=9D=84=EC=9B=80(=EB=82=A8=EA=B8=B4=20=EC=A0=84=EA=B0=9C=20?= =?UTF-8?q?=EC=A4=84=EC=97=90=20=EA=B1=B8=EB=A6=B0=20=EC=82=AC=EC=9C=A0?= =?UTF-8?q?=EB=A7=8C=20=EB=A8=B8=EB=A6=AC=20=EB=8B=AC=EC=95=84=20=EB=91=A0?= =?UTF-8?q?)=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EC=A0=9C=EB=AA=A9=20H=20?= =?UTF-8?q?=EC=99=80=20=EC=9E=A5=20=EC=A0=9C=EC=9B=90=20H=20=EA=B0=80=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B4=EB=A9=B4=20=EB=B9=A8=EA=B0=84=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20+=20=E3=80=8C=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EB=8B=A4=EB=A6=84=E3=80=8D=20=EB=B0=B0=EC=A7=80=20+=20?= =?UTF-8?q?=E3=80=8C=EB=B2=BD=20=EC=88=98=EB=9F=89=EC=9D=80=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EB=B0=95=ED=9E=8C=20=EA=B0=92=20=C2=B7=20=ED=86=A0?= =?UTF-8?q?=EA=B3=B5=C2=B7=EC=82=AC=ED=86=A0=20=EA=B3=B5=EC=A0=9C=EB=8A=94?= =?UTF-8?q?=20=EC=9E=A5=20=EC=A0=9C=EC=9B=90=EC=9C=BC=EB=A1=9C=20=EC=85=88?= =?UTF-8?q?=E3=80=8D=20=EC=82=AC=EC=9C=A0(=EA=B8=88=EC=95=A1=EC=9D=80=20?= =?UTF-8?q?=EC=84=AC=20=C2=B7=20=EB=A7=89=EC=A7=80=20=EC=95=8A=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 936be972 화면: 돌쌓기(찰) H=2.5 장에 H=2.0 호표 → 배지 「규격 다름 H2 ↔ H2.5」 · 테두리 · 사유 첫 줄 규격 다름 + 원문 시점 수량 · 뒷길이·돌 종류 사유 걷힘 · 되돌려 전부 같음 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureTemplate.py | 51 ++++++++++ .../B08_Quantity_UI_StructureSheet.ts | 8 ++ .../tester/test_b08_fixed_sheet_notes.py | 96 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 resources/tester/test_b08_fixed_sheet_notes.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 895eafcd..149af2f9 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Any @@ -170,6 +171,49 @@ def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> ] +#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름. +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]]: @@ -314,6 +358,8 @@ def replace_with_templates( 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) @@ -383,6 +429,11 @@ def apply_templates( 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"), diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index 22394443..72e5c75a 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -89,6 +89,8 @@ export interface StructureSheet extends StandardSheetSpec { }; /** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */ formula_sheet?: FormulaSheet; + /** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */ + spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null; /** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */ var_candidates?: { name: string; @@ -260,6 +262,12 @@ function sheetBody( // 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음). el("span", "b08-grid__caption", sheet.unit_label), ); + // ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음. + if (sheet.spec_mismatch) { + const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch; + head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`)); + main.classList.add("b08-unit__manual"); + } // 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭. main.append(head, figureSection(sheet.figure, sheet.figure_reason)); if (!sheet.rows.length) { diff --git a/resources/tester/test_b08_fixed_sheet_notes.py b/resources/tester/test_b08_fixed_sheet_notes.py new file mode 100644 index 00000000..07ffa003 --- /dev/null +++ b/resources/tester/test_b08_fixed_sheet_notes.py @@ -0,0 +1,96 @@ +"""㉰ 고정형 장 사유 · ㉱ 규격 다름 (2026-09-14 브레인 판정). + +㉰ 고정형을 가져온 장에 **전개 사유(뒷길이 표준·돌 종류…)가 그대로 남아** 고정형 줄과 안 맞음 = 거짓 사유 → + 항목 사유(원문 시점 수량·가산 행)를 띄우고 전개 사유는 **남긴 전개 줄(토공·버림·기초잡석·채집석)에 걸린 것만** 둠. +㉱ H=2.0 호표를 H=2.5 벽에 쓰면 벽 수량은 박힌 값(H2.0) · 토공·사토 공제는 장 제원(H2.5) — 밑수가 갈림 → + 미확정과 같은 급으로 보임(빨간 테두리·「규격 다름」 배지) · 금액은 섬 · 막지 않음. +""" + +from __future__ import annotations + +from pathlib import Path + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets +from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table + +ROOT = Path(__file__).resolve().parents[2] +HOPYO = { + "no": 6, + "source_code": "B00010", + "name": "기슭막이(깬잡석,찰쌓기 L3=45m)", + "spec": "H=2.0m, 채집", + "unit": "M", + "basis": [], + "contract_rows": 0, + "rows": [ + { + "name": "깬잡석찰쌓기", + "spec": "L3=45", + "amount": 2.09, + "unit": "M2", + "remark": "", + "source_code": "", + }, + { + "name": "공구손료", + "spec": "노무비의 %", + "amount": 2, + "unit": "%", + "remark": "", + "source_code": "", + }, + ], +} +NAMES = {"masonry_wet": "돌쌓기(찰)"} + + +def _sheet(height: float) -> dict: + wall = StructureInstance.model_validate( + { + "structure_id": "a", + "type_id": "masonry_wet", + "placement": "interval", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": height, "foundation": "기초유"}, + } + ).model_dump() + templates = { + "masonry_wet": { + **recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"), + "code": "AX-ST-0000abcd", + } + } + table = build_table([wall], NAMES, {}, {}, None, structure_templates=templates) + payload = build_standard_sheets(table, {}) + apply_templates(payload, {}, templates) + return payload["sheets"][0] + + +def test_고정형_장은_항목_사유를_띄우고_남의_사유를_걷는다() -> None: + notes = " / ".join(_sheet(2.0)["notes"]) + assert "원문 시점 수량" in notes and "가산 행 1줄" in notes + assert "돌 종류를 안 골라" not in notes and "뒷채움 폭" not in notes + + +def test_규격이_다르면_미확정_급으로_밑수_갈림을_알린다() -> None: + sheet = _sheet(2.5) + mismatch = sheet["spec_mismatch"] + assert (mismatch["item_height_m"], mismatch["sheet_height_m"]) == (2.0, 2.5) + assert "규격 다름" in sheet["notes"][0] + assert "토공·사토 공제는 장 제원(H=2.5m)" in sheet["notes"][0] + assert any(row["amount"] for row in sheet["rows"]) # 금액·수량은 막지 않음 + + +def test_규격이_같으면_아무것도_안_뜬다() -> None: + sheet = _sheet(2.0) + assert not sheet.get("spec_mismatch") + assert not any("규격 다름" in note for note in sheet["notes"]) + + +def test_화면이_규격_다름을_빨간_배지로() -> None: + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(encoding="utf-8") + assert "spec_mismatch" in ui and "규격 다름" in ui and "b08-unit__badge" in ui