From 8b0f7e8baf3cd334500c3e548b58882a3c2a74b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 01:41:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EC=9C=A0=EB=A1=9C=ED=8F=BC=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98=20=EC=9B=90=EB=AC=B8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20+=20=EB=AA=BB=20=EC=B1=84=EC=9A=B4=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81=20=EC=82=AC=EC=9C=A0=EB=A5=BC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「원문이 이미 정해 둠」 **네 번째** — 품셈 12-38-3 [주]④ 「보통: 측구, 수로, 옹벽, 일반적인 벽체, 박스」. 앞선 셋(거푸집 사용횟수 1-7-1 · 철근 갈래 12-3 [주]① · 밑수는 표 위 본문)과 같은 자리라 사람이 고를 칸을 안 만듦. 옹벽 → `FP-12-38#보통`, 근거 문구를 조각에 함께 실음. - ⚠ 12-38-1 「사용횟수」는 1-7-1 과 **다른 자리**임. 그쪽은 유로폼(강재)의 잔존율(12회 25% · 25회 10%)로 임대료·손료 산정용이고, 1-7-1 은 소모성 거푸집 전용 횟수(옹벽 3회)임. 이름이 같아 하나로 이으면 조용히 틀림 — 데이터에 그 구분을 적고 시험으로 못 박음. - 유로폼 자재는 12-38-2 가 10㎡당 패널 0.89매로 냄. B08 은 **면적(㎡)** 으로 보내고 매수 환산은 밑수를 아는 B09 가 함 — B08 이 환산하면 밑수가 두 벌. `composite_not_ready` 를 `{code, reason}` 구조로 바꿈. 받는 쪽이 「단가 없음」과 「물량 없음」을 갈라야 함(기초잡석이 후자). 원단위 자체가 없으면 조각을 늘어놓지 않고 한 줄로 말함 — 같은 사유가 조각마다 다섯 번 반복되면 진짜 사유가 묻힘(화면에서 실제로 그렇게 보였음). 시험 추가 — 같은 공종코드가 지반만 달리해 여러 줄 오는 표본(도자 토사·리핑암). 실물에서 잡힌 모양이라 고정 자료에도 넣음. 검증 — 인계 59건 통과, 전체 600 passed, tsc 오류 0. 화면 그리기는 실제 모듈을 그대로 돌려 확인(사용자 프로젝트·DB 무접촉). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Formwork.py | 3 + B08_Quantity/B08_Quantity_Engine_Handoff.py | 39 +++++- B08_Quantity/B08_Quantity_Router_Material.py | 3 +- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 16 ++- .../formwork_reuse_2026-01-01.json | 112 ++++++++++++++++-- .../work_item_mapping_2026-01-01.json | 3 +- 6 files changed, 163 insertions(+), 13 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Formwork.py b/B08_Quantity/B08_Quantity_Engine_Formwork.py index d37945dc..65c80556 100644 --- a/B08_Quantity/B08_Quantity_Engine_Formwork.py +++ b/B08_Quantity/B08_Quantity_Engine_Formwork.py @@ -51,6 +51,8 @@ class FormworkTable: reuse_by_class: list[dict[str, Any]] = field(default_factory=list) reuse_ratio_pct: dict[str, Any] = field(default_factory=dict) shoring: dict[str, Any] = field(default_factory=dict) + #: 유로폼 설치·해체 유형 — 품셈 12-38-3 [주]④ 가 시설 예시로 갈라 둔 것. + euroform_type: dict[str, Any] = field(default_factory=dict) def for_type(self, type_id: str) -> dict[str, Any] | None: for row in self.type_map: @@ -72,6 +74,7 @@ def load_formwork_table(path: Path | None = None) -> FormworkTable: reuse_by_class=list(payload.get("reuse_by_class") or []), reuse_ratio_pct=payload.get("reuse_ratio_pct") or {}, shoring=payload.get("shoring") or {}, + euroform_type=payload.get("euroform_type") or {}, ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 522101c6..af1dff06 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -165,6 +165,12 @@ def composite_quantities( ⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다. 안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다. """ + # ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면 + # **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다. + if not (structure.get("components") or []): + note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" + return [], [{"code": None, "reason": note}] + amounts: dict[str, tuple[float, str]] = {} for component in structure.get("components") or []: name = str(component.get("name") or "").strip() @@ -197,6 +203,16 @@ def composite_quantities( "basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None), "from_components": sources, } + if suffix == "euroform_type": + kind, why = euroform_type(str(structure.get("type_id") or "")) + entry["kind"] = kind + entry["kind_basis"] = why + if kind: + entry["code"] = f"{spec.get('code')}#{kind}" + else: + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) if suffix == "rebar_complexity": # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. complexity, why = rebar_complexity( @@ -209,11 +225,12 @@ def composite_quantities( else: entry["not_ready"] = True entry["why"] = why - missing.append(f"{spec.get('name')}: {why}") + missing.append({"code": spec.get("code"), "reason": why}) if spec.get("not_ready") or not found: entry["not_ready"] = True entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음") - missing.append(f"{spec.get('name') or spec.get('code')}: {entry['why']}") + # ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다. + missing.append({"code": spec.get("code"), "reason": entry["why"]}) parts.append(entry) return parts, missing @@ -279,6 +296,24 @@ def rebar_complexity( return None, f"품셈 12-3 [주]① 예시에 없는 구조({type_id} {form}) — 임의로 고르지 않음" +def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]: + """유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. + + 「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래 + (12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다. + + ⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고 + 1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다. + """ + from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table + + found = table if table is not None else load_formwork_table().euroform_type + 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}) — 임의로 고르지 않음" + + def _spec_detail(structure: dict[str, Any]) -> str: """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" parts: list[str] = [] diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 9d347c76..5498ec10 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -109,7 +109,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: "not_ready": row.get("composite_not_ready"), } for row in handoff["work_items"] - if row.get("composite_parts") + # 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다. + if row.get("composite_parts") or row.get("composite_not_ready") ] return JSONResponse( content={ diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 16872f43..98eb43ff 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -90,7 +90,8 @@ export interface MaterialResponse { composite?: { name: string; parts: CompositePart[]; - not_ready?: string[] | null; + /** 못 채운 조각 — 「단가 없음」과 「물량 없음」을 가르려고 사유를 구조로 받는다. */ + not_ready?: { code: string | null; reason: string }[] | null; }[]; } @@ -135,6 +136,11 @@ function headRow(labels: string[]): HTMLTableSectionElement { return head; } +/** 못 채운 조각의 사유를 사람이 읽는 줄로. 코드가 있으면 앞에 붙인다. */ +function reasons(items?: { code: string | null; reason: string }[] | null): string[] { + return (items ?? []).map((item) => (item.code ? `${item.code} ${item.reason}` : item.reason)); +} + /** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */ function warning(title: string, items: string[]): HTMLElement | null { if (!items.length) return null; @@ -344,6 +350,12 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement // ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로 // 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다. for (const group of response.composite ?? []) { + // 조각이 하나도 없으면 「묶음 공종 — 」 빈 줄이 남는다. 사유만 보이는 것이 낫다. + if (!group.parts?.length) { + const blocked = warning("⚠ 묶음을 못 세움", reasons(group.not_ready)); + if (blocked) wrap.append(blocked); + continue; + } const box = document.createElement("p"); box.className = "b08-quantity__notice"; const parts = group.parts.map((part) => { @@ -356,7 +368,7 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement }); box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`; wrap.append(box); - const blocked = warning("⚠ 물량을 못 채운 조각", group.not_ready ?? []); + const blocked = warning("⚠ 물량을 못 채운 조각", reasons(group.not_ready)); if (blocked) wrap.append(blocked); } diff --git a/resources/data_formwork/formwork_reuse_2026-01-01.json b/resources/data_formwork/formwork_reuse_2026-01-01.json index 0c426ae5..a8cc0429 100644 --- a/resources/data_formwork/formwork_reuse_2026-01-01.json +++ b/resources/data_formwork/formwork_reuse_2026-01-01.json @@ -17,22 +17,45 @@ { "reuse_count": 2, "class": "복잡한 구조", - "examples": ["T형보", "난간", "복잡한 교각", "교대", "수문관 본체"] + "examples": [ + "T형보", + "난간", + "복잡한 교각", + "교대", + "수문관 본체" + ] }, { "reuse_count": 3, "class": "약간 복잡한 구조", - "examples": ["슬래브", "교대", "교각", "옹벽", "파라펫트", "날개벽"] + "examples": [ + "슬래브", + "교대", + "교각", + "옹벽", + "파라펫트", + "날개벽" + ] }, { "reuse_count": 4, "class": "비교적 간단한 구조", - "examples": ["측구", "수로", "확대기초", "우물통"] + "examples": [ + "측구", + "수로", + "확대기초", + "우물통" + ] }, { "reuse_count": 6, "class": "극히 간단한 구조", - "examples": ["수문 기초", "관의 기초", "호안 기초", "보호공 기초"] + "examples": [ + "수문 기초", + "관의 기초", + "호안 기초", + "보호공 기초" + ] } ], "type_map": [ @@ -57,12 +80,87 @@ "reuse_ratio_pct": { "note": "품셈 12-4 「사용횟수별 기준수량에 대한 비율(%)」. **B09 일위대가가 쓰는 값**이며 B08 은 참고로만 싣는다 — 여기서 곱하면 이중계상.", "table_id": "F0336", - "plywood": { "1": 100.0, "2": 57.0, "3": 46.1, "4": 40.1, "5": 37.1, "6": 34.7 }, - "timber": { "1": 100.0, "2": 60.0, "3": 47.1, "4": 40.0, "5": 34.2, "6": 32.0 } + "plywood": { + "1": 100.0, + "2": 57.0, + "3": 46.1, + "4": 40.1, + "5": 37.1, + "6": 34.7 + }, + "timber": { + "1": 100.0, + "2": 60.0, + "3": 47.1, + "4": 40.0, + "5": 34.2, + "6": 32.0 + } }, "shoring": { "note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.", - "targets_pending": ["box_culvert", "ford_bridge"], + "targets_pending": [ + "box_culvert", + "ford_bridge" + ], "why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보" + }, + "euroform_type": { + "note": "유로폼 설치·해체 유형(간단/보통/복잡) — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. 거푸집 사용횟수(1-7-1)·철근 갈래(12-3 [주]①)와 같은 자리로, 사람이 고르는 값이 아니다.", + "source": { + "doc": "산림사업 표준품셈 12-38-3 설치 및 해체 [주]④", + "table_id": "F0396", + "quote": "복잡 — 토목: 교대, 날개벽 등 복잡하고 보강이 많은 구조 / 보통 — 측구, 수로, 옹벽, 일반적인 벽체, 박스 등 / 간단 — 수문 또는 관의 기초, 건축 매트기초 등 간단한 구조" + }, + "classes": [ + { + "key": "복잡", + "examples": [ + "교대", + "날개벽" + ], + "daily_area_m2": 25 + }, + { + "key": "보통", + "examples": [ + "측구", + "수로", + "옹벽", + "일반적인 벽체", + "박스" + ], + "daily_area_m2": 35 + }, + { + "key": "간단", + "examples": [ + "수문 기초", + "관의 기초", + "매트기초" + ], + "daily_area_m2": 40 + } + ], + "type_map": [ + { + "type_id": "retaining_wall", + "class": "보통", + "matched": "옹벽" + }, + { + "type_id": "box_culvert", + "class": "보통", + "matched": "박스" + }, + { + "type_id": "pipe_inlet_basin", + "class": "간단", + "matched": "관의 기초", + "note": "⚠ 집수정 벽체까지 간단으로 볼지는 확인 필요 — 거푸집 사용횟수와 같은 물음" + } + ], + "reuse_note": "⚠ 12-38-1 「사용횟수」는 **1-7-1 과 다른 자리다.** 1-7-1 은 합판거푸집처럼 **소모성 거푸집을 몇 번 쓰나**(옹벽 3회)이고, 12-38-1 은 유로폼(강재)의 **잔존율 기준**(12회 사용 잔존율 25 % · 25회 사용 잔존율 10 %)으로 **임대료·손료 산정용**이다. 하나로 잇지 않는다.", + "material_unit_note": "유로폼 자재는 12-38-2 가 **10㎡당 패널 0.89매**로 낸다. B08 은 **접촉 면적(㎡)** 으로 보내고 매수 환산은 그 표의 밑수(10㎡)를 아는 B09 가 한다 — B08 이 환산하면 밑수를 두 벌로 들게 된다." } } 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 66632217..6d1ca677 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 @@ -189,7 +189,8 @@ "unit": "㎡", "from_components": [ "유로폼" - ] + ], + "kind_suffix": "euroform_type" }, { "code": "FP-12-03",