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,275 @@
"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**.
실무 원본은 울진소광 `07-구조도-소광리.xlsx`(보이는 22탭 + 숨긴 31탭). **탭 하나 = 장 하나**이고
탭 안은 「위 치수조서 + 아래 수량산출서」다. 이 모듈이 만드는 것은 **아래쪽 표**다.
실무 표의 짜임(돌쌓기 계열 네 탭 실측, 2026-09-09)::
(단위 표시) m당
공종 | 산 출 근 거 | 수량 | 단위
1 | 면적 2 × 1 × 1.044 2.088 ㎡
...
11 | 잔토정리 터파기 − 되메우기 0.165 ㎥
찰쌓기는 11줄, 메쌓기는 8줄(채움콘크리트·모르터·물구멍이 빠짐)이다. 우리 B08 전개가 이미
줄마다 `name · unit · amount · basis` 를 내므로 **여기서 새로 계산하지 않는다** — 접어서 낼 뿐이다.
⚠ **장 나눔 축은 「제원 조합」이다**(2026-09-09 사용자 확정 ⑨). 같은 종류라도 높이·기울기·
뒷길이·돌 종류가 다르면 그림도 수량도 달라지므로 **장이 갈린다**. 기울기가 제원의 한 칸이라
「기울기가 바뀌면 장이 갈린다」는 확정이 저절로 지켜진다.
⚠ **단위당 값**은 성분 수량을 `billing_quantity` 로 나눈 것이다. 실무 시트 머리의 `m당` ·
`개소당` · `㎡당` 이 그 단위이고, **구조물마다 다르다**(통일하지 않음 — 4-1).
⚠ **2026-09-13 B07 에서 이관**(PLAN 3장 · 사용자 확정 ③ 「구조물도는 B08 로」).
옛 자리 `B07_DesignDetail_Engine_Standard_Sheet.py`.
B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김.
"""
from __future__ import annotations
import json
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS, face_slope_ratio
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset(
{
"start_m",
"end_m",
"station",
"station_m",
"side",
"length_m",
# 구간 규약(C군) — 기준 측점에서 앞뒤로 얼마나 걸치나. **자리**이지 제원이 아니다
# (2026-09-13 실측: 제원이 같은 찰쌓기 두 기가 이 칸 때문에 두 장으로 갈렸음).
"before_m",
"after_m",
"inlet_revet_before_m",
"inlet_revet_after_m",
"outlet_revet_before_m",
"outlet_revet_after_m",
"inlet_basin_before_m",
"inlet_basin_after_m",
"note",
"memo",
"label",
}
)
def billing_of(structure: dict[str, Any]) -> tuple[str, float]:
"""이 장이 설 **단위와 그 수량**.
⚠ `billing_unit` 이 비어 있으면 「단위가 없다」가 아니라 **「m · 연장」**이라는 뜻이다
(`StructureQuantity` 계약). 관측 원단위가 「개소당」·「㎡당」인 종류만 자기 단위를 채운다.
이것을 「미정」으로 읽으면 **돌쌓기처럼 흔한 종류가 전부 단위 없는 장**이 된다.
"""
unit = str(structure.get("billing_unit") or "")
if unit:
return unit, float(structure.get("billing_quantity") or 0.0)
return "m", float(structure.get("length_m") or 0.0)
def _sheet_options(structure: dict[str, Any]) -> dict[str, Any]:
"""장 나눔에 쓰는 제원만 남긴다."""
options = structure.get("options") or {}
return {
key: value for key, value in sorted(options.items()) if key not in PER_PLACE_OPTION_KEYS
}
def sheet_key(structure: dict[str, Any]) -> str:
"""제원 조합 하나를 가리키는 이름. 같은 값이면 같은 장이다."""
payload = {
"type_id": structure.get("type_id") or "",
"height_m": round(float(structure.get("height_m") or 0.0), 3),
"options": _sheet_options(structure),
}
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
#: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**.
#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
#: 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset(
{"masonry_wet", "masonry_dry", "boulder_masonry", "revetment"}
)
#: 기슭막이는 **형태가 돌쌓기일 때만** 돌쌓기 식이 섬(`_UnitQuantity_Revetment.STONE_FORMS`).
REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False}
def sheet_is_wet(sheet: dict[str, Any]) -> bool:
"""장의 메/찰 — 표준경사·뒷길이 표준이 같은 갈래로 읽음.
큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 찰쌓기로 본다 — 품셈 13-6 은
둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
return False
if type_id == "boulder_masonry":
return options.get("bond") != "메쌓기"
if type_id == "revetment":
# 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메.
return REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
return True
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다."""
options = sheet.get("options") or {}
wet = sheet_is_wet(sheet)
return face_slope_ratio(
options,
wet=wet,
height_m=float(sheet.get("height_m") or 0.0),
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
face=sheet.get("face"),
face_reason=str(sheet.get("face_reason") or ""),
)
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`."""
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
type_id = str(structure.get("type_id") or "")
if type_id not in SLOPE_TYPE_IDS:
return None
form = str((structure.get("options") or {}).get("form") or "").strip()
if type_id == "revetment" and form not in REVETMENT_STONE_FORMS:
return None # 돌쌓기형이 아닌 기슭막이는 기울기 판정이 없음(표도 안 섬)
return slope_of(structure)[0]
def sheet_title(structure: dict[str, Any]) -> str:
"""장 제목 — 「이름 H=2.0 1:0.3 뒷길이 45㎝」처럼 **무엇이 갈랐는지**가 보이게."""
parts = [str(structure.get("name") or structure.get("type_id") or "구조물")]
height = float(structure.get("height_m") or 0.0)
if height > 0:
parts.append(f"H={height:g}")
options = structure.get("options") or {}
# 기울기는 **판정 결과**를 적는다 — 사용자가 안 정해도 품셈 표준경사로 갈린다(확정 ⑨).
# 판정이 안 되는 종류(옹벽 등)는 안 적는다.
slope = _slope_or_none(structure)
if slope is not None:
parts.append(f"1:{slope:g}")
# 옛 저장분 키까지 읽는 차례는 전개의 한 벌(`BACK_LENGTH_KEYS`) — 키 이름을 다시 적지 않음.
back = next((options[key] for key in BACK_LENGTH_KEYS if options.get(key)), None)
if back:
parts.append(f"뒷길이 {int(back)}㎝")
kind = options.get("stone_kind")
if kind:
parts.append(str(kind))
return " ".join(parts)
def _unit_amount(amount: float, quantity: float) -> float | None:
"""단위당 값. 셀 단위를 못 정했으면 **0 으로 나누지 않고 `None`** 을 낸다."""
if quantity <= 0:
return None
return amount / quantity
def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"""구조물 하나의 성분을 표 줄로 접는다 — 실무 시트의 `공종 | 산출근거 | 수량 | 단위`."""
_unit, quantity = billing_of(structure)
rows: list[dict[str, Any]] = []
for index, component in enumerate(structure.get("components") or [], start=1):
amount = float(component.get("amount") or 0.0)
rows.append(
{
"no": index,
"name": component.get("name") or "",
# 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13).
"spec": component.get("spec") or "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity),
"amount": amount,
"unit": component.get("unit") or "",
# 갈 곳 — 이중계상 경계를 줄마다 보임(명세 13장 Ⓑ). 전개가 이미 싣고 옴.
"destination": component.get("destination") or "",
# 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용.
"basis_kind": component.get("basis_kind") or "",
"source": component.get("source") or "",
}
)
return rows
def build_standard_sheets(
unit_table: dict[str, Any], section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다.
⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role_of
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
# ⚠ 성절토는 **표를 만든 그 판정**을 그대로 물고 온다 — 제목·그림이 다시 판정하면
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role_of(mode, structure.get("options"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
if sheet is None:
unit, _quantity = billing_of(structure)
sheet = {
"key": key,
"title": sheet_title(structure),
"type_id": structure.get("type_id") or "",
"height_m": float(structure.get("height_m") or 0.0),
"options": _sheet_options(structure),
# 판정 근거 — 제목·그림이 표와 **같은 기울기**를 쓰게 하는 열쇠.
"face": structure.get("face"),
"face_reason": structure.get("face_reason") or "",
# 실무 시트 머리의 「m당」·「개소당」·「㎡당」. 종류마다 다르다 — 통일하지 않는다.
"unit_label": f"{unit}당",
"billing_unit": unit,
"rows": _rows_of(structure),
"members": [],
"notes": list(structure.get("notes") or []),
}
groups[key] = sheet
sheet["members"].append(
{
"structure_id": structure.get("structure_id"),
"name": structure.get("name") or "",
"start_m": structure.get("start_m"),
"end_m": structure.get("end_m"),
"length_m": float(structure.get("length_m") or 0.0),
"billing_quantity": billing_of(structure)[1],
}
)
for note in structure.get("notes") or []:
if note not in sheet["notes"]:
sheet["notes"].append(note)
sheets = sorted(groups.values(), key=lambda sheet: (sheet["type_id"], sheet["height_m"]))
for sheet in sheets:
sheet["member_count"] = len(sheet["members"])
sheet["billing_total"] = sum(member["billing_quantity"] for member in sheet["members"])
# ⚠ 단위당을 못 낸 줄 — 「값이 없다」를 숫자 0 으로 때우지 않고 이름으로 드러낸다.
sheet["unpriced_rows"] = [
row["name"] for row in sheet["rows"] if row["unit_amount"] is None
]
return {
"sheets": sheets,
"sheet_count": len(sheets),
# 왜 안 실렸는지 — 화면이 「구조물이 없다」와 「걸러졌다」를 가릴 수 있어야 한다.
"structure_count": int(unit_table.get("structure_count") or 0),
"pending_choices": list(unit_table.get("pending_choices") or []),
}