"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15). 식을 발명하지 않는다 — 실무 원본을 옮긴다 울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로 남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값 (돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**. 그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다. 치수 정본은 하나다 (PLAN 8-6 ② 필수 조건) 전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다. 자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. ⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙 ㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**. 시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다. 실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②). `verify_no_mix_components()` 가 이 규칙을 코드로 지킨다. ㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7). · **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종 (`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다 (울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 — 그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다. ⚠ 공제 규칙 (품셈 1-2-1 원문) 「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리, 리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.** 치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼 실제로 비어 있는 자리뿐이다. """ from __future__ import annotations from typing import Any, Iterable from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status # ⓘ 2026-09-14 쪼갬 — 성분 계약 `_Base` · 돌 제원·계수 `_StoneSpec` · 돌쌓기 전개식 `_Masonry`. # 밖에서 이 모듈 이름으로 가져가던 것은 그대로 닿게 다시 내보냄(부르는 쪽 44곳 불변). from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( BASIS_DERIVED, # noqa: F401 — 다시 내보냄 ObservedUnitTable, billing_of, expand_observed, load_observed_table, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401 — 다시 내보냄 DESTINATION, MIX_COMPONENTS, RUBBLE_BASE_NAME, Component, StructureQuantity, _num, is_unconfirmed, missing_height_reason, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄 boulder_masonry, stone_masonry, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F401 — 다시 내보냄 BACK_LENGTH_KEYS, BLINDING_THICKNESS_M, COLLECTED_STONE_KEY, FILL_CONCRETE_MPA_DEFAULT, RUBBLE_BASE_THICKNESS_M, STONE_BACK_LENGTH_TABLE, STONE_KIND_OPTION, STONE_MASONRY, _back_length, back_length_default_note, face_slope_ratio, fill_concrete_mpa, is_collected_stone, load_stone_kind_table, stone_weight_per_m2, wall_thickness, wants_blinding, wants_practice_coefficients, weep_hole_spec, ) from common_util.common_util_quantity_spread import spread_by_unit from common_util.common_util_structure_face_role import structure_face_role_of # 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). # ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. # 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면 # 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다. OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { "retaining_wall": ("form", "height_m"), "ford_pavement": ("thickness_cm",), # 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다. "pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"), # 관보호공 날개벽 — **개소당**이고 치수가 형식마다 붙박이다(원본 탭 다섯). 형식을 고르지 # 않으면 날개벽 줄 자체가 안 선다(안 놓은 것과 같다). "pipe_wing_wall": ("wing_wall_type", "pipe_diameter_mm"), } EXPANDERS = { # 넷째·다섯째 인자는 **성토/절토와 그 까닭** — 표준경사 표가 그것으로 갈린다. "masonry_wet": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, True, f, r), "masonry_dry": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, False, f, r), # 큰돌쌓기는 표준경사 표 대상이 아니라 성절토를 안 쓴다(교본 「1:0.3 이상」). "boulder_masonry": lambda h, l, o, f=None, r="": boulder_masonry(h, l, o), # 기슭막이는 **형태가 돌쌓기면 돌쌓기 식**이다 — 실무 정본 탭 제목이 「돌기슭막이(…찰쌓기…)」 # 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 — # 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다). "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), # 골막이는 **개소당**이고 정면적이 사다리꼴이라 상장·하장·높이를 본다 # (정본 「골막이(찰)(치수조서연결)」). 돌쌓기 식을 빌려 쓰지 않는다 — 까닭은 그 모듈에 적었다. "erosion_check": lambda h, l, o, f=None, r="": _erosion_check()(h, o), # 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」). "open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o), # 흙막이는 **「떼」만 섬**(정본 「떼흙막이」 개소당). 나머지 일곱 형식은 원단위 미확보. # ⚠ 확정 4차 — 그림·옵션은 기슭막이와 한 벌이되 **수량 데이터는 분리**. "soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o), # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). # 앞서 `stone_masonry(dry)` 로 전개하고 있었는데, 그러면 직경 60~80㎝ 짜리가 # **「뒷길이 45㎝」 계수로 돌아 조용히 틀린 값**이 나온다(고임돌 0.15·야면석 0.88 …). # ⚠ 값이 나오기는 하므로 어떤 시험도 안 잡던 자리다 — 「값이 있기는 하니 안 보이는」 그것. # 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.** } def _soil_guard(): """흙막이 사유 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard return soil_guard def _open_ditch(): """개거 전개 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import open_ditch return open_ditch def _bed_sill(): """바닥막이 전개 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill return bed_sill def _erosion_check(): """골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리).""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_dam return erosion_check_dam def _revetment(): """기슭막이 전개 함수를 늦게 가져온다 — 서로 부르는 것을 풀기 위한 자리.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import revetment return revetment #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. EXPANDER_WITHHELD: dict[str, str] = {} #: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음). #: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고** #: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음. EXPANDER_BILLING: dict[str, Any] = { "erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당 "soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이) "bed_sill": lambda options: ("㎡", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당 } #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = { # (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리) "pipe": ( ("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"), ("pipe_wing_wall", "wing_wall_type", "관보호공 날개벽"), ), } #: 날개벽 「A-TYPE+집수정」은 집수정을 **품은** 형식이다 — 집수정 줄을 따로 세우면 한 개소를 #: 두 번 센다(원단위표 `double_count_rules` 와 짝). WING_WALL_WITH_BASIN = "A-TYPE+집수정" WING_WALL_DOUBLE_COUNT = ( "⚠ 날개벽을 「A-TYPE+집수정」으로 골랐는데 집수정 형식도 차 있음 — " "그 날개벽 값이 집수정을 이미 품고 있어 **두 번 셀 수 있음**. 한쪽을 비울 것" ) def wing_wall_double_count(options: dict[str, Any]) -> str | None: """겹쳐 세는 자리면 사유 한 줄. 값을 고치지 않고 **드러내기만** 한다.""" if str(options.get("wing_wall_type") or "") != WING_WALL_WITH_BASIN: return None return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None def child_row(structure: dict[str, Any], type_id: str, label: str, suffix: str = "") -> dict: """딸린 줄 한 벌 — 이름에 「어디에 딸렸나」가 남음(`expand` 가 부모 이름 · 꼬리로 붙임).""" return { **structure, "structure_id": f"{structure.get('structure_id')}-{suffix or type_id}", "type_id": type_id, "attachment_of": structure.get("structure_id"), "attachment_parent_type": structure.get("type_id"), "attachment_label": label, } def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" options = structure.get("options") or {} return [ child_row(structure, type_id, label) for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()) if options.get(gate_key) # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 ] def _observed_components( type_id: str, structure: dict[str, Any], observed: ObservedUnitTable | None, ) -> tuple[list[dict[str, Any]], list[str]]: """관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다.""" keys = OBSERVED_SPEC_KEYS.get(type_id) if keys is None: return [], [] options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: from B08_Quantity.B08_Quantity_Wording import option_missing return [], [option_missing(keys[0], type_id)] components, notes = expand_observed(type_id, spec, structure, observed) warning = wing_wall_double_count(options) if warning: notes.append(warning) return components, notes def _observed_billing( type_id: str, structure: dict[str, Any], observed: ObservedUnitTable | None, ) -> tuple[str, float] | None: """관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다.""" keys = OBSERVED_SPEC_KEYS.get(type_id) if keys is None: return None options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: return None return billing_of(type_id, spec, structure, observed) def expand( structure: dict[str, Any], names: dict[str, str] | None = None, observed: ObservedUnitTable | None = None, section_mode: str | None = None, ) -> StructureQuantity: """구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지).""" type_id = str(structure.get("type_id") or "") options = structure.get("options") or {} start = _num(structure.get("start_m")) end = _num(structure.get("end_m")) length = _num(options.get("length_m")) or abs(end - start) height = _num(options.get("height_m")) # ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** — # `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사). from B08_Quantity.B08_Quantity_Wording import type_label as _type_label label = _type_label(type_id, names) if structure.get("attachment_label"): # 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다. parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "") label = f"{parent or label} · {structure['attachment_label']}".strip(" ·") result = StructureQuantity( structure_id=structure.get("structure_id"), type_id=type_id, name=label, length_m=length, height_m=height, start_m=start if structure.get("start_m") is not None else None, end_m=end if structure.get("end_m") is not None else None, options=dict(options), ) # 줄을 만든 쪽이 붙인 사유(관 지점 시설의 기본값 · 못 가른 자리) — `withheld` 면 값을 안 세움. result.notes.extend(str(note) for note in structure.get("notes") or []) if structure.get("withheld"): return result withheld = EXPANDER_WITHHELD.get(type_id) if withheld: result.notes.append(f"전개식 미확보 — {withheld}") return result expander = EXPANDERS.get(type_id) if expander is None: # 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류). components, notes = _observed_components(type_id, structure, observed) if components or notes: result.components = [Component(**item) for item in components] result.notes.extend(notes) billing = _observed_billing(type_id, structure, observed) if billing is not None: result.billing_unit, result.billing_quantity = billing return result from B08_Quantity.B08_Quantity_Wording import type_label result.notes.append( f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다" ) return result # 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다). face, face_reason = structure_face_role_of(section_mode, options) result.components, notes = expander(height, length, options, face, face_reason) result.notes.extend(notes) billing = EXPANDER_BILLING.get(type_id) if billing is not None and result.components: result.billing_unit, result.billing_quantity = billing(options) return result def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]: """⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어). 시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다. 실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다. """ found: list[str] = [] for item in quantities: for component in item.components: if component.name.strip() in MIX_COMPONENTS: found.append(f"{item.name}({item.type_id}) 의 '{component.name}'") return found def section_modes_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]: """저장된 횡단 설계 목록 → `{측점: 단면유형}`. ⚠ **부르는 쪽마다 다시 짜지 말라고 여기 둔다.** 구조물 전개(B08)와 표준도(B07)가 같은 표를 써야 기울기 판정이 두 곳에서 갈리지 않는다. `design.section_mode` 가 없는 측점은 담지 않는다 — 빈 값을 담으면 「가를 근거 없음」과 「모드가 빈 문자열」이 뒤섞인다. """ modes: dict[float, str] = {} for item in designs or (): design = item.get("design") if isinstance(item, dict) else None mode = str((design or {}).get("section_mode") or "").strip() if not mode: continue chainage = _num(item.get("chainage_m")) modes[float(chainage)] = mode return modes #: 저장된 지반 갈래 ↔ 품셈 9-13 토질 3구분. **새 칸을 만들지 않는다** — 측점마다 이미 #: `design.ground_type` 이 저장돼 있고(재생성 사고 때 이 값이 비어 B08 이 통째로 0 이 됐던 #: 그 키다), 값 셋이 품셈 구분과 그대로 맞물린다(2026-09-08 조율 창 확인). GROUND_TYPE_LABEL = { "soil": "토사", "ripping_rock": "암절취", "blasting_rock": "발파암", } def ground_types_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]: """저장된 횡단 설계 목록 → `{측점: 지반갈래}`. 값이 없는 측점은 담지 않는다.""" grounds: dict[float, str] = {} for item in designs or (): design = item.get("design") if isinstance(item, dict) else None ground = str((design or {}).get("ground_type") or "").strip() if not ground: continue grounds[float(_num(item.get("chainage_m")))] = ground return grounds def ground_type_at( structure: dict[str, Any], ground_types: dict[float, str] | None ) -> tuple[str | None, str]: """(토질, 근거). 구조물이 **걸친 측점 전부**를 보고 갈래가 하나일 때만 값을 낸다. ⚠ **판정 규칙 — 섞이면 안 고른다.** 구조물은 구간(start~end)이고 지반은 측점 값이라 한 구조물이 토사 측점과 암 측점에 걸칠 수 있다. 그때 다수결로 한쪽을 고르면 **임의값이 금액으로 굳는다**(암 단가가 몇 배다). 섞였다는 사실과 갈래별 측점 수를 근거에 적고 값은 `None` 으로 둔다 — 성절토·용수에서 지킨 그대로다. ⚠ 걸친 측점이 하나도 없으면(구간이 측점 사이에 통째로 들어간 짧은 구조물) **가장 가까운 측점**을 쓴다 — 그 사실도 근거에 적는다. """ if not ground_types: return None, "측점 지반 갈래가 저장에 없어 못 가름" start, end = _num(structure.get("start_m")), _num(structure.get("end_m")) if end < start: start, end = end, start inside = { chainage: kind for chainage, kind in ground_types.items() if start <= float(chainage) <= end } if not inside: center = (start + end) / 2.0 nearest = min(ground_types, key=lambda chainage: abs(float(chainage) - center)) kind = ground_types[nearest] return ( kind, f"걸친 측점이 없어 가장 가까운 측점({nearest:g}m)의 {GROUND_TYPE_LABEL.get(kind, kind)}", ) counts: dict[str, int] = {} for kind in inside.values(): counts[kind] = counts.get(kind, 0) + 1 if len(counts) == 1: kind = next(iter(counts)) return kind, f"걸친 측점 {len(inside)}곳이 모두 {GROUND_TYPE_LABEL.get(kind, kind)}" breakdown = " · ".join( f"{GROUND_TYPE_LABEL.get(kind, kind)} {count}곳" for kind, count in sorted(counts.items()) ) return None, f"걸친 측점의 지반이 섞여 못 가름 — {breakdown}" def _section_mode_at( structure: dict[str, Any], section_modes: dict[float, str] | None ) -> str | None: """구조물이 선 자리의 단면유형. **가장 가까운 측점**의 값을 쓴다. ⚠ 구조물은 구간(start~end)이고 단면유형은 측점 값이라 딱 맞는 측점이 없을 수 있다. 가장 가까운 측점을 쓰되, 목록이 없으면 `None`(가를 근거 없음)으로 둔다 — **성토로 눅이지 않는다.** """ if not section_modes: return None center = _num(structure.get("chainage_m")) if not center: start, end = _num(structure.get("start_m")), _num(structure.get("end_m")) center = (start + end) / 2.0 if (start or end) else 0.0 nearest = min(section_modes, key=lambda chainage: abs(float(chainage) - center)) return section_modes.get(nearest) def _rubble_base_component( components: list[Component], thickness_m: float | None ) -> Component | None: """기초잡석 한 줄 — **버림 폭이 곧 잡석다짐 폭**이라 두께 비로 낸다(확정 3차 ②). ⚠ 폭을 다시 세지 않는다. 버림이 이미 그 폭으로 서 있으므로 두께 비만 곱하면 **관측 원단위로 오는 구조물(옹벽)에도 같은 식이 선다** — 두 벌로 짜지 않는 자리다. """ thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m) if thickness <= 0: return None blinding = next((item for item in components if item.name == "버림콘크리트"), None) if blinding is None or blinding.amount <= 0: return None ratio = thickness / BLINDING_THICKNESS_M return Component( RUBBLE_BASE_NAME, "㎥", blinding.amount * ratio, DESTINATION[RUBBLE_BASE_NAME], f"버림 {blinding.amount:.3f}㎥ × (잡석두께 {thickness:g} ÷ 버림두께" f" {BLINDING_THICKNESS_M:g}) — 폭이 같음(KCS 34 50 05) · 두께는 사용자 확정 3차 ②" " (품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않음)", ) def _append_section_trench( quantity: StructureQuantity, structure: dict[str, Any], observed: ObservedUnitTable, thickness_m: float | None, ) -> None: """단면이 실린 관측 줄(옹벽)의 터파기·되메우기·잔토 — 기초잡석과 **같은 두께**로 깊이를 잡음.""" from B08_Quantity.B08_Quantity_Engine_ObservedUnit import scale_for, section_trench_components keys = OBSERVED_SPEC_KEYS.get(quantity.type_id) if not keys or not quantity.components: return options = structure.get("options") or {} entry = observed.find( quantity.type_id, {key: options[key] for key in keys if options.get(key) is not None} ) if entry is None: return scale, _note = scale_for(entry, structure) thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m) found = section_trench_components(entry, max(thickness, 0.0), scale) if found is None: return rows, quantity.trench_depth_m = found quantity.components.extend(Component(**row) for row in rows) def build_table( structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None, 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, structure_formulas: dict[str, Any] | None = None, structure_templates: 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장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림. ⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`). 없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김. """ observed = load_observed_table() # 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다. expanded_inputs: list[dict[str, Any]] = [] for item in structures: expanded_inputs.append(item) expanded_inputs.extend(attachments_of(item)) quantities = [] for item in expanded_inputs: quantity = expand(item, names, observed, _section_mode_at(item, section_modes)) quantity.ground_type, quantity.ground_type_basis = ground_type_at(item, ground_types) # 기초잡석 — 버림이 선 구조물에 함께 선다(전개식이든 관측 원단위든 같은 자리). rubble = _rubble_base_component(quantity.components, rubble_base_thickness_m) if rubble is not None: quantity.components.append(rubble) _append_section_trench(quantity, item, observed, rubble_base_thickness_m) quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item) 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, structure_formulas, structure_templates, ) violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} # 합은 **금액에 드는 구조물만** — 기본값으로 선 구조물(`unconfirmed`)은 줄에만 보임. priced = [item for item in quantities if not item.unconfirmed] for item in priced: for component in item.components: key = f"{component.name}|{component.spec}|{component.unit}" entry = totals.setdefault( key, { "name": component.name, "spec": component.spec, "unit": component.unit, "amount": 0.0, "destination": component.destination, }, ) 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, # 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다. "billing_unit": item.billing_unit, "billing_quantity": item.billing_quantity, # 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다. "options": item.options, # 구조물이 놓인 자리의 지반 갈래 — **품셈 9-13 토질 3구분**이 이 값으로 갈린다. # ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다. "ground_type": item.ground_type, "ground_type_basis": item.ground_type_basis, # 파는 깊이 — 단면으로 터파기를 세운 종류만(옹벽 기초분). 비면 직고 + 기초 깊이. "trench_depth_m": item.trench_depth_m, # 양식 있음/없음 — 화면이 가림(비면 지금 전개). "library_item": item.library_item, # 기본값으로 선 까닭 — 차 있으면 금액·자재·토공·운반 합에서 빠짐(`is_unconfirmed`). "unconfirmed": item.unconfirmed, "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, "spec": component.spec, } for component in item.components ], } for item in quantities ] # 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫). formwork_notes, formwork_missing = annotate_formwork(payload_structures) return { "structures": payload_structures, "formwork_notes": formwork_notes, "formwork_reuse_missing": formwork_missing, # 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다. "shoring": shoring_status(), # ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. # 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지. "pending_choices": (observed.pending_choices or {}).get("items") or [], "totals": sorted(totals.values(), key=lambda entry: entry["name"]), # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). "surcharge_applied": False, "mix_components_found": violations, "structure_count": len(quantities), # ⚠ 사토에서 뺄 밑수 — **양수**로 낸다. 빼는 것은 유토곡선(랩탑 메인) 몫이다. COLLECTED_STONE_KEY: round( sum( component.amount for item in priced for component in item.components if component.name == "채집석" ), 3, ), "amount_spread": spread_by_unit(totals.values(), value_key="amount"), }