- 관공 등 묶음 열 차례 = 관종·관경마다 관매설 · 커플링 짝 — 머리 합치기가 값마다 묶임
- 머리틀은 맨 자리표 + 값꼴(H={} · T={}cm · Φ{}) · 빈 값은 빈값 글 · 열 id 는 그대로(빈 값 -)
- 포장 A · 수축줄눈 · 커플링 · 큰돌쌓기 면적 = 바인딩 대신 펼침틀(묶음 · 머리틀 · 식틀)
- 식틀 자리 값이 비면 식을 빼고 설명에 적음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
577 lines
24 KiB
Python
577 lines
24 KiB
Python
"""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 = "-" # 열 id 안 빈 값(저장본과 맞춤) — 머리 글은 `빈값`
|
|
EMPTY_TEXT = "미입력"
|
|
#: 계산 열이 설계값 열 묶음을 따라 나뉠 때 쓰는 틀(바인딩 아님 — 표 부품이 설계값으로 안 봄).
|
|
FRAME_KEY = "펼침틀"
|
|
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 _frame(column: dict[str, Any]) -> dict[str, Any]:
|
|
"""펼침 틀 — 설계값 열은 `바인딩` · 따라 나뉘는 계산 열은 `펼침틀`(설계값 딱지 없음)."""
|
|
return column.get("바인딩") or column.get(FRAME_KEY) or {}
|
|
|
|
|
|
def _group(column: dict[str, Any]) -> str:
|
|
return _frame(column).get("묶음") or column["id"]
|
|
|
|
|
|
def _head_part(frame: dict[str, Any], index: int, value: str) -> str:
|
|
"""펼친 값 한 자리 머리 글 — `값꼴`(`H={}`) · 빈 값은 `빈값`(`높이 미입력`)."""
|
|
if value == EMPTY_LABEL:
|
|
empties = frame.get("빈값") or []
|
|
return (empties[index] if index < len(empties) else "") or EMPTY_TEXT
|
|
shapes = frame.get("값꼴") or []
|
|
shape = (shapes[index] if index < len(shapes) else "") or "{}"
|
|
return shape.replace("{}", str((frame.get("이름") or {}).get(value, value)))
|
|
|
|
|
|
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 = _frame(column).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]]]]]:
|
|
"""펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …).
|
|
|
|
돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`.
|
|
"""
|
|
columns = _unexpand(columns)
|
|
groups: dict[str, set[tuple[str, ...]]] = {}
|
|
orders_of: dict[str, list[list[str]]] = {}
|
|
members: dict[str, list[dict[str, Any]]] = {}
|
|
for column in columns:
|
|
if not column.get("펼침") or not _frame(column):
|
|
continue
|
|
members.setdefault(_group(column), []).append(column)
|
|
binding = column.get("바인딩")
|
|
if not binding:
|
|
continue # 계산 열 — 설계값 열이 연 값을 따름
|
|
orders_of.setdefault(_group(column), _position_orders(binding, choices))
|
|
found = groups.setdefault(_group(column), set())
|
|
for source in _sources(binding):
|
|
# 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함
|
|
found.update(_expansion(source, item) for item in items if _matches(source, item))
|
|
|
|
out: list[dict[str, Any]] = []
|
|
made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
|
|
for column in columns:
|
|
if not column.get("펼침") or not _frame(column):
|
|
out.append(copy.deepcopy(column))
|
|
continue
|
|
group = _group(column)
|
|
if members[group][0] is not column:
|
|
continue # 묶음 첫 열 자리에서 값마다 묶음 열을 한데 냄
|
|
orders = orders_of.get(group, [])
|
|
sibling_ids = {member["id"] for member in members[group]}
|
|
for values in sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders)):
|
|
for member in members[group]:
|
|
new = _expand_one(member, values, sibling_ids, variables)
|
|
out.append(new)
|
|
made.setdefault(member["id"], []).append((new["id"], "|".join(values), new))
|
|
return out, made
|
|
|
|
|
|
def _expand_one(
|
|
column: dict[str, Any],
|
|
values: tuple[str, ...],
|
|
siblings: set[str],
|
|
variables: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
frame = _frame(column)
|
|
suffix = "|".join(values)
|
|
shown = [_head_part(frame, index, value) for index, value in enumerate(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 (frame.get("머리틀") or column["머리"])
|
|
]
|
|
key = "바인딩" if column.get("바인딩") else FRAME_KEY
|
|
new[key] = {**frame, "펼친값": list(values), "원열": copy.deepcopy(column)}
|
|
template = frame.get("식틀")
|
|
used = [int(i) for i in re.findall(r"\{(\d+)\}", template or "")]
|
|
blank = ""
|
|
if any(values[i] == EMPTY_LABEL for i in used if i < len(values)):
|
|
formula, blank = None, "설계값이"
|
|
else:
|
|
formula = template.format(*values) if template else column.get("식")
|
|
if formula:
|
|
formula = _rewrite(formula, siblings, suffix)
|
|
blank = "변수가" if _blank_vars(formula, variables) else ""
|
|
if blank:
|
|
new.pop("식", None)
|
|
new["설명"] = f"{column.get('설명', '')} · {blank} 빈칸이라 계산 안 함".strip()
|
|
elif formula:
|
|
new["식"] = formula
|
|
return new
|
|
|
|
|
|
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, suffix, new in made.get("pv_jt", []):
|
|
if "|".join(_expansion({"펼침": ["thickness_cm"]}, item)) == suffix:
|
|
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 _frame(column) and 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)
|