from datetime import datetime from io import BytesIO from typing import Any from uuid import UUID 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.infra.db.postgresql import sensor_placement as sensor_placement_repository 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 = sensor_placement_repository.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, "max_pipe_diameter": ( float(node["max_pipe_diameter"]) if node["max_pipe_diameter"] is not None else None ), "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 get_sensor_placement_candidate( network: str, node_id: str, ) -> dict[str, Any]: """Return the authoritative editable point data for one junction.""" return _sensor_points(network, _normalize_locations([node_id]))[0] def validate_sensor_placement_nodes( network: str, sensor_location: list[str], ) -> None: _sensor_points(network, _normalize_locations(sensor_location)) def get_sensor_placement_run(network: str, run_id: UUID) -> dict[str, Any]: run = sensor_placement_repository.get_sensor_placement(network, run_id) if run is None: raise SensorPlacementNotFoundError("监测点优化运行不存在") locations = [str(item) for item in (run.get("sensor_locations") or [])] return { **run, "sensor_count": len(locations), "sensor_locations": locations, "sensor_points": _sensor_points(network, locations), } def list_sensor_placement_runs(network: str) -> list[dict[str, Any]]: return [ { **run, "sensor_points": _sensor_points(network, run["sensor_locations"]), } for run in sensor_placement_repository.get_all_sensor_placements(network) ] def update_sensor_placement_run( network: str, run_id: UUID, *, expected_sensor_locations: list[str], sensor_locations: list[str], ) -> dict[str, Any]: expected = _normalize_locations(expected_sensor_locations) next_locations = _normalize_locations(sensor_locations) _sensor_points(network, next_locations) updated = sensor_placement_repository.update_sensor_placement( network, run_id, expected_sensor_locations=expected, sensor_locations=next_locations, ) if updated is None: if sensor_placement_repository.get_sensor_placement(network, run_id) is None: raise SensorPlacementNotFoundError("监测点优化运行不存在") raise SensorPlacementConflictError("运行结果已被其他用户修改,请重新加载") return get_sensor_placement_run(network, run_id) def can_edit_sensor_placement(user: Any, run: dict[str, Any]) -> bool: return bool( getattr(user, "is_superuser", False) or getattr(user, "role", None) == "admin" or getattr(user, "username", None) == run.get("created_by") ) 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["created_at"] if isinstance(created_at, datetime): created_at = created_at.isoformat(timespec="minutes") rows = [ ("项目", network), ("运行名称", scheme["name"]), ("监测点数量", location_count), ("最小管径", scheme["min_diameter"]), ("创建人", scheme["created_by"]), ("创建时间", 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_locations"]) 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