feat(M02): 구조물 집계표 설계값 채우기 — 펼침 열 · 측점 줄 · 관 줄 나눔 · 손 값 지킴 · 채운 표 길에 B06 관 연장 (PLAN 10-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
This commit is contained in:
@@ -10,17 +10,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_workflow_route_context,
|
||||
)
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"])
|
||||
|
||||
Layer = Literal["system", "company", "personal", "project"]
|
||||
@@ -103,6 +112,17 @@ async def _company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
return [{"project_id": str(row[0]), "name": row[1], "storage_path": row[2]} for row in rows]
|
||||
|
||||
|
||||
async def _cross_designs(project_id: str) -> list[dict[str, Any]]:
|
||||
"""최신 노선의 B06 횡단 설계 — 못 읽으면 빈 목록(관 연장이 빈칸으로 섬 · 0 아님)."""
|
||||
try:
|
||||
context = await run_with_connection(get_workflow_route_context, UUID(str(project_id)))
|
||||
route_id = int((context or {}).get("route_id") or 0)
|
||||
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
|
||||
except Exception:
|
||||
logger.exception("M02 채운 표 — 횡단 설계 조회 실패: project_id=%s", project_id)
|
||||
return []
|
||||
|
||||
|
||||
# ── 권한 · 자리 ───────────────────────────────────────
|
||||
|
||||
|
||||
@@ -397,9 +417,7 @@ async def filled_table(
|
||||
name: str,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""설계값을 채운 표 문서 — 저장 안 함(5장 ③ 서버 단독)."""
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
|
||||
"""설계값을 채운 표 문서 — 저장 안 함(5장 ③ 서버 단독) · `결과` = 계산 열(없으면 null)."""
|
||||
project = await _project(session, project_id)
|
||||
try:
|
||||
found = await asyncio.to_thread(
|
||||
@@ -413,5 +431,7 @@ async def filled_table(
|
||||
raise _bad(error) from error
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
|
||||
document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"])
|
||||
return {"이름": name, "판": found["판"], "문서": document}
|
||||
lengths = fill.pipe_lengths_from_designs(await _cross_designs(project_id))
|
||||
document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"], lengths)
|
||||
result = await asyncio.to_thread(fill.recalc, document)
|
||||
return {"이름": name, "판": found["판"], "문서": document, "결과": result}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"""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 = "-"
|
||||
_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
|
||||
columns, made = _expand_columns(doc.get("열") or [], 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 = {column["id"] for column in columns if column.get("손")}
|
||||
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")
|
||||
added.append({"id": row["id"], "값": {"sta": sta, **values}, "손": True})
|
||||
doc["열"] = columns
|
||||
doc["줄"] = [*fixed, *design_rows, *added]
|
||||
doc["알림"] = notes
|
||||
return doc
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""M02 구조물 집계표 채우기 — 펼침 열 · 측점 줄 · 관 줄 나눔 · 손 값 지킴 (임시 프로젝트)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
|
||||
MASTER = config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json"
|
||||
|
||||
|
||||
def _master() -> dict[str, Any]:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "proj"
|
||||
route = root / "B05_Profile/route"
|
||||
route.mkdir(parents=True)
|
||||
structures = [
|
||||
{
|
||||
"type_id": "masonry_dry",
|
||||
"chainage_m": 85,
|
||||
"start_m": 80,
|
||||
"end_m": 90,
|
||||
"options": {"height_m": 2.0, "length_m": 10},
|
||||
},
|
||||
{
|
||||
"type_id": "masonry_wet",
|
||||
"chainage_m": 80,
|
||||
"start_m": 80,
|
||||
"end_m": 90,
|
||||
"options": {"height_m": 2.5, "length_m": 10},
|
||||
},
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 205,
|
||||
"start_m": 200,
|
||||
"end_m": 230,
|
||||
"options": {"length_m": 30, "width_m": 3, "thickness_cm": 20, "joint_spacing_m": 5},
|
||||
},
|
||||
{"type_id": "position_sign", "chainage_m": 300},
|
||||
{
|
||||
"type_id": "boulder_masonry",
|
||||
"chainage_m": 325,
|
||||
"start_m": 320,
|
||||
"end_m": 330,
|
||||
"options": {"height_m": 2.5, "length_m": 10, "bond": "메쌓기", "stone_cm": "40~60"},
|
||||
},
|
||||
]
|
||||
for index, item in enumerate(structures):
|
||||
item.setdefault("structure_id", f"st{index}")
|
||||
item.setdefault("placement", "interval" if "start_m" in item else "point")
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
edits = root / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True)
|
||||
points = [
|
||||
{"chainage_m": 85.05, "source": "user", "options": {"pipe_diameter_mm": "1000"}},
|
||||
{"chainage_m": 85.05, "source": "user", "options": {"pipe_kind": "흄관"}},
|
||||
{"chainage_m": 173.09, "source": "user", "facility": "ford_bridge", "options": {}},
|
||||
]
|
||||
(edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _filled(project: Path, document: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return fill.fill_table(project, document or _master(), {85.0: 14.0})
|
||||
|
||||
|
||||
def _col(doc: dict[str, Any], col_id: str) -> dict[str, Any]:
|
||||
return next(column for column in doc["열"] if column["id"] == col_id)
|
||||
|
||||
|
||||
def test_펼침_열은_설계에_쓰인_값만_차례대로(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
ids = [column["id"] for column in doc["열"]]
|
||||
assert [i for i in ids if i.startswith("ms|")] == ["ms|찰쌓기|2.5", "ms|메쌓기|2.0"]
|
||||
assert _col(doc, "ms|메쌓기|2.0")["머리"] == ["돌쌓기", "메쌓기", "H=2.0"]
|
||||
assert [i for i in ids if i.startswith("pp_len|")] == [
|
||||
"pp_len|흄관|1000",
|
||||
"pp_len|파형강관|1000",
|
||||
]
|
||||
assert not any(i.startswith("rw|") for i in ids) # 옹벽 없음 → 열 없음
|
||||
assert "rv" not in ids and "ps" in ids # 펼침 마스터 칸은 빠지고 고정 열은 남음
|
||||
# 관 유입·유출 기슭막이는 레지스트리 기본값(찰 · 메 · 높이 없음)으로 열이 섬 · id 는 설계 값
|
||||
assert "rv|돌쌓기(찰)|-" in ids and "rv|돌쌓기(메)|-" in ids
|
||||
assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "H=-"]
|
||||
assert not any(i.startswith("pg_in|") for i in ids) # 날개벽·집수정 형식 없음 → 관보호공 없음
|
||||
|
||||
|
||||
def test_계산_열_식은_같은_묶음_열로_바뀌고_빈_변수면_안_셈(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
assert _col(doc, "pv_a|20")["식"] == 'IF([pv_l|20]>0,[pv_b|20]*[pv_l|20]+[pv_w|20],"")'
|
||||
cp = _col(doc, "pp_cp|파형강관|1000")["식"]
|
||||
assert "[pp_len|파형강관|1000]" in cp and "[$파형강관_1본_m]" in cp
|
||||
assert "식" not in _col(doc, "pp_cp|흄관|1000") # 흄관 1본 길이 빈칸 → 계산 안 함
|
||||
assert _col(doc, "bm_area|메쌓기|40~60|2.5")["식"] == (
|
||||
'IF([bm_len|메쌓기|40~60|2.5]>0,[bm_len|메쌓기|40~60|2.5]*2.5,"")'
|
||||
)
|
||||
|
||||
|
||||
def test_측점_줄_같은_측점은_한_줄_관은_나눔(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
rows = {row["id"]: row for row in doc["줄"]}
|
||||
assert doc["줄"][0]["고정"] == "전구간"
|
||||
interval = rows["s80.00~90.00"]["값"]
|
||||
assert interval["sta"] == "NO.4~NO.4+10"
|
||||
assert interval["ms|찰쌓기|2.5"] == 10 and interval["ms|메쌓기|2.0"] == 10
|
||||
first, second = rows["s85.05"]["값"], rows["s85.05#1"]["값"]
|
||||
assert first["sta"] == second["sta"] == "NO.4+5.05"
|
||||
assert first["pp_len|파형강관|1000"] == 14.0 and "pp_len|흄관|1000" not in first
|
||||
assert second["pp_len|흄관|1000"] == 14.0 # 한 측점 횡단 관 연장은 하나 — 두 관이 같이 씀
|
||||
assert first["rv|돌쌓기(찰)|-"] == second["rv|돌쌓기(찰)|-"] == 10
|
||||
assert rows["s300.00"]["값"]["ps"] == 1
|
||||
assert [row["값"].get("no") for row in doc["줄"][1:]] == [str(i) for i in range(1, 8)]
|
||||
assert rows["s200.00~230.00"]["식"]["pv_jt|20"].count("/5)") == 1 # 줄눈 간격 5 박음
|
||||
assert doc["알림"] == []
|
||||
bare = fill.fill_table(project, _master(), {})
|
||||
assert bare["알림"] == ["관 연장 없음 2곳 — B06 횡단 설계 전이면 빈칸"]
|
||||
assert not any("pp_len" in key for row in bare["줄"] for key in row["값"])
|
||||
|
||||
|
||||
def test_손_값과_전구간_줄은_다시_채워도_지킴(project: Path) -> None:
|
||||
doc = _filled(project)
|
||||
doc["줄"][0]["값"]["ps"] = 3
|
||||
next(row for row in doc["줄"] if row["id"] == "s300.00")["값"]["h_intake"] = 2
|
||||
doc["줄"].append({"id": "u1", "값": {"sta": "NO.9", "h_rockfall": 4}, "손": True})
|
||||
doc["줄"].append({"id": "s999.00", "값": {"sta": "NO.49+19", "h_waste": 1.5}})
|
||||
again = _filled(project, doc)
|
||||
rows = {row["id"]: row for row in again["줄"]}
|
||||
assert rows["all"]["값"]["ps"] == 3
|
||||
assert rows["s300.00"]["값"]["h_intake"] == 2
|
||||
assert rows["u1"]["값"]["h_rockfall"] == 4
|
||||
assert rows["s999.00"]["손"] is True and rows["s999.00"]["값"]["h_waste"] == 1.5
|
||||
assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]] # 두 번 펼치지 않음
|
||||
|
||||
|
||||
def test_측점_표기() -> None:
|
||||
assert fill.station_label(0) == "NO.0"
|
||||
assert fill.station_label(38) == "NO.1+18"
|
||||
assert fill.station_label(59.999) == "NO.3"
|
||||
assert fill.station_label(2413.5) == "NO.120+13.5"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -181,3 +182,29 @@ def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> N
|
||||
for name in ("..", "_initial", "a.b/c"):
|
||||
with pytest.raises(ValueError):
|
||||
layers.template_path(layers.system_dir(), "table", name)
|
||||
|
||||
|
||||
def test_채운_표는_작업본을_채워_돌려주고_저장_안_함(
|
||||
world: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def designs(project_id: str) -> list[dict[str, Any]]:
|
||||
return [{"chainage_m": 40.0, "design": {"pipe_length_m": 12}}]
|
||||
|
||||
monkeypatch.setattr(router_module, "_cross_designs", designs)
|
||||
monkeypatch.setattr(router_module.fill, "recalc", lambda document: {"계산": {}})
|
||||
root = _root(world, P1)
|
||||
master = config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json"
|
||||
layers.write_template(
|
||||
root / "templates", "table", "구조물집계표", json.loads(master.read_text(encoding="utf-8"))
|
||||
)
|
||||
edits = root / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True, exist_ok=True)
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"points": [{"chainage_m": 40.0, "source": "user"}]}), encoding="utf-8"
|
||||
)
|
||||
before = layers.version_of(root / "templates/table/구조물집계표.json")
|
||||
got = world["client"].get(f"/api/m02/projects/{P1}/tables/구조물집계표/filled").json()
|
||||
row = next(r for r in got["문서"]["줄"] if r["id"] == "s40.00")
|
||||
assert row["값"]["sta"] == "NO.2" and row["값"]["pp_len|파형강관|1000"] == 12
|
||||
assert got["결과"] == {"계산": {}} and got["판"] == before
|
||||
assert layers.version_of(root / "templates/table/구조물집계표.json") == before
|
||||
|
||||
Reference in New Issue
Block a user