from datetime import datetime from io import BytesIO from typing import Any from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.worksheet.worksheet import Worksheet from openpyxl.utils import get_column_letter from pyproj import Transformer from app.native import wndb class SensorPlacementNotFoundError(LookupError): pass class SensorPlacementValidationError(ValueError): pass class SensorPlacementConflictError(RuntimeError): pass _to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True) _STATUS_LABELS = { "current": "当前方案", "original": "原方案", "added": "新增", "replaced": "替换", } _COORDINATE_DESCRIPTION = ( "工程 X/Y: 项目地方坐标系;地图 X/Y: EPSG:3857;经纬度: WGS84" ) _LIST_HEADERS = ( "序号", "节点 ID", "经度", "纬度", "工程 X", "工程 Y", "地图 X", "地图 Y", "高程", "调整状态", ) _LIST_COLUMN_WIDTHS = (8, 20, 16, 16, 18, 18, 18, 18, 14, 14) def _normalize_locations(sensor_location: list[str]) -> list[str]: normalized = [str(node_id).strip() for node_id in sensor_location] if not normalized or any(not node_id for node_id in normalized): raise SensorPlacementValidationError("监测点列表不能为空") if len(set(normalized)) != len(normalized): raise SensorPlacementValidationError("监测点列表不能包含重复节点") return normalized def _sensor_points( network: str, sensor_location: list[str], ) -> list[dict[str, Any]]: nodes = wndb.get_sensor_placement_nodes(network, sensor_location) by_id = {str(node["node_id"]): node for node in nodes} missing = [node_id for node_id in sensor_location if node_id not in by_id] if missing: raise SensorPlacementValidationError( f"以下节点不存在或不是 junction: {', '.join(missing)}" ) points: list[dict[str, Any]] = [] for node_id in sensor_location: node = by_id[node_id] project_x = float(node["project_x"]) project_y = float(node["project_y"]) map_x = float(node["map_x"]) map_y = float(node["map_y"]) longitude, latitude = _to_wgs84.transform(map_x, map_y) points.append( { "node_id": node_id, "project_x": project_x, "project_y": project_y, "map_x": map_x, "map_y": map_y, "longitude": float(longitude), "latitude": float(latitude), "elevation": float(node["elevation"]), } ) return points def validate_sensor_placement_nodes( network: str, sensor_location: list[str], ) -> None: _sensor_points(network, _normalize_locations(sensor_location)) def get_sensor_placement_scheme(network: str, scheme_id: int) -> dict[str, Any]: scheme = wndb.get_sensor_placement(network, scheme_id) if scheme is None: raise SensorPlacementNotFoundError("监测点方案不存在") locations = [str(item) for item in (scheme.get("sensor_location") or [])] return { **scheme, "sensor_number": len(locations), "sensor_location": locations, "sensor_points": _sensor_points(network, locations), } def update_sensor_placement_scheme( network: str, scheme_id: int, *, expected_sensor_location: list[str], sensor_location: list[str], ) -> dict[str, Any]: expected = _normalize_locations(expected_sensor_location) next_locations = _normalize_locations(sensor_location) _sensor_points(network, next_locations) updated = wndb.update_sensor_placement( network, scheme_id, expected_sensor_location=expected, sensor_location=next_locations, ) if updated is None: if wndb.get_sensor_placement(network, scheme_id) is None: raise SensorPlacementNotFoundError("监测点方案不存在") raise SensorPlacementConflictError("方案已被其他用户修改,请重新加载") return get_sensor_placement_scheme(network, scheme_id) def can_edit_sensor_placement(user: Any, scheme: dict[str, Any]) -> bool: return bool( getattr(user, "is_superuser", False) or getattr(user, "role", None) == "admin" or getattr(user, "username", None) == scheme.get("username") ) def _safe_excel_text(value: Any) -> str: text = "" if value is None else str(value) if text.startswith(("=", "+", "-", "@")): return f"'{text}" return text def _populate_info_sheet( sheet: Worksheet, *, network: str, scheme: dict[str, Any], location_count: int, is_draft: bool, ) -> None: created_at = scheme["create_time"] if isinstance(created_at, datetime): created_at = created_at.isoformat(timespec="minutes") rows = [ ("项目", network), ("方案名称", scheme["scheme_name"]), ("监测点数量", location_count), ("最小管径", scheme["min_diameter"]), ("创建人", scheme["username"]), ("创建时间", created_at), ("导出时间", datetime.now().astimezone().isoformat(timespec="minutes")), ("文档状态", "未保存草稿" if is_draft else "当前方案"), ("坐标说明", _COORDINATE_DESCRIPTION), ] for row_index, (label, value) in enumerate(rows, start=1): sheet.cell(row=row_index, column=1, value=label) safe_value = _safe_excel_text(value) if isinstance(value, str) else value sheet.cell(row=row_index, column=2, value=safe_value) sheet.column_dimensions["A"].width = 18 sheet.column_dimensions["B"].width = 64 def _populate_list_sheet( sheet: Worksheet, *, points: list[dict[str, Any]], adjustment_status: dict[str, str], ) -> None: sheet.append(_LIST_HEADERS) for index, point in enumerate(points, start=1): status = adjustment_status.get(point["node_id"], "current") sheet.append( [ index, _safe_excel_text(point["node_id"]), point["longitude"], point["latitude"], point["project_x"], point["project_y"], point["map_x"], point["map_y"], point["elevation"], _STATUS_LABELS.get(status, "当前方案"), ] ) header_fill = PatternFill("solid", fgColor="257DD4") for cell in sheet[1]: cell.fill = header_fill cell.font = Font(color="FFFFFF", bold=True) cell.alignment = Alignment(horizontal="center", vertical="center") sheet.freeze_panes = "A2" sheet.auto_filter.ref = sheet.dimensions for index, width in enumerate(_LIST_COLUMN_WIDTHS, start=1): sheet.column_dimensions[get_column_letter(index)].width = width for row in sheet.iter_rows(min_row=2): row[0].alignment = Alignment(horizontal="center") for cell in row[2:9]: cell.number_format = "0.000000" def build_sensor_placement_workbook( *, network: str, scheme: dict[str, Any], sensor_location: list[str], adjustment_status: dict[str, str], ) -> BytesIO: locations = _normalize_locations(sensor_location) points = _sensor_points(network, locations) is_draft = locations != list(scheme["sensor_location"]) workbook = Workbook() info_sheet = workbook.active info_sheet.title = "方案信息" _populate_info_sheet( info_sheet, network=network, scheme=scheme, location_count=len(locations), is_draft=is_draft, ) list_sheet = workbook.create_sheet("监测点清单") _populate_list_sheet( list_sheet, points=points, adjustment_status=adjustment_status, ) output = BytesIO() workbook.save(output) output.seek(0) return output