refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)

B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음).
화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음.
B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry
로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠.
B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져
부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음.
B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함.
B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-22 12:27:45 +09:00
co-authored by Claude Opus 5
parent 185f800d23
commit 8472fc9f40
231 changed files with 1617 additions and 1216 deletions
@@ -0,0 +1,700 @@
"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (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
import importlib
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),
# BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D).
"box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(o),
# 콘크리트 포장 — 면적 × 두께 · 거푸집 2L · 수축줄눈(교본 3-2 · 부록 4-7 · 소광 · 2026-09-15).
"pavement_concrete": lambda h, l, o, f=None, r="": _lazy("Pavement").concrete_pavement(l, o),
# 비탈면 녹화 ㎡ 넷 — 구간 사면 면적 × 고른 면(품셈 5-14·5-22·5-23·5-25·5-28 · 2026-09-15).
"revegetation": lambda h, l, o, f=None, r="": _lazy("Revegetation").revegetation(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 _box_culvert():
"""BOX암거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_culvert
return box_culvert
def _lazy(part: str):
"""`_UnitQuantity_<part>` 전개 모듈을 늦게 가져온다(포장·녹화 · 700줄이라 한 벌로)."""
return importlib.import_module(f"B08_Quantity.B08_Quantity_Engine_UnitQuantity_{part}")
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-15 브레인 판정 ⑥ — 찾아봤으나 없음(「아직 안 만듦」과 다름).
"ford_bridge": (
"세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도)"
" · 실무 원본에 세월교 시트·도면 없음 · 관은 배수관 줄로 따로 섬"
),
}
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음.
EXPANDER_BILLING: dict[str, Any] = {
"erosion_check": lambda options, length: ("개소", 1.0), # 정본 「골막이(찰)」 개소당
"soil_guard": lambda options, length: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options, length: ("㎡", _num(options.get("area_m2"), 0.0)), # 돌붙임 ㎡당
"box_culvert": lambda options, length: ("m", _num(options.get("length_m"), 0.0)), # 점 배치
"pavement_concrete": lambda options, length: _lazy("Pavement").billing(
length, options
), # 면적 ㎡
"revegetation": lambda options, length: _lazy("Revegetation").billing(options), # 사면 면적 ㎡
}
#: 연장을 **구간(끝 − 시작)** 에서 읽는 종류 — 포장은 물넘이로 나뉘면 길이 칸(10)이 조각마다
#: 그대로 복사돼 칸 값이 구간과 안 맞음(`B05_Profile_UI_Structures_Panel_Commit` 나누기).
SPAN_LENGTH_TYPES = frozenset({"pavement_concrete"})
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
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)
if type_id in SPAN_LENGTH_TYPES and None not in (
structure.get("start_m"),
structure.get("end_m"),
):
length = 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, length)
return result
def _shoring_of(quantities: Iterable[StructureQuantity]) -> dict[str, Any]:
"""동바리 상태 — 전개식이 동바리 줄을 낸 구조물이 있으면 대상(2026-09-15 BOX암거)."""
status = shoring_status()
found = sorted({q.name for q in quantities if any(c.name == "동바리" for c in q.components)})
if found:
status.update(applicable=True, reason=f"슬래브 받침 동바리(12-20) — {' · '.join(found)}")
return status
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 _append_box_trench(
quantity: StructureQuantity, structure: dict[str, Any], thickness_m: float | None
) -> None:
"""BOX암거 구체 터파기(토공 축) — 기초잡석과 **같은 두께**로 든 것을 뺌(2026-09-15)."""
if quantity.type_id != "box_culvert" or not quantity.components:
return
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_trench
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
found = box_trench(structure.get("options") or {}, max(thickness, 0.0))
if found is not None:
rows, quantity.trench_depth_m = found
quantity.components.extend(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)
_append_box_trench(quantity, item, 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 이 아니라 「없음」이라고 말한다(BOX암거 동바리 줄이 서면 대상).
"shoring": _shoring_of(quantities),
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
"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"),
}