"""M02 표 양식 설계값 채우기 — 서버 단독(CLAUDE.md 5장 ③) · 저장 안 함. 프로젝트 설계 정본 셋을 읽어 표 문서의 **바인딩 열**을 채움: `B05_Profile/route/structures.json` 놓인 구조물 `.../drainage/edits/pipe_points.json` 계곡 통과 시설(관 · BOX · 물넘이 · 세월교 · 기슭막이) B06 횡단 설계 `design.pipe_length_m` 관 연장(부르는 쪽이 DB 에서 읽어 넘김) - 펼침 열 — 마스터 한 칸 → 설계에 쓰인 값마다 열(choices 차례) · 쓰인 값이 없으면 열을 뺌. 열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로). - 줄 — 측점마다 한 줄(같은 측점 구조물은 한 줄) · 점 `NO.x+y` · 구간 `NO.a~NO.b` · 같은 측점에 관이 둘이면 줄을 나눔 · 줄 id 는 측점에서 지어 손 값이 다시 채워도 붙어 있음. - 손 열 값 · 전구간 줄 · 사용자가 더한 줄(`손`)은 작업본 것을 그대로 지킴. - 값을 여기서 짓지 않음 — 정본 값을 모으기만. 빈 값은 빈칸(0 으로 안 채움). """ from __future__ import annotations import copy import re from collections.abc import Iterable from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import StructureType, structure_type_map from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file #: 관 측점 ↔ 횡단 측점 맞추기 허용(옛 배수관 물량과 같은 규칙) — 넓히면 옆 관 연장을 물어 옴. PIPE_MATCH_TOLERANCE_M = 0.5 #: 계곡 통과 시설 `facility` → 종류(빈 값은 관). FACILITY_TYPES = { "pipe": "pipe", "box_culvert": "box_culvert", "ford_pavement": "ford_pavement", "ford_bridge": "ford_bridge", "revetment": "revetment", } EMPTY_LABEL = "-" ORPHAN_NOTE = "설계에서 측점 없어짐" #: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀. RULE_COLUMNS_KEY = "양식열" _REF = re.compile(r"\[([^\]$@][^\]@]*)\]") # ── 설계 읽기 ───────────────────────────────────────── def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]: """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음.""" found: dict[float, float] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None if not isinstance(design, dict): continue length = _number(design.get("pipe_length_m")) if length and length > 0: found[round(float(row.get("chainage_m") or 0.0), 3)] = length return found def _nearest(lengths: dict[float, float], chainage: float) -> float | None: if not lengths: return None key = round(chainage, 3) if key in lengths: return lengths[key] best = min(lengths, key=lambda x: abs(x - chainage)) return lengths[best] if abs(best - chainage) <= PIPE_MATCH_TOLERANCE_M else None def _with_defaults(definition: StructureType | None, options: dict[str, Any]) -> dict[str, Any]: """빠진 칸은 레지스트리 기본값 — B05 화면이 보여 주는 값과 같게.""" merged = dict(options or {}) for field in definition.options if definition else []: if field.key not in merged and field.default is not None: merged[field.key] = field.default return merged def collect_items( project_root: str | Path, pipe_lengths: dict[float, float] | None = None, types: dict[str, StructureType] | None = None, ) -> list[dict[str, Any]]: """설계 정본 → 구조물 목록. 한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장}`. """ types = types or structure_type_map() lengths = pipe_lengths or {} items: list[dict[str, Any]] = [] _, structures = load_structures(str(project_root)) for item in structures: definition = types.get(item.type_id) if definition is not None and definition.managed_by: continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈 items.append( { "type_id": item.type_id, "placement": definition.placement if definition else "point", "chainage_m": float(item.chainage_m), "start_m": item.start_m, "end_m": item.end_m, "options": _with_defaults(definition, dict(item.options or {})), } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): type_id = FACILITY_TYPES.get(point.facility or "pipe", point.facility) chainage = float(point.chainage_m) items.append( { "type_id": type_id, "placement": "point", "chainage_m": chainage, "start_m": None, "end_m": None, "options": _with_defaults(types.get(type_id), dict(point.options or {})), "관연장": _nearest(lengths, chainage) if type_id == "pipe" else None, } ) return items # ── 값 · 글 ─────────────────────────────────────────── def _number(value: Any) -> float | None: if isinstance(value, bool) or value is None or value == "": return None try: return float(value) except (TypeError, ValueError): return None def _label(key: str, value: Any) -> str: """펼친 값 글 — 높이 등 `_m` 칸은 `2.0` 처럼 소수 한 자리는 남김.""" if value is None or value == "": return EMPTY_LABEL number = _number(value) if number is None or isinstance(value, str) and not key.endswith("_m"): return str(value) if number.is_integer(): return f"{number:.1f}" if key.endswith("_m") else str(int(number)) return f"{number:.3f}".rstrip("0").rstrip(".") def _length(item: dict[str, Any]) -> float | None: stated = _number(item["options"].get("length_m")) if stated: return stated start, end = _number(item.get("start_m")), _number(item.get("end_m")) return abs(end - start) if start is not None and end is not None else None def _source_value(source: dict[str, Any], item: dict[str, Any]) -> float | None: kind = source.get("값") if kind == "개소": return 1.0 if kind == "길이": return _length(item) if kind == "관연장": return item.get("관연장") if not kind or kind == "식": return None return _number(item["options"].get(kind)) def _matches(source: dict[str, Any], item: dict[str, Any]) -> bool: if source.get("종류") != item["type_id"]: return False for key, want in (source.get("조건") or {}).items(): if str(item["options"].get(key)) != str(want): return False # 펼칠 칸이 모두 비면 그 시설이 없는 것(예: 날개벽 형식 없음 → 관보호공 없음) keys = [key for key in source.get("펼침") or [] if not key.startswith("=")] return not keys or any(item["options"].get(key) not in (None, "") for key in keys) def _sources(binding: dict[str, Any]) -> list[dict[str, Any]]: main = {key: binding.get(key) for key in ("종류", "펼침", "값", "조건")} return [main, *(binding.get("더함") or [])] def _expansion(source: dict[str, Any], item: dict[str, Any]) -> tuple[str, ...]: values = [] for key in source.get("펼침") or []: values.append(key[1:] if key.startswith("=") else _label(key, item["options"].get(key))) return tuple(values) def _position_orders(binding: dict[str, Any], choices: dict[str, list[str]]) -> list[list[str]]: """펼침 자리마다 차례 목록 — 고정 글(`=찰쌓기`)은 출처 차례 · 칸은 choices 차례.""" orders: list[list[str]] = [] for source in _sources(binding): for index, key in enumerate(source.get("펼침") or []): while len(orders) <= index: orders.append([]) listed = [key[1:]] if key.startswith("=") else choices.get(key, []) orders[index].extend(value for value in listed if value not in orders[index]) return orders def _order_key(values: tuple[str, ...], orders: list[list[str]]) -> tuple[Any, ...]: """차례 목록 → 수 차례 → 글 차례 · 빈 값(-)은 끝.""" out: list[Any] = [] for index, value in enumerate(values): listed = orders[index] if index < len(orders) else [] number = _number(value) out.append( ( value == EMPTY_LABEL, listed.index(value) if value in listed else len(listed), number if number is not None else float("inf"), value, ) ) return tuple(out) # ── 측점 ────────────────────────────────────────────── def station_label(chainage_m: float, interval_m: float = 20.0) -> str: """`NO.12` · `NO.12+5` · `NO.12+5.5` — 실무 구조물위치 표기.""" safe = interval_m if interval_m > 0 else 20.0 number = int((chainage_m + 1e-6) // safe) remainder = round(chainage_m - number * safe, 2) if remainder >= safe - 0.005: number, remainder = number + 1, 0.0 if abs(remainder) < 0.005: return f"NO.{number}" text = f"{remainder:.2f}".rstrip("0").rstrip(".") return f"NO.{number}+{text}" def _span(item: dict[str, Any]) -> tuple[float, float | None]: start, end = _number(item.get("start_m")), _number(item.get("end_m")) if item["placement"] == "interval" and start is not None and end is not None: low, high = sorted((start, end)) if high - low > 1e-6: return round(low, 2), round(high, 2) return round(float(item["chainage_m"]), 2), None def _row_id(start: float, end: float | None, index: int) -> str: text = f"s{start:.2f}" + (f"~{end:.2f}" if end is not None else "") return text + (f"#{index}" if index else "") # ── 채우기 ──────────────────────────────────────────── def _choices(types: dict[str, StructureType]) -> dict[str, list[str]]: found: dict[str, list[str]] = {} for definition in types.values(): for field in definition.options: if field.choices and field.key not in found: found[field.key] = list(field.choices) return found def _rewrite(formula: str, siblings: set[str], suffix: str) -> str: return _REF.sub( lambda m: f"[{m.group(1)}|{suffix}]" if m.group(1) in siblings else m.group(0), formula ) def _blank_vars(formula: str, variables: dict[str, Any]) -> list[str]: names = re.findall(r"\[\$([^\]]+)\]", formula) return [name for name in names if variables.get(name) in (None, "")] def _unexpand(columns: list[dict[str, Any]]) -> list[dict[str, Any]]: """채운 표가 작업본으로 저장됐으면 펼친 열을 마스터 칸(`원열`)으로 되돌림 — 두 번 안 펼침.""" out: list[dict[str, Any]] = [] seen: set[str] = set() for column in columns: origin = (column.get("바인딩") or {}).get("원열") if origin is None: out.append(column) elif origin["id"] not in seen: seen.add(origin["id"]) out.append(origin) return out def _expand_columns( columns: list[dict[str, Any]], items: list[dict[str, Any]], variables: dict[str, Any], choices: dict[str, list[str]], ) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]: """펼침 열 → 값마다 열. 돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`.""" columns = _unexpand(columns) groups: dict[str, set[tuple[str, ...]]] = {} orders_of: dict[str, list[list[str]]] = {} for column in columns: binding = column.get("바인딩") or {} if not column.get("펼침") or not binding: continue group = binding.get("묶음") or column["id"] orders_of.setdefault(group, _position_orders(binding, choices)) found = groups.setdefault(group, set()) for source in _sources(binding): # 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함 found.update(_expansion(source, item) for item in items if _matches(source, item)) members: dict[str, set[str]] = {} for column in columns: binding = column.get("바인딩") or {} if column.get("펼침") and binding: members.setdefault(binding.get("묶음") or column["id"], set()).add(column["id"]) out: list[dict[str, Any]] = [] made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {} for column in columns: binding = column.get("바인딩") or {} if not column.get("펼침") or not binding: out.append(copy.deepcopy(column)) continue group = binding.get("묶음") or column["id"] ordered = sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders_of[group])) names = binding.get("이름") or {} for values in ordered: shown = [names.get(value, value) for value in values] suffix = "|".join(values) new = copy.deepcopy(column) new["id"] = f"{column['id']}|{suffix}" new["펼침"] = False new["머리"] = [ None if part is None else part.format(*shown) for part in (binding.get("머리틀") or column["머리"]) ] new["바인딩"] = {**binding, "펼친값": list(values), "원열": copy.deepcopy(column)} formula = binding.get("식틀") formula = formula.format(*values) if formula else column.get("식") if formula: formula = _rewrite(formula, members.get(group, set()), suffix) if _blank_vars(formula, variables): new.pop("식", None) new["설명"] = f"{column.get('설명', '')} · 변수가 빈칸이라 계산 안 함".strip() else: new["식"] = formula out.append(new) made.setdefault(column["id"], []).append((new["id"], suffix, new)) return out, made def fill_document( document: dict[str, Any], items: list[dict[str, Any]], types: dict[str, StructureType] | None = None, ) -> dict[str, Any]: """표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본).""" types = types or structure_type_map() doc = copy.deepcopy(document) variables = dict(doc.get("변수") or {}) interval = _number(variables.get("측점간격_m")) or 20.0 rule_columns = _unexpand(doc.get("열") or []) columns, made = _expand_columns(rule_columns, items, variables, _choices(types)) notes: list[str] = [] # 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔) slots: dict[tuple[float, float | None, int], dict[str, Any]] = {} pipes_at: dict[tuple[float, float | None], int] = {} item_slot: list[tuple[float, float | None, int]] = [] for item in items: start, end = _span(item) index = 0 if item["type_id"] == "pipe": index = pipes_at.get((start, end), 0) pipes_at[(start, end)] = index + 1 slot = (start, end, index) slots.setdefault(slot, {"id": _row_id(*slot), "값": {}}) item_slot.append(slot) for column in columns: binding = column.get("바인딩") or {} if not binding or column.get("손"): continue expanded = binding.get("펼친값") for source in _sources(binding): if source.get("값") in (None, "식"): continue for item, slot in zip(items, item_slot, strict=True): if not _matches(source, item): continue if expanded is not None and list(_expansion(source, item)) != expanded: continue value = _source_value(source, item) if value is None: continue cells = slots[slot]["값"] cells[column["id"]] = round((cells.get(column["id"]) or 0) + value, 3) # 포장 줄눈 간격이 변수와 다르면 그 줄 식에 박음(줄 식이 열 식을 이김) base_spacing = _number(variables.get("수축줄눈_간격_m")) for item, slot in zip(items, item_slot, strict=True): spacing = _number(item["options"].get("joint_spacing_m")) if item["type_id"] != "pavement_concrete" or spacing in (None, base_spacing): continue for new_id, _, new in made.get("pv_jt", []): if list(_expansion({"펼침": new["바인딩"]["펼침"]}, item)) == new["바인딩"]["펼친값"]: text = new.get("식", "").replace("[$수축줄눈_간격_m]", _label("", spacing)) slots[slot].setdefault("식", {})[new_id] = text missing = sum(1 for item in items if item["type_id"] == "pipe" and not item.get("관연장")) if missing: notes.append(f"관 연장 없음 {missing}곳 — B06 횡단 설계 전이면 빈칸") loose = sum( 1 for item in items if item["type_id"] == "guardrail" and not item["options"].get("kind") ) if loose: notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈") # 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄 hand = _hand_ids(columns) old_rows = {row.get("id"): row for row in doc.get("줄") or []} fixed = [row for row in doc.get("줄") or [] if row.get("고정")] added = [row for row in doc.get("줄") or [] if row.get("손") and not row.get("고정")] design_rows = [] for number, slot in enumerate(sorted(slots), start=1): row = slots[slot] start, end, _ = slot label = station_label(start, interval) if end is not None: label = f"{label}~{station_label(end, interval)}" row["값"].update({"no": str(number), "sta": label}) previous = old_rows.get(row["id"]) or {} for key, value in (previous.get("값") or {}).items(): if key in hand: row["값"][key] = value design_rows.append(row) # 설계에서 빠진 줄의 손 값은 버리지 않음 — 사용자 줄로 남겨 보이게 kept = {row["id"] for row in design_rows} for row in doc.get("줄") or []: if row.get("고정") or row.get("손") or row.get("id") in kept: continue values = { k: v for k, v in (row.get("값") or {}).items() if k in hand and v not in (None, "") } if values: sta = (row.get("값") or {}).get("sta") memo = " · ".join(filter(None, [str(values.get("memo") or ""), ORPHAN_NOTE])) added.append({"id": row["id"], "값": {"sta": sta, **values, "memo": memo}, "손": True}) doc["열"] = columns doc["줄"] = [*fixed, *design_rows, *added] doc["알림"] = notes doc[RULE_COLUMNS_KEY] = copy.deepcopy(rule_columns) # 저장 때 설계에 안 쓰인 펼침 열도 되살림 return doc def _hand_ids(columns: list[dict[str, Any]]) -> set[str]: """사람이 적는 열 — 바인딩 없는 열(손 열 · 비고 · 사용자가 더한 열) · 차례 · 측점 빼고.""" return { column["id"] for column in columns if not column.get("바인딩") and column["id"] not in ("no", "sta") } def _restore_rules( columns: list[dict[str, Any]], rules: list[dict[str, Any]] ) -> list[dict[str, Any]]: """채울 때 설계 값이 없어 빠졌던 펼침 열을 원래 자리(앞 열 뒤)에 되살림.""" out = list(columns) for index, rule in enumerate(rules): if not rule.get("펼침") or any(c["id"] == rule["id"] for c in out): continue before = [r["id"] for r in rules[:index]] at = max((i + 1 for i, c in enumerate(out) if c["id"] in before), default=0) out.insert(at, copy.deepcopy(rule)) return out def strip_design(document: dict[str, Any]) -> dict[str, Any]: """작업본에 둘 것만 — 양식(열 · 식 · 변수 · 머리) + 손 값 + 전구간 줄 · 사용자 줄. 채운 표가 [저장]으로 와도 설계값 · 펼친 열은 버림(펼침 규칙 열 하나로 되돌림) · 측점 줄은 손 값이 있을 때만 `{id(측점 키), sta, 손 값}` 으로 남김 — 다시 채우면 같은 측점에 붙음. """ doc = copy.deepcopy(document) for key in ("알림", "결과"): doc.pop(key, None) columns = _restore_rules(_unexpand(doc.get("열") or []), doc.pop(RULE_COLUMNS_KEY, None) or []) hand = _hand_ids(columns) rows = [] for row in doc.get("줄") or []: if row.get("고정"): rows.append(row) continue values = row.get("값") or {} kept = {k: v for k, v in values.items() if k in hand and v not in (None, "")} if row.get("손"): formulas = {k: v for k, v in (row.get("식") or {}).items() if k in hand} new = {**row, "값": {"sta": values.get("sta"), **kept}} new.pop("식", None) if formulas: new["식"] = formulas rows.append(new) elif kept: rows.append({"id": row["id"], "값": {"sta": values.get("sta"), **kept}}) doc["열"] = columns doc["줄"] = rows return doc def is_fillable(document: Any) -> bool: """설계값을 받는 표인가 — 바인딩 열이 하나라도 있으면.""" return isinstance(document, dict) and any( isinstance(column, dict) and column.get("바인딩") for column in document.get("열") or [] ) def fill_table( project_root: str | Path, document: dict[str, Any], pipe_lengths: dict[float, float] | None = None, ) -> dict[str, Any]: """프로젝트 설계로 표 문서를 채움 — 저장 안 함.""" types = structure_type_map() return fill_document(document, collect_items(project_root, pipe_lengths, types), types) def recalc(document: dict[str, Any]) -> dict[str, Any] | None: """계산 열 — 표 재계산(sub3 `common_util_sheet_recalc`)이 있으면 그 결과 · 없으면 None.""" try: from common_util.common_util_sheet_recalc import recalc_sheet except ImportError: return None return recalc_sheet(document)