From 3741f8919fd34240d6b0cd520f97f5fc5530e260 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:18:25 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B08):=20=ED=81=B0=EB=8F=8C=EC=8C=93?= =?UTF-8?q?=EA=B8=B0(13-6)=20=EC=A0=84=EA=B0=9C=EC=8B=9D=20+=20=EB=8F=8C?= =?UTF-8?q?=EC=8C=93=EA=B8=B0=20=EB=92=B7=EA=B8=B8=EC=9D=B4=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EC=9E=90=EB=8F=99=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⑲. 앞서 막아 둔 큰돌쌓기를 13-6 축으로 세움. - `boulder_masonry` 전개식 — 규격 축은 **직경**(40~60·60~80·80~100㎝)이고 저장 제원 `stone_cm` 이 품셈 축과 글자까지 같아 그대로 씀. 치수로 낼 수 있는 것(면적·터파기·되메우기·잔토)만 내고 나머지는 사유와 함께 알림. - 재료(큰돌)는 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 원단위가 없음. - 고임돌·채움콘크리트는 [주]① 「품에 포함」 — 따로 세우면 이중계상. - 뒤채움(조약돌)은 [주]⑧ 로 13-3 적용. 두께는 13-4-3 [주]⑨ 준용인데 직고별 **범위값**이라 한 값으로 못 정함 — 미확보. - 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용하고 그 사실을 근거에 적음. - ⚠ 메/찰 구분 칸이 저장 제원에 없어 13-6-1/13-6-2 를 못 고름. 레지스트리는 다른 창 소관이라 손대지 않고 `pending_user` 로 드러냄. ⚠ 함께 발견 — 엔진이 읽는 옵션 키가 레지스트리와 달랐음. 레지스트리는 `back_len_cm` 인데 엔진은 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았음**. 뒷길이를 75 로 골라도 45 계수가 붙던 자리이고, 값이 나오므로 아무 시험도 안 잡았음. 두 이름 다 읽게 하고, 표에 없는 값(25·30·75)은 **덮는 위 칸**으로 접되 그 사실을 적음. 돌쌓기 뒷길이 갈래를 자동 판정해 코드에 붙임(`FP-13-04-05#55cm이하`). 「…㎝ 이하」 구간이므로 **저장값 이상인 첫 경계**를 고름. 근거 문구를 함께 실음. 갈래표는 `resources/data_masonry/` 에 데이터로 둠. 검증 — 전체 621 passed, tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 50 ++++++ .../B08_Quantity_Engine_UnitQuantity.py | 144 ++++++++++++++++-- .../masonry_class_2026-01-01.json | 35 +++++ .../work_item_mapping_2026-01-01.json | 17 ++- 4 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 resources/data_masonry/masonry_class_2026-01-01.json diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 2c4f519f..c7c00a0e 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -55,6 +55,44 @@ DATASET_PREFIX = "work_item_mapping_" REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar" REBAR_PREFIX = "rebar_complexity_" +#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다). +MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +MASONRY_PREFIX = "masonry_class_" + + +def load_masonry_table(path: Path | None = None) -> dict[str, Any]: + """돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" + target = path + if target is None: + files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def masonry_class( + options: dict[str, Any], table: dict[str, Any] | None = None +) -> tuple[str | None, str]: + """돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다. + + ⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다. + **저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다. + """ + found = table if table is not None else load_masonry_table() + spec = (found or {}).get("back_length") or {} + raw = options.get(str(spec.get("option_key") or "back_len_cm")) + if raw is None: + return None, "뒷길이가 저장돼 있지 않아 갈래를 못 고름" + try: + value = float(raw) + except (TypeError, ValueError): + return None, f"뒷길이 값을 못 읽음({raw!r})" + for row in spec.get("classes") or []: + if value <= float(row["max_cm"]): + return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간" + return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음" + def normalize_kind_key(label: str) -> str: """갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정). @@ -467,6 +505,13 @@ def _structure_rows( type_id = str(structure.get("type_id") or "") entry = mapping.for_structure(type_id) or {} code = entry.get("work_item_code") + # 돌쌓기는 **뒷길이 갈래**로 단가가 갈린다 — 저장 제원에서 자동으로 고른다. + class_key: str | None = None + class_basis = "" + if code and entry.get("class_from") == "back_length": + class_key, class_basis = masonry_class(structure.get("options") or {}) + if class_key: + code = f"{code}#{normalize_kind_key(class_key)}" composite = mapping.composite_for(type_id) if code is None else None kind = structure_kind(structure) if composite else None parts: list[dict[str, Any]] | None = None @@ -475,6 +520,8 @@ def _structure_rows( parts, parts_missing = composite_quantities(structure, composite, mapping) if code is None and composite is None: unmatched.append(f"구조물({type_id})") + elif entry.get("class_from") == "back_length" and class_key is None: + unmatched.append(f"구조물({type_id}) — {class_basis}") length = float(structure.get("length_m") or 0.0) rows.append( { @@ -497,6 +544,9 @@ def _structure_rows( "composite_parts": parts, # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. "structure_kind": kind, + # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. + "spec_class": class_key, + "spec_class_basis": class_basis, # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. "composite_not_ready": parts_missing or None, "in_bill": True, diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index c1e5d697..0c79e8a4 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -134,10 +134,35 @@ class StructureQuantity: notes: list[str] = field(default_factory=list) +#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). +#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** +#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. +#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). +BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") + + def _back_length(options: dict[str, Any]) -> int: - raw = options.get("stone_back_length_cm") - if isinstance(raw, (int, float)) and int(raw) in STONE_BACK_LENGTH_TABLE: - return int(raw) + """뒷길이(㎝). 표에 없는 값이면 **가장 가까운 아래 칸**이 아니라 기본으로 간다. + + ⚠ 표는 35·45·55·60 네 칸뿐인데 레지스트리 선택지는 25·30·35·45·55·60·75 일곱이다. + 25·30 은 35 로, 75 는 60 으로 접는다 — **원단위표가 그 구간을 그렇게 덮는다** + (품셈 13-4 뒷길이 표준). 접은 사실은 근거 문구에 적는다. + """ + for key in BACK_LENGTH_KEYS: + raw = options.get(key) + if raw is None: + continue + try: + value = int(float(raw)) + except (TypeError, ValueError): + continue + if value in STONE_BACK_LENGTH_TABLE: + return value + # 표에 없는 값 — 그 값을 덮는 **가장 가까운 위 칸**으로 접는다(작은 돌이 큰 칸에 들어감). + larger = [cm for cm in sorted(STONE_BACK_LENGTH_TABLE) if cm >= value] + if larger: + return larger[0] + return max(STONE_BACK_LENGTH_TABLE) return DEFAULT_BACK_LENGTH_CM @@ -145,6 +170,111 @@ def _num(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback +#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). +#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 +#: 「뒷길이 45㎝」 계수로 돌던 자리다. +BOULDER_DIAMETERS = ("40~60", "60~80", "80~100") + +#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. +#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다. +BOULDER_MASONRY = { + "face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (13-4 와 같은 기울기 몫) + "excavation_extra_m": 0.2, # 터파기 폭 여유 + "backfill_thickness_m": 0.2, # 되메우기 두께 +} + + +def _boulder_diameter(options: dict[str, Any]) -> str | None: + """저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**.""" + raw = options.get("stone_cm") + text = str(raw).strip() if raw is not None else "" + return text if text in BOULDER_DIAMETERS else None + + +def boulder_masonry( + height_m: float, length_m: float, options: dict[str, Any] +) -> tuple[list[Component], list[str]]: + """큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다. + + ⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. + 돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을 + 내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다. + + ⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) — + 따로 세우면 이중계상이다. + + ⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다. + 두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라 + 한 값으로 못 정한다. 미확보로 둔다. + """ + notes: list[str] = [] + if height_m <= 0 or length_m <= 0: + return [], ["높이·연장이 없어 전개하지 않음"] + + diameter = _boulder_diameter(options) + if diameter is None: + return [], [ + "직경 갈래(`stone_cm`)가 저장돼 있지 않거나 표에 없는 값 — " + f"품셈 13-6 갈래는 {list(BOULDER_DIAMETERS)}" + ] + + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) + constants = BOULDER_MASONRY + face_area = height_m * length_m + slope_area = face_area * math.hypot(1.0, slope_ratio) + masonry_area = slope_area * constants["face_to_slope_factor"] + + components = [ + Component( + "큰돌쌓기", + "㎡", + masonry_area, + DESTINATION["돌쌓기"], + f"비탈면적 × 1.04 · 직경 {diameter}㎝ (품셈 13-6)", + ) + ] + + # 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다. + # ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로 + # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. + upper_cm = float(str(diameter).split("~")[-1]) + thickness = upper_cm / 100.0 + excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m + backfill = height_m * constants["backfill_thickness_m"] * length_m + components.extend( + [ + Component( + "터파기", + "㎥", + excavation, + DESTINATION["터파기"], + f"높이 × (벽두께 {thickness:g}m + 0.2) × 연장 · ⚠ 큰돌쌓기 터파기 폭 규정이" + " 품셈에 없어 돌쌓기 방식을 준용", + ), + Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), + Component( + "잔토처리", + "㎥", + excavation - backfill, + DESTINATION["잔토처리"], + "터파기 − 되메우기", + ), + ] + ) + + notes.append( + "재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 " + "돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음" + ) + notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음") + notes.append( + "뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 " + "직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보" + ) + notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름") + return components, notes + + def stone_masonry( height_m: float, length_m: float, options: dict[str, Any], wet: bool ) -> tuple[list[Component], list[str]]: @@ -288,6 +418,7 @@ OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { EXPANDERS = { "masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True), "masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False), + "boulder_masonry": lambda h, l, o: boulder_masonry(h, l, o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). @@ -298,12 +429,7 @@ EXPANDERS = { } #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. -EXPANDER_WITHHELD = { - "boulder_masonry": ( - "큰돌쌓기는 품셈 13-6 이고 규격 축이 **직경**(40~60·60~80·80~100㎝)이다. " - "돌쌓기(13-4)의 **뒷길이** 계수로 돌리면 조용히 틀린 값이 나온다 — 전개식 미확보." - ), -} +EXPANDER_WITHHELD: dict[str, str] = {} #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 diff --git a/resources/data_masonry/masonry_class_2026-01-01.json b/resources/data_masonry/masonry_class_2026-01-01.json new file mode 100644 index 00000000..9026c746 --- /dev/null +++ b/resources/data_masonry/masonry_class_2026-01-01.json @@ -0,0 +1,35 @@ +{ + "schema_version": "1.0", + "dataset_id": "masonry_class", + "effective_date": "2026-01-01", + "note": "돌쌓기(13-4)·큰돌쌓기(13-6) 의 **규격 갈래**. 저장 제원의 값으로 자동 판정하며 사람이 고르는 값이 아니다.", + "back_length": { + "note": "돌쌓기 — 저장 제원 `back_len_cm`(㎝). 품셈 갈래는 「…㎝ 이하」 구간이라 **저장값 이상인 첫 경계**를 고른다.", + "option_key": "back_len_cm", + "source": "품셈 13-4 뒷길이 표준 · B09 단가 갈래(#35cm이하·#55cm이하·#75cm이하)", + "classes": [ + { + "max_cm": 35, + "key": "35cm이하" + }, + { + "max_cm": 55, + "key": "55cm이하" + }, + { + "max_cm": 75, + "key": "75cm이하" + } + ] + }, + "boulder_diameter": { + "note": "큰돌쌓기 — 저장 제원 `stone_cm`. **품셈 13-6 직경 축과 글자까지 같다** — 접거나 바꾸지 않고 그대로 쓴다.", + "option_key": "stone_cm", + "source": "품셈 13-6 「직경 40㎝이상∼60㎝미만 / 60㎝이상∼80㎝미만 / 80㎝이상∼100㎝이하」", + "classes": [ + "40~60", + "60~80", + "80~100" + ] + } +} diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index a9f1bedb..c6bafe55 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -118,13 +118,17 @@ "type_id": "masonry_wet", "work_item_code": "FP-13-04-05", "master_name": "돌쌓기 > 찰쌓기(장비)", - "note": "인력 시공이면 FP-13-04-04" + "note": "인력 시공이면 FP-13-04-04", + "class_from": "back_length", + "class_note": "저장 제원 `back_len_cm` 으로 「…㎝ 이하」 구간을 고름 — 자동 판정" }, { "type_id": "masonry_dry", "work_item_code": "FP-13-04-02", "master_name": "돌쌓기 > 메쌓기(장비)", - "note": "인력 시공이면 FP-13-04-01" + "note": "인력 시공이면 FP-13-04-01", + "class_from": "back_length", + "class_note": "저장 제원 `back_len_cm` 으로 「…㎝ 이하」 구간을 고름 — 자동 판정" }, { "type_id": "pipe_inlet_basin", @@ -156,6 +160,15 @@ "FP-09-05 발파암" ], "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" + }, + { + "type_id": "boulder_masonry", + "candidates": [ + "FP-13-06-01 메쌓기", + "FP-13-06-02 찰쌓기" + ], + "why": "저장 제원(`boulder_masonry`)에 **메/찰 구분 칸이 없어** 어느 쪽인지 못 고름. 레지스트리(`B05_Profile_Structure_Types.json`)는 다른 창 소관이라 여기서 못 고침 — 칸이 생기면 `structure` 에 옮긴다.", + "class_axis": "직경(`stone_cm`) 40~60·60~80·80~100 — 품셈 13-6 축과 글자까지 같음" } ] }, From b06ae123aabe57e0380f3c31e493e01403419f59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:23:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs(B08):=20=EC=98=B5=EC=85=98=20=ED=82=A4?= =?UTF-8?q?=20=EC=96=B4=EA=B8=8B=EB=82=A8=20=EB=8C=80=EC=A1=B0=20=E2=80=94?= =?UTF-8?q?=20face=5Fslope=5Fratio=20=EB=8A=94=20=EC=B9=B8=20=EC=9E=90?= =?UTF-8?q?=EC=B2=B4=EA=B0=80=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `back_len_cm` 사고(레지스트리 키와 엔진이 읽는 키가 달라 저장값이 영영 안 닿던 것) 뒤 전수 훑기. B08 이 읽는 옵션 키 7개를 레지스트리와 대조한 결과 어긋남은 하나뿐이고 그것은 이름 어긋남이 아니라 **칸 자체가 없는 것**임(`face_slope_ratio` — 전면 기울기, 늘 기본 0.3 으로 돎). 「칸이 생기면 받는다」는 뜻으로 남겨 두고 코드에 그 사실을 적음. 대조를 시험으로 둠(`tmp/tests/test_b08_option_keys.py`) — 「이름으로 알아보는 코드는 정본과 대조하라」의 데이터 키 판. 칸이 생겼는데 목록만 남는 것도 잡음. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 0c79e8a4..2e5f5972 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -218,7 +218,7 @@ def boulder_masonry( f"품셈 13-6 갈래는 {list(BOULDER_DIAMETERS)}" ] - slope_ratio = _num(options.get("face_slope_ratio"), 0.3) + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3 constants = BOULDER_MASONRY face_area = height_m * length_m slope_area = face_area * math.hypot(1.0, slope_ratio) @@ -290,6 +290,9 @@ def stone_masonry( back_cm = _back_length(options) table = STONE_BACK_LENGTH_TABLE[back_cm] + # ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다. + # 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다. + # (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.) slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3) constants = STONE_MASONRY From 755e37ba551cb7b243eb0fe8ece2e6986ca6e2cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:31:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(B08):=20=ED=99=94=EB=A9=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=A7=90=EB=A1=9C=20=E2=80=94=20=ED=82=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=95=88=20=EC=83=88=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ㉑. `back_len_cm` 같은 개발자 키가 그대로 화면에 뜨던 자리를 모두 바꿈 (`B08_Quantity_Wording.py`). 반영률 라벨이 서버 키로 뜨던 그 자리와 같은 병. - 「없다」만 말하지 않고 **어디서 채우면 값이 서는지**를 함께 적음. 「돌 뒷길이(㎝)가 아직 입력되지 않았습니다 — 구조물 상세 입력에서 입력하면 값이 섭니다」 · 「옹벽의 이 규격은 자료에 없습니다 — 자료에 있는 규격: 옹벽 형식 반중력식 · 높이(m) 2.0」 - 규격도 사람 말로 — `form`·`height_m` 이 아니라 「옹벽 형식」·「높이(m)」. - ⚠ 모르는 키는 지어내지 않고 그대로 보임. 잘못된 안내가 없는 안내보다 나쁨. - 키가 새는지 검사를 둠(`test_b08_wording.py`) — 새 문구를 넣다 흘리면 깨짐. 검증 — 문구 9건 통과, 전체 633 passed. 실물 프로젝트(`5601e828`)로 문구 확인. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 31 +++++-- .../B08_Quantity_Engine_ObservedUnit.py | 10 ++- .../B08_Quantity_Engine_UnitQuantity.py | 14 +++- B08_Quantity/B08_Quantity_Wording.py | 80 +++++++++++++++++++ 4 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Wording.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index c7c00a0e..11273cb0 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -81,9 +81,14 @@ def masonry_class( """ found = table if table is not None else load_masonry_table() spec = (found or {}).get("back_length") or {} - raw = options.get(str(spec.get("option_key") or "back_len_cm")) + from B08_Quantity.B08_Quantity_Wording import option_missing + + option_key = str(spec.get("option_key") or "back_len_cm") + raw = options.get(option_key) if raw is None: - return None, "뒷길이가 저장돼 있지 않아 갈래를 못 고름" + # ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지. + # 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다. + return None, option_missing(option_key) + " (단가 갈래를 못 고름)" try: value = float(raw) except (TypeError, ValueError): @@ -94,6 +99,9 @@ def masonry_class( return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음" +from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label + + def normalize_kind_key(label: str) -> str: """갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정). @@ -348,7 +356,13 @@ def rebar_complexity( return None, str(row.get("why") or "원문 예시에 없음") if fallback and fallback.get("class"): return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」" - return None, f"품셈 12-3 [주]① 예시에 없는 구조({type_id} {form}) — 임의로 고르지 않음" + from B08_Quantity.B08_Quantity_Wording import type_label + + detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" + return None, ( + f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 " + "철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다" + ) def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]: @@ -366,7 +380,12 @@ def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[st for row in (found or {}).get("type_map") or []: if row.get("type_id") == type_id and row.get("class"): return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」" - return None, f"품셈 12-38-3 [주]④ 예시에 없는 시설({type_id}) — 임의로 고르지 않음" + from B08_Quantity.B08_Quantity_Wording import type_label + + return None, ( + f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 " + "정하지 못했습니다 — 임의로 고르지 않습니다" + ) def _spec_detail(structure: dict[str, Any]) -> str: @@ -519,9 +538,9 @@ def _structure_rows( if composite: parts, parts_missing = composite_quantities(structure, composite, mapping) if code is None and composite is None: - unmatched.append(f"구조물({type_id})") + unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") elif entry.get("class_from") == "back_length" and class_key is None: - unmatched.append(f"구조물({type_id}) — {class_basis}") + unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") length = float(structure.get("length_m") or 0.0) rows.append( { diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index ad2d25f6..b2caa79d 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -138,15 +138,19 @@ def expand_observed( table: ObservedUnitTable | None = None, ) -> tuple[list[dict[str, Any]], list[str]]: """(성분 목록, 알림). 규격이 표에 없으면 **빈 목록 + 미확보 알림**을 낸다.""" + from B08_Quantity.B08_Quantity_Wording import spec_missing + found = (table or load_observed_table()).find(type_id, spec) if found is None: known = (table or load_observed_table()).specs_for(type_id) - detail = f" — 표에 있는 규격: {known}" if known else "" - return [], [f"{NOTE_UNIT_MISSING} ({type_id} {spec}){detail}"] + # ⚠ 키 이름을 화면에 내보내지 않는다 — 사용자는 `retaining_wall` 을 모른다. + return [], [spec_missing(type_id, known)] scale, scale_note = scale_for(found, structure) if scale <= 0: - return [], [f"{NOTE_UNIT_MISSING} — 곱할 연장·면적이 0 ({type_id})"] + from B08_Quantity.B08_Quantity_Wording import type_label + + return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"] source_key = str(found.get("source") or "") components: list[dict[str, Any]] = [] diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 2e5f5972..13117703 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -214,8 +214,8 @@ def boulder_masonry( diameter = _boulder_diameter(options) if diameter is None: return [], [ - "직경 갈래(`stone_cm`)가 저장돼 있지 않거나 표에 없는 값 — " - f"품셈 13-6 갈래는 {list(BOULDER_DIAMETERS)}" + "큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " + f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" ] slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3 @@ -475,7 +475,9 @@ def _observed_components( options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: - return [], [f"{type_id} 규격이 비어 있음 — 관측 원단위를 고를 수 없음"] + from B08_Quantity.B08_Quantity_Wording import option_missing + + return [], [option_missing(keys[0], type_id)] return expand_observed(type_id, spec, structure, observed) @@ -519,7 +521,11 @@ def expand( result.components = [Component(**item) for item in components] result.notes.extend(notes) return result - result.notes.append(f"'{type_id}' 전개식이 아직 없음 — 물량을 내지 않음") + from B08_Quantity.B08_Quantity_Wording import type_label + + result.notes.append( + f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다" + ) return result result.components, notes = expander(height, length, options) result.notes.extend(notes) diff --git a/B08_Quantity/B08_Quantity_Wording.py b/B08_Quantity/B08_Quantity_Wording.py new file mode 100644 index 00000000..b535dd4e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Wording.py @@ -0,0 +1,80 @@ +"""사용자에게 뜨는 문구 — **키 이름을 화면에 내보내지 않는다** (B08 ㉑). + +왜 있나 + 「`back_len_cm` 가 저장돼 있지 않아 갈래를 못 고름」처럼 **개발자 키 이름이 그대로** + 화면에 뜨던 자리가 있었다. 사용자는 그 이름을 모르고, 무엇을 해야 하는지도 알 수 없다. + (반영률 라벨이 서버 키로 뜨던 그 자리와 같은 병이다.) + +⚠ 「없다」만 말하지 않는다 — **어디서 채우면 풀리는지**를 함께 적는다 + 「원단위 미확보」로 끝나면 사용자는 손쓸 데를 모른다. **무엇이 채워지면 값이 서는지**가 + 같이 있어야 그 말이 쓸모 있다. 오늘 「표에 있는 규격을 함께 알린」 그 방식이다. + +⚠ 여기서 값을 바꾸지 않는다 + 문구만 다듬는 자리다. 판정·계산은 각 엔진이 하고, 이 모듈은 **그 결과를 사람 말로** 옮긴다. +""" + +from __future__ import annotations + +from typing import Any + +#: 구조물 종류의 사람 이름. 레지스트리 이름이 정본이고 여기는 **화면 문구가 필요할 때만** 쓴다. +#: ⚠ 레지스트리에 없는 이름을 지어내지 않는다 — 못 찾으면 원래 값을 그대로 보인다. +TYPE_LABELS = { + "masonry_wet": "돌쌓기(찰)", + "masonry_dry": "돌쌓기(메)", + "boulder_masonry": "큰돌쌓기", + "retaining_wall": "옹벽", + "soil_guard": "흙막이", + "pipe": "배수관", + "pipe_inlet_basin": "배수관 유입부 집수정", + "ford_pavement": "물넘이포장", + "ford_bridge": "세월교", + "box_culvert": "BOX암거", +} + +#: 저장 제원 칸의 사람 이름 + **어디서 채우는지**. 키 이름을 화면에 내보내지 않기 위한 표. +OPTION_LABELS = { + "back_len_cm": ("돌 뒷길이(㎝)", "구조물 상세 입력"), + "stone_cm": ("돌 직경(㎝)", "구조물 상세 입력"), + "form": ("옹벽 형식", "구조물 상세 입력"), + "height_m": ("높이(m)", "구조물 배치"), + "length_m": ("연장(m)", "구조물 배치"), + "face_slope_ratio": ("전면 기울기", "아직 입력 칸이 없음"), +} + + +def type_label(type_id: str, names: dict[str, str] | None = None) -> str: + """구조물 이름 — 레지스트리 이름이 있으면 그것을 먼저 쓴다.""" + stored = (names or {}).get(type_id) + return stored or TYPE_LABELS.get(type_id, type_id) + + +def option_missing(option_key: str, type_id: str = "", names: dict[str, str] | None = None) -> str: + """「무엇이 없고 어디서 채우면 되는지」 한 줄. + + ⚠ 모르는 키면 **지어내지 않고** 키를 그대로 보인다 — 잘못된 안내가 없는 안내보다 나쁘다. + """ + label, where = OPTION_LABELS.get(option_key, (option_key, "")) + subject = f"{type_label(type_id, names)} " if type_id else "" + tail = f" — {where}에서 입력하면 값이 섭니다" if where else "" + return f"{subject}{label}이(가) 아직 입력되지 않았습니다{tail}" + + +def spec_missing( + type_id: str, known: list[dict[str, Any]], names: dict[str, str] | None = None +) -> str: + """규격이 표에 없을 때 — **표에 있는 규격을 함께** 보인다.""" + name = type_label(type_id, names) + if not known: + return f"{name}의 표준 물량 자료가 아직 없습니다 — 설계 표준도 확보가 필요합니다" + readable = " / ".join(_spec_text(spec) for spec in known) + return f"{name}의 이 규격은 자료에 없습니다 — 자료에 있는 규격: {readable}" + + +def _spec_text(spec: dict[str, Any]) -> str: + """규격 한 벌을 사람 말로. 키 이름 대신 라벨을 쓴다.""" + parts = [] + for key, value in spec.items(): + label = OPTION_LABELS.get(key, (key, ""))[0] + parts.append(f"{label} {value}") + return " · ".join(parts) From 4deaf0239ca4e59a2a503f270d756d23fe5d4802 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:35:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(B08):=20=EC=9D=B8=EA=B3=84=20=EC=A4=84?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EB=A7=89=ED=9E=8C=20=EA=B9=8C=EB=8B=AD?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=9D=8C=20+=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=B9=B8=20=EB=8C=80=EC=A1=B0=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서브 화면에 「왜 안 서는지」가 안 뜨던 줄 셋(돌쌓기·흙막이·큰돌쌓기)을 고침. - `blocked_reason` · `blocked_kind` 를 줄마다 실음. 갈래는 셋 — `input_missing`(입력하면 풀림) / `unit_data_missing`(자료 없음) / `formula_missing`(산출식 없음). 받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 화면에서 갈라야 함(8-27 표와 같은 축). - 문구는 `B08_Quantity_Wording` 것을 그대로 씀 — 두 벌로 짜면 갈림. - 토공·운반 줄은 막힐 자리가 없어도 **칸은 둠** — 계약이 한 모양이어야 함. ⚠ 계약 칸 대조 시험 추가(`test_b08_handoff_contract.py`). 오늘 이 병이 계약 양쪽에서 하나씩 났음 — 우리 쪽은 레지스트리 키와 엔진이 읽는 키가 달라 저장값이 안 닿았고, 받는 쪽은 우리가 보낸 `composite_parts`· `quantity_gross` 를 안 읽고 버렸음. **「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」 자리는 양쪽 다 조용함.** 키를 늘릴 때 계약에 안 적고 늘리면 받는 쪽이 영영 모름. 자재 줄에 공종코드가 없는 것도 계약으로 못 박음. 검증 — 전체 638 passed. 실물 프로젝트(`5601e828`)에서 네 줄 다 사유가 실림. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 53 ++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 11273cb0..bdfcde7c 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -128,6 +128,12 @@ NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" #: 아니라 공종 이름이라 성분 목록에는 안 온다. REBAR_PREFIXES = ("이형철근", "원형철근", "철근") +#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 +#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다. +BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림 +BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음 +BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 + #: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) @@ -459,6 +465,14 @@ def _earthwork_rows( "station_from": None, "station_to": None, "spec_detail": "", + "composite_parts": None, + "structure_kind": None, + "spec_class": None, + "spec_class_basis": "", + # 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다). + "blocked_kind": None, + "blocked_reason": "", + "composite_not_ready": None, # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. "in_bill": bool(row.get("in_bill", True)) and not is_subtotal, "excavation_method": methods.get(ground) if ground else None, @@ -499,9 +513,17 @@ def _haul_rows( "ground_class": row.get("ground") or None, "haul_distance_m": float(row.get("average_distance_m") or 0.0), "haul_equipment": equipment, + "excavation_method": None, "station_from": None, "station_to": None, "spec_detail": "", + "composite_parts": None, + "structure_kind": None, + "spec_class": None, + "spec_class_basis": "", + "blocked_kind": None, + "blocked_reason": "", + "composite_not_ready": None, "in_bill": in_bill, "in_bill_reason": str(entry.get("reason") or ""), "origin": ORIGIN_HAUL, @@ -510,6 +532,30 @@ def _haul_rows( return rows, unmatched +def blocked_of(structure: dict[str, Any], class_basis: str = "") -> tuple[str | None, str]: + """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. + + ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. + 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. + """ + notes = [str(note) for note in structure.get("notes") or []] + if structure.get("components"): + # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). + if class_basis and "입력되지 않았습니다" in class_basis: + return BLOCKED_INPUT_MISSING, class_basis + return None, "" + for note in notes: + if "입력되지 않았습니다" in note: + return BLOCKED_INPUT_MISSING, note + if "자료에 없습니다" in note or "표준 물량 자료" in note: + return BLOCKED_UNIT_DATA_MISSING, note + if "산출식이 아직 없습니다" in note: + return BLOCKED_FORMULA_MISSING, note + if notes: + return BLOCKED_UNIT_DATA_MISSING, notes[0] + return None, "" + + def _structure_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping ) -> tuple[list[dict[str, Any]], list[str]]: @@ -534,9 +580,10 @@ def _structure_rows( composite = mapping.composite_for(type_id) if code is None else None kind = structure_kind(structure) if composite else None parts: list[dict[str, Any]] | None = None - parts_missing: list[str] = [] + parts_missing: list[dict[str, Any]] = [] if composite: parts, parts_missing = composite_quantities(structure, composite, mapping) + blocked_kind, blocked_reason = blocked_of(structure, class_basis) if code is None and composite is None: unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") elif entry.get("class_from") == "back_length" and class_key is None: @@ -558,11 +605,15 @@ def _structure_rows( "haul_equipment": None, "station_from": structure.get("start_m"), "station_to": structure.get("end_m"), + "excavation_method": None, "spec_detail": _spec_detail(structure), # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. "composite_parts": parts, # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. "structure_kind": kind, + # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason, # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. "spec_class": class_key, "spec_class_basis": class_basis,