diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index b6a714be..27aaade5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -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} diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py new file mode 100644 index 00000000..71790d56 --- /dev/null +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -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) diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py new file mode 100644 index 00000000..6d390bbd --- /dev/null +++ b/resources/tester/test_m02_table_fill.py @@ -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" diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index ab784375..1951b72b 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -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