From 8e78940b7a029601d8b10167335721a06122aee9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:45:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EA=B1=B0=ED=91=B8=EC=A7=91=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=9A=9F=EC=88=98=20+=20=EA=B3=B5=EC=A2=85?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=87=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 거푸집 사용횟수는 **관측값이 아니라 법**임. 품셈 1-7-1 이 구조물 종류별로 정해 둠(옹벽 3회 · 보호공 기초 6회). 원문 문구를 데이터에 싣고 우리 구조물이 어느 예시에 걸리는지 적음. 걸리는 예시가 없으면 「사용횟수 미확보」. - ⚠ 횟수별 재료 환산(품셈 12-4 합판 3회 46.1 %)은 **하지 않음**. 그 비율은 일위대가 재료비에 걸리는 값이라 B08 이 곱하면 B09 와 겹쳐 두 번 줌. B08 이 내는 것은 접촉 면적 그대로 + 몇 회짜리인가까지. 시험으로 못 박음. - 동바리는 슬래브를 떠받칠 때 쓰는 것이라 지금 서는 구조물(옹벽·집수정)은 대상이 아님. 0 으로 적지 않고 「대상 없음 + 사유」로 냄. - 거푸집 이름은 정확 일치로만 봄 — 부분일치면 「거푸집씻기」(공사용수)가 걸림. 공종코드 잇기 - 집수정 → FP-12-15, 물넘이포장 → FP-12-06 로 이음. - 옹벽은 **품셈 12장에 그 이름의 공종이 없음**. 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 되므로 `composite` 로 묶음(타설+거푸집+철근+기초잡석)을 적음. 일위대가 조립은 B09 몫이고 B08 은 물량과 묶음만 넘김. 검증 — 거푸집 8건 · 인계 45건 통과, 전체 558 passed. tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Formwork.py | 127 ++++++++++++++++++ B08_Quantity/B08_Quantity_Engine_Handoff.py | 27 +++- .../B08_Quantity_Engine_UnitQuantity.py | 60 +++++---- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 27 +++- .../formwork_reuse_2026-01-01.json | 68 ++++++++++ .../work_item_mapping_2026-01-01.json | 37 ++++- 6 files changed, 314 insertions(+), 32 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Formwork.py create mode 100644 resources/data_formwork/formwork_reuse_2026-01-01.json diff --git a/B08_Quantity/B08_Quantity_Engine_Formwork.py b/B08_Quantity/B08_Quantity_Engine_Formwork.py new file mode 100644 index 00000000..d37945dc --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Formwork.py @@ -0,0 +1,127 @@ +"""거푸집 사용횟수 — 접촉 면적에 **몇 회 쓰는 거푸집인지**를 붙인다 (B08 일감 ⑩). + +⚠ 사용횟수는 **관측값이 아니라 법이다** + 품셈 1-7-1 이 구조물 종류별로 정해 둔다 — 「3회 … 옹벽, 파라펫트, 날개벽 등 약간 복잡한 + 구조」. 그래서 실무 관측값으로 갈음하지 않고 **원문 문구를 그대로 데이터에 싣고** 우리 + 구조물이 그 줄의 어느 예시에 걸리는지를 적는다. 걸리는 예시가 없으면 지어내지 않는다. + +⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다** (이중계상) + 품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」(합판 3회 46.1 % 등)은 **일위대가 + 재료비**에 걸리는 값이다. B08 이 면적에 그 비율을 곱해 넘기면 B09 가 또 곱해 두 번 준다. + **B08 이 내는 것은 「접촉 면적 + 몇 회짜리인가」까지다.** 비율표는 참고로만 싣는다. + +⚠ 동바리는 지금 대상이 없다 + 강관동바리(12-20)는 **슬래브를 떠받칠 때** 쓴다. 지금 서는 구조물(옹벽·집수정)은 벽체 + 거푸집뿐이라 대상이 아니고, 대상이 될 BOX암거·세월교는 치수·원단위가 미확보라 + 슬래브 면적 자체가 안 나온다. **없는 것을 0 으로 적지 않고 「대상 없음」이라고 말한다.** +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_formwork" +DATASET_PREFIX = "formwork_reuse_" + +#: 거푸집으로 보는 성분 이름. 정확히 같은 이름으로만 본다 — 부분일치면 「거푸집씻기」가 걸린다. +FORMWORK_NAMES = frozenset({"합판거푸집", "유로폼", "문양거푸집", "거푸집"}) + +NOTE_REUSE_MISSING = "사용횟수 미확보" +NOTE_NOT_APPLICABLE = "거푸집 대상 아님" + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class FormworkTable: + """사용횟수표 한 판.""" + + effective_date: str = "" + source: dict[str, Any] = field(default_factory=dict) + type_map: list[dict[str, Any]] = field(default_factory=list) + 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) + + def for_type(self, type_id: str) -> dict[str, Any] | None: + for row in self.type_map: + if row.get("type_id") == type_id: + return row + return None + + +def load_formwork_table(path: Path | None = None) -> FormworkTable: + """사용횟수표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return FormworkTable() + payload = json.loads(target.read_text(encoding="utf-8")) + return FormworkTable( + effective_date=str(payload.get("effective_date") or ""), + source=payload.get("source") or {}, + type_map=list(payload.get("type_map") or []), + reuse_by_class=list(payload.get("reuse_by_class") or []), + reuse_ratio_pct=payload.get("reuse_ratio_pct") or {}, + shoring=payload.get("shoring") or {}, + ) + + +def annotate( + structures: list[dict[str, Any]], table: FormworkTable | None = None +) -> tuple[list[str], list[str]]: + """산출물의 거푸집 성분에 사용횟수를 달아 준다. (알림, 미확보 종류) 를 돌려준다. + + 성분 딕셔너리를 **그 자리에서** 고친다 — 거푸집 줄만 손대고 나머지는 건드리지 않는다. + """ + found = table or load_formwork_table() + notes: list[str] = [] + missing: list[str] = [] + for structure in structures: + type_id = str(structure.get("type_id") or "") + entry = found.for_type(type_id) + targets = [ + component + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in FORMWORK_NAMES + ] + if not targets: + continue + if entry is None: + missing.append(type_id) + for component in targets: + component["reuse_count"] = None + component["reuse_note"] = NOTE_REUSE_MISSING + continue + count = entry.get("reuse_count") + for component in targets: + component["reuse_count"] = count + component["reuse_note"] = ( + f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}」" + if count + else NOTE_NOT_APPLICABLE + ) + if count: + notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)") + else: + missing.append(type_id) + return notes, sorted(set(missing)) + + +def shoring_status(table: FormworkTable | None = None) -> dict[str, Any]: + """동바리 — **대상이 없으면 없다고 말한다.** 0 으로 적으면 「없음」과 구별이 안 된다.""" + found = table or load_formwork_table() + shoring = found.shoring or {} + return { + "applicable": False, + "reason": str(shoring.get("note") or "슬래브 구조물이 없어 동바리 대상이 아님"), + "pending_types": list(shoring.get("targets_pending") or []), + } diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index fd268f5e..dbeeae62 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -85,6 +85,7 @@ class WorkItemMapping: haul: list[dict[str, Any]] = field(default_factory=list) structure: list[dict[str, Any]] = field(default_factory=list) pending_user: dict[str, Any] = field(default_factory=dict) + composite: dict[str, Any] = field(default_factory=dict) def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" @@ -111,6 +112,16 @@ class WorkItemMapping: return row return None + def composite_for(self, type_id: str) -> dict[str, Any] | None: + """품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가. + + 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다. + """ + for row in self.composite.get("items") or []: + if row.get("type_id") == type_id: + return row + return None + def load_mapping(path: Path | None = None) -> WorkItemMapping: """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" @@ -124,6 +135,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping: haul=list(payload.get("haul") or []), structure=list(payload.get("structure") or []), pending_user=payload.get("pending_user") or {}, + composite=payload.get("composite") or {}, ) @@ -263,7 +275,8 @@ def _structure_rows( type_id = str(structure.get("type_id") or "") entry = mapping.for_structure(type_id) or {} code = entry.get("work_item_code") - if code is None: + composite = mapping.composite_for(type_id) if code is None else None + if code is None and composite is None: unmatched.append(f"구조물({type_id})") length = float(structure.get("length_m") or 0.0) rows.append( @@ -283,8 +296,10 @@ def _structure_rows( "station_from": structure.get("start_m"), "station_to": structure.get("end_m"), "spec_detail": _spec_detail(structure), + # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. + "composite_parts": (composite or {}).get("parts"), "in_bill": True, - "in_bill_reason": "", + "in_bill_reason": (composite or {}).get("why", ""), "origin": ORIGIN_STRUCTURE, } ) @@ -422,8 +437,12 @@ def verify_bill_flags(handoff: dict[str, Any]) -> list[str]: """ found: list[str] = [] for row in handoff.get("work_items") or []: - if row.get("in_bill") and not row.get("work_item_code"): - found.append(str(row.get("name"))) + if not row.get("in_bill") or row.get("work_item_code"): + continue + # 묶음으로 서는 줄은 코드가 없어도 정상이다 — 무엇으로 묶이는지 적혀 있다. + if row.get("composite_parts"): + continue + found.append(str(row.get("name"))) return found diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 2f985f94..ce9ff1da 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -41,6 +41,8 @@ from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( expand_observed, load_observed_table, ) +from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork +from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status from common_util.common_util_quantity_spread import spread_by_unit # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── @@ -406,32 +408,40 @@ def build_table( ) entry["amount"] += component.amount + payload_structures = [ + { + "structure_id": item.structure_id, + "type_id": item.type_id, + "name": item.name, + "length_m": item.length_m, + "height_m": item.height_m, + "start_m": item.start_m, + "end_m": item.end_m, + "notes": item.notes, + "components": [ + { + "name": component.name, + "unit": component.unit, + "amount": component.amount, + "destination": component.destination, + "basis": component.basis, + "basis_kind": component.basis_kind, + "source": component.source, + } + for component in item.components + ], + } + for item in quantities + ] + # 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫). + formwork_notes, formwork_missing = annotate_formwork(payload_structures) + return { - "structures": [ - { - "structure_id": item.structure_id, - "type_id": item.type_id, - "name": item.name, - "length_m": item.length_m, - "height_m": item.height_m, - "start_m": item.start_m, - "end_m": item.end_m, - "notes": item.notes, - "components": [ - { - "name": component.name, - "unit": component.unit, - "amount": component.amount, - "destination": component.destination, - "basis": component.basis, - "basis_kind": component.basis_kind, - "source": component.source, - } - for component in item.components - ], - } - for item in quantities - ], + "structures": payload_structures, + "formwork_notes": formwork_notes, + "formwork_reuse_missing": formwork_missing, + # 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다. + "shoring": shoring_status(), "totals": sorted(totals.values(), key=lambda entry: entry["name"]), # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). "surcharge_applied": False, diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 675e5bda..3da8cfa0 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -55,6 +55,9 @@ export interface UnitQuantityStructure { /** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */ basis_kind?: string; source?: string; + /** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */ + reuse_count?: number | null; + reuse_note?: string; }[]; } @@ -71,6 +74,13 @@ export interface MaterialResponse { structure_count: number; } +/** 거푸집·동바리 안내에 쓰는 값. */ +export interface FormworkInfo { + formwork_notes?: string[]; + formwork_reuse_missing?: string[]; + shoring?: { applicable: boolean; reason: string; pending_types: string[] }; +} + /** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */ const DESTINATION_LABELS: Record = { earthwork: "토공 합산", @@ -297,6 +307,20 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement const skipped = warning("건너뛴 구조물", response.skipped_structures); if (skipped) wrap.append(skipped); + // 거푸집 사용횟수 — 값이 아니라 **몇 회짜리인지**를 알려 주는 자리(품셈 1-7-1). + const info = unit as unknown as FormworkInfo; + const reuse = warning("거푸집 사용횟수", info.formwork_notes ?? []); + if (reuse) wrap.append(reuse); + const reuseMissing = warning("사용횟수 미확보", info.formwork_reuse_missing ?? []); + if (reuseMissing) wrap.append(reuseMissing); + if (info.shoring && !info.shoring.applicable) { + // 「없음」을 0 으로 적지 않는다 — 대상이 없는 것과 값이 0 인 것은 다르다. + const line = document.createElement("p"); + line.className = "b08-grid__caption"; + line.textContent = `동바리: 대상 없음 — ${info.shoring.reason.replace(/\*\*/g, "")}`; + wrap.append(line); + } + if (!unit.structures.length) { const empty = document.createElement("p"); empty.className = "b08-quantity__message"; @@ -338,7 +362,8 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement tr.append(textCell(component.basis, "b08-grid__note")); // ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야 // 나중에 「이 값이 왜 이런가」를 되짚을 수 있다. - tr.append(textCell(component.basis_kind === "observed" ? "실무 관측" : "치수 전개")); + const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개"; + tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}회` : kind)); body.append(tr); } } diff --git a/resources/data_formwork/formwork_reuse_2026-01-01.json b/resources/data_formwork/formwork_reuse_2026-01-01.json new file mode 100644 index 00000000..0c426ae5 --- /dev/null +++ b/resources/data_formwork/formwork_reuse_2026-01-01.json @@ -0,0 +1,68 @@ +{ + "schema_version": "1.0", + "dataset_id": "formwork_reuse", + "effective_date": "2026-01-01", + "note": "거푸집 사용횟수 — **품셈 1-7-1 원문**이 구조물 종류별로 정해 둔 값이다. 관측값이 아니라 법이므로 실무값으로 갈음하지 않는다.", + "source": { + "doc": "산림사업 표준품셈 1-7-1 거푸집 사용", + "table_id": "F0040", + "quote": "2회 T형보, 난간, 특히 복잡한 구조의 교각, 교대, 수문관의 본체 등 복잡한 구조 / 3회 슬래브, 교대, 교각, 옹벽, 파라펫트, 날개벽 등 약간 복잡한 구조 / 4회 측구, 수로, 확대기초, 우물통 등 비교적 간단한 구조 / 6회 수문 또는 관의 기초, 호안 및 보호공의 기초 등 극히 간단한 구조" + }, + "policy": { + "b08_delivers": "접촉 면적(㎡) + 사용횟수. **횟수별 재료 환산은 하지 않는다**.", + "b09_applies": "품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」은 일위대가 재료비에 걸린다. B08 이 여기서 곱하면 B09 와 겹쳐 두 번 준다.", + "unlisted_is_flagged": true + }, + "reuse_by_class": [ + { + "reuse_count": 2, + "class": "복잡한 구조", + "examples": ["T형보", "난간", "복잡한 교각", "교대", "수문관 본체"] + }, + { + "reuse_count": 3, + "class": "약간 복잡한 구조", + "examples": ["슬래브", "교대", "교각", "옹벽", "파라펫트", "날개벽"] + }, + { + "reuse_count": 4, + "class": "비교적 간단한 구조", + "examples": ["측구", "수로", "확대기초", "우물통"] + }, + { + "reuse_count": 6, + "class": "극히 간단한 구조", + "examples": ["수문 기초", "관의 기초", "호안 기초", "보호공 기초"] + } + ], + "type_map": [ + { + "type_id": "retaining_wall", + "reuse_count": 3, + "matched_example": "옹벽", + "note": "원문 3회 줄에 「옹벽」이 그대로 있음" + }, + { + "type_id": "pipe_inlet_basin", + "reuse_count": 6, + "matched_example": "보호공 기초", + "note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요" + }, + { + "type_id": "ford_pavement", + "reuse_count": null, + "note": "물넘이포장은 거푸집이 서지 않는 구조(면 포장) — 대상 아님" + } + ], + "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 } + }, + "shoring": { + "note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.", + "targets_pending": ["box_culvert", "ford_bridge"], + "why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보" + } +} 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 646c7681..af19bd3b 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 @@ -125,6 +125,16 @@ "work_item_code": "FP-13-04-02", "master_name": "돌쌓기 > 메쌓기(장비)", "note": "인력 시공이면 FP-13-04-01" + }, + { + "type_id": "pipe_inlet_basin", + "work_item_code": "FP-12-15", + "master_name": "집수정" + }, + { + "type_id": "ford_pavement", + "work_item_code": "FP-12-06", + "master_name": "콘크리트 포장(인력시공)" } ], "pending_user": { @@ -132,14 +142,37 @@ "items": [ { "group": "지장목제거", - "candidates": ["FP-04-01 수확베기", "FP-04-02 단목베기", "FP-04-03 위험목 베기"], + "candidates": [ + "FP-04-01 수확베기", + "FP-04-02 단목베기", + "FP-04-03 위험목 베기" + ], "why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음" }, { "group": "흙깎기/측구터파기 암", - "candidates": ["FP-09-04 암절취(리핑)", "FP-09-05 발파암"], + "candidates": [ + "FP-09-04 암절취(리핑)", + "FP-09-05 발파암" + ], "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" } ] + }, + "composite": { + "note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.", + "items": [ + { + "type_id": "retaining_wall", + "parts": [ + "FP-12-01 콘크리트 타설", + "FP-12-04 합판거푸집", + "FP-12-03 철근 현장가공 및 조림", + "FP-12-25 기초잡석" + ], + "why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.", + "needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김" + } + ] } }