From 0076d2db2fe440d182c7141a49642e509cc7cc82 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 14 Nov 2025 10:32:23 +0800 Subject: [PATCH] feat: add permit file management UI and APIs --- lawrisk/api/v2.py | 65 +++ lawrisk/services/licensing_repo.py | 756 ++++++++++++++++++++++++++++- static/db_admin.html | 753 ++++++++++++++++++++++++---- 3 files changed, 1468 insertions(+), 106 deletions(-) diff --git a/lawrisk/api/v2.py b/lawrisk/api/v2.py index c14c0fa..8902cee 100644 --- a/lawrisk/api/v2.py +++ b/lawrisk/api/v2.py @@ -29,6 +29,9 @@ from lawrisk.services.licensing_repo import ( fetch_permit_file, describe_permit_import_session, resolve_region_permit_theme, + list_stored_permit_files, + start_import_session_from_file, + delete_stored_permit_file, ) from lawrisk.services.lawrisk_service import suggest_questions_embed @@ -368,6 +371,68 @@ def admin_permit_import_commit(): return jsonify({"success": False, "message": str(exc)}), 500 +@v2_bp.route('/admin/permit-files', methods=['GET']) +def admin_list_permit_files(): + """List stored permit files with pagination.""" + try: + limit = int(request.args.get("limit", 20)) + except (TypeError, ValueError): + limit = 20 + limit = max(1, min(limit, 100)) + + try: + offset = int(request.args.get("offset", 0)) + except (TypeError, ValueError): + offset = 0 + offset = max(0, offset) + + keyword = request.args.get("keyword") or request.args.get("q") + + try: + data = list_stored_permit_files(limit=limit, offset=offset, keyword=keyword) + return jsonify({"success": True, "data": data}) + except Exception as exc: + print(f"admin_list_permit_files error: {exc}") + return jsonify({"success": False, "message": "文件列表加载失败"}), 500 + + +@v2_bp.route('/admin/permit-files//reimport', methods=['POST']) +def admin_reimport_permit_file(file_id: str): + """Re-create an import session from an archived Excel file.""" + if not file_id: + return jsonify({"success": False, "message": "file_id 不能为空"}), 400 + + user = get_current_user() or {} + requested_by = user.get("display_name") or user.get("username") or user.get("id") + + try: + data = start_import_session_from_file(file_id, requested_by=requested_by) + return jsonify({"success": True, "data": data}) + except ValueError as exc: + return jsonify({"success": False, "message": str(exc)}), 404 + except Exception as exc: + print(f"admin_reimport_permit_file error: {exc}") + return jsonify({"success": False, "message": "暂无法重新载入该文件"}), 500 + + +@v2_bp.route('/admin/permit-files/', methods=['DELETE']) +def admin_delete_permit_file(file_id: str): + """Delete an archived permit file.""" + if not file_id: + return jsonify({"success": False, "message": "file_id 不能为空"}), 400 + + try: + deleted = delete_stored_permit_file(file_id) + if deleted: + return jsonify({"success": True, "message": "文件已删除"}) + return jsonify({"success": False, "message": "文件不存在"}), 404 + except ValueError as exc: + return jsonify({"success": False, "message": str(exc)}), 400 + except Exception as exc: + print(f"admin_delete_permit_file error: {exc}") + return jsonify({"success": False, "message": "文件暂无法删除"}), 500 + + @v2_bp.route('/admin/permit-file/download', methods=['GET']) @login_required def admin_permit_file_download(): diff --git a/lawrisk/services/licensing_repo.py b/lawrisk/services/licensing_repo.py index 2a1ae7f..6eaef0c 100644 --- a/lawrisk/services/licensing_repo.py +++ b/lawrisk/services/licensing_repo.py @@ -53,6 +53,12 @@ MAX_PERMIT_FILE_SIZE_BYTES = 500 * 1024 # 500 KB limit for uploaded Excel files _PERMIT_IMPORT_SESSIONS: Dict[str, Dict[str, Any]] = {} _PERMIT_IMPORT_LOCK = threading.Lock() +ALL_THEMES_SENTINEL = "__ALL_THEMES__" +ALL_THEMES_DISPLAY_NAME = "所有主题" + +_PERMIT_THEME_OVERRIDE_SCHEMA_READY: Optional[bool] = None +_PERMIT_THEME_OVERRIDE_SCHEMA_LOCK = threading.Lock() + _IMPORT_HEADER_ALIASES: Dict[str, Set[str]] = { "theme_names": { "主题", @@ -204,6 +210,24 @@ def _clean_text(value: Any) -> str: return str(value).strip() +def _normalize_theme_binding_value(value: Any) -> str: + """Normalize theme binding payload values (including virtual markers).""" + text = _clean_text(value) + if not text: + return "" + lowered = text.lower() + if lowered == ALL_THEMES_SENTINEL.lower(): + return ALL_THEMES_SENTINEL + if text == ALL_THEMES_DISPLAY_NAME: + return ALL_THEMES_SENTINEL + return text + + +def _is_all_theme_marker(value: Any) -> bool: + """Return True if the provided value represents the virtual 'all themes' marker.""" + return _normalize_theme_binding_value(value) == ALL_THEMES_SENTINEL + + def _normalize_header_label(value: Any) -> str: """Normalise header labels by removing spaces and lowercasing.""" if value is None: @@ -941,6 +965,14 @@ def _purge_region_permit_relations(conn: pg.Connection, region_id: str, permit_i cur = conn.cursor() delete_counts: Dict[str, int] = {} + overrides_available = _PERMIT_THEME_OVERRIDE_SCHEMA_READY + if overrides_available is None: + try: + _ensure_permit_theme_override_schema() + overrides_available = True + except Exception: + overrides_available = False + statements = [ ("region_permit_risks", "DELETE FROM region_permit_risks WHERE region_id = %s AND permit_id = %s"), ("region_permit_scopes", "DELETE FROM region_permit_scopes WHERE region_id = %s AND permit_id = %s"), @@ -948,6 +980,13 @@ def _purge_region_permit_relations(conn: pg.Connection, region_id: str, permit_i ("region_permit_details", "DELETE FROM region_permit_details WHERE region_id = %s AND permit_id = %s"), ("region_theme_permits", "DELETE FROM region_theme_permits WHERE region_id = %s AND permit_id = %s"), ] + if overrides_available: + statements.append( + ( + "region_permit_theme_overrides", + "DELETE FROM region_permit_theme_overrides WHERE region_id = %s AND permit_id = %s", + ) + ) for key, sql in statements: cur.execute(sql, (region_id, permit_id)) @@ -973,6 +1012,7 @@ def commit_permit_import_session( overrides: Optional[Dict[str, Iterable[str]]] = None, edited_by: Optional[str] = None, change_summary: Optional[str] = None, + theme_bindings: Optional[Dict[str, Dict[str, Iterable[str]]]] = None, ) -> Dict[str, Any]: if not session_id: raise ValueError("导入会话无效") @@ -1012,6 +1052,27 @@ def commit_permit_import_session( _clean_text(name) for name in (permit_names or []) if _clean_text(name) } + theme_binding_map: Dict[str, Dict[str, List[str]]] = {} + if theme_bindings: + for sheet_key, permit_map in theme_bindings.items(): + sheet_token = _clean_text(sheet_key) + if not sheet_token: + continue + permit_binding: Dict[str, List[str]] = {} + for permit_key, theme_values in (permit_map or {}).items(): + permit_token = _clean_text(permit_key) + if not permit_token: + continue + normalized_themes: List[str] = [] + for raw_theme in theme_values or []: + normalized = _normalize_theme_binding_value(raw_theme) + if normalized: + normalized_themes.append(normalized) + if normalized_themes: + permit_binding[permit_token] = normalized_themes + if permit_binding: + theme_binding_map[sheet_token] = permit_binding + default_change_summary = change_summary or (f"Excel导入:{workbook_filename}" if workbook_filename else "Excel导入") result: Dict[str, Any] = { @@ -1034,10 +1095,12 @@ def commit_permit_import_session( stored_file_meta: Optional[Dict[str, Any]] = None stored_file_id: Optional[str] = None + region_theme_cache: Dict[str, List[Dict[str, str]]] = {} with _lic_pg_conn(autocommit=False) as conn: try: _ensure_permit_sources_table(conn) + _ensure_permit_theme_override_schema(conn) if session_file_bytes: _ensure_permit_file_schema(conn) stored_file_meta = _insert_permit_file_record( @@ -1135,6 +1198,18 @@ def commit_permit_import_session( ) permit_modified = True + binding_override = theme_binding_map.get(sheet_name, {}).get(canonical_permit_name) + override_theme_names: Set[str] = set() + binds_all_themes = False + if binding_override: + for override_value in binding_override: + if _is_all_theme_marker(override_value): + binds_all_themes = True + else: + cleaned_override = _clean_text(override_value) + if cleaned_override: + override_theme_names.add(cleaned_override) + theme_names: Set[str] = set() scope_descriptions: Set[str] = set() subitem_names: Set[str] = set() @@ -1144,7 +1219,8 @@ def commit_permit_import_session( jurisdiction_scope_val: Optional[str] = None for row in permit_rows: - theme_names.update(row.get("theme_names") or []) + if not binding_override or binds_all_themes: + theme_names.update(row.get("theme_names") or []) scope_descriptions.update(row.get("scope_descriptions") or []) subitem_names.update(row.get("subitem_names") or []) if not permit_status_val and row.get("permit_status"): @@ -1156,11 +1232,27 @@ def commit_permit_import_session( if not jurisdiction_scope_val and row.get("jurisdiction_scope"): jurisdiction_scope_val = row.get("jurisdiction_scope") + if binding_override and not binds_all_themes and override_theme_names: + theme_names = set(override_theme_names) + + if binds_all_themes and region_id: + cached_theme_options = region_theme_cache.get(region_id) + if cached_theme_options is None: + fetched_map = _fetch_region_theme_map(conn, [region_id]) + cached_theme_options = fetched_map.get(region_id, []) + region_theme_cache[region_id] = cached_theme_options + for option in cached_theme_options or []: + option_label = _clean_text(option.get("name")) + if option_label: + theme_names.add(option_label) + if not theme_names: theme_names.add("不涉及") theme_ids: List[str] = [] for theme_name in sorted(theme_names): + if _is_all_theme_marker(theme_name): + continue theme_id = _ensure_theme(conn, theme_name) theme_ids.append(theme_id) cur.execute( @@ -1171,6 +1263,11 @@ def commit_permit_import_session( """, (region_id, theme_id), ) + if cur.rowcount: + cache_entry = region_theme_cache.setdefault(region_id, []) + if not any(entry.get("id") == theme_id for entry in cache_entry): + cache_entry.append({"id": theme_id, "name": theme_name}) + _propagate_all_theme_bindings(conn, region_id, theme_id) cur.execute( """ INSERT INTO region_theme_permits (region_id, theme_id, permit_id) @@ -1180,6 +1277,16 @@ def commit_permit_import_session( (region_id, theme_id, permit_id), ) + if binds_all_themes: + _set_permit_bind_all_themes( + conn, + region_id, + permit_id, + created_by=edited_by or session_uploaded_by, + ) + elif binding_override: + _clear_permit_bind_all_themes(conn, region_id, permit_id) + for scope_desc in sorted(scope_descriptions): scope_id = _ensure_business_scope(conn, scope_desc) if not scope_id: @@ -1343,6 +1450,153 @@ def commit_permit_import_session( return result +def describe_permit_import_session(session_id: str) -> Dict[str, Any]: + """Return preview data for a pending permit import session.""" + if not session_id: + raise ValueError("导入会话无效") + + with _PERMIT_IMPORT_LOCK: + session_payload = _PERMIT_IMPORT_SESSIONS.get(session_id) + + if not session_payload: + raise ValueError("导入会话不存在或已过期,请重新上传Excel文件") + + session_sheets: Dict[str, Dict[str, Any]] = session_payload.get("sheets") or {} + if not session_sheets: + raise ValueError("导入会话不存在或已过期,请重新上传Excel文件") + + region_ids = sorted({sheet.get("region_id") for sheet in session_sheets.values() if sheet.get("region_id")}) + region_theme_map: Dict[str, List[Dict[str, str]]] = {} + all_theme_options: List[Dict[str, str]] = [] + with _lic_pg_conn() as conn: + all_theme_options = _fetch_all_theme_options(conn) + if region_ids: + region_theme_map = _fetch_region_theme_map(conn, region_ids) + + preview_sheets: List[Dict[str, Any]] = [] + total_permits = 0 + total_risks = 0 + + for sheet_name in sorted(session_sheets.keys()): + sheet_data = session_sheets[sheet_name] + region_id = sheet_data.get("region_id") or "" + region_name = sheet_data.get("region_name") or sheet_name + duplicate_permits = set(sheet_data.get("duplicate_permits") or []) + new_permits = set(sheet_data.get("new_permits") or []) + permit_groups: Dict[str, List[Dict[str, Any]]] = sheet_data.get("permit_groups") or {} + + permit_summaries: List[Dict[str, Any]] = [] + workbook_theme_names: Set[str] = set() + sheet_risk_total = 0 + + for permit_name in sorted(permit_groups.keys()): + permit_rows = permit_groups[permit_name] + sheet_risk_total += len(permit_rows) + total_risks += len(permit_rows) + + default_theme_names: List[str] = sorted( + { + (_clean_text(theme_name) or "").strip() + for row in permit_rows + for theme_name in (row.get("theme_names") or []) + if (_clean_text(theme_name) or "").strip() + } + ) + if not default_theme_names: + default_theme_names = ["不涉及"] + + for theme_name in default_theme_names: + workbook_theme_names.add(theme_name) + + permit_summaries.append( + { + "permit_name": permit_name, + "canonical_name": _clean_text(permit_name), + "risk_count": len(permit_rows), + "is_duplicate": permit_name in duplicate_permits, + "is_new": permit_name in new_permits, + "default_theme_names": default_theme_names, + "sample_risk": permit_rows[0].get("risk_content") if permit_rows else "", + } + ) + + total_permits += len(permit_summaries) + + theme_options: List[Dict[str, Any]] = [] + seen_theme_labels: Set[str] = set() + + theme_options.append( + { + "id": ALL_THEMES_SENTINEL, + "name": ALL_THEMES_DISPLAY_NAME, + "source": "virtual", + "description": "选择后将与当前及未来的新主题自动绑定", + "is_all": True, + } + ) + seen_theme_labels.add(ALL_THEMES_SENTINEL.lower()) + seen_theme_labels.add(ALL_THEMES_DISPLAY_NAME.lower()) + + candidate_theme_lists: List[List[Dict[str, str]]] = [] + region_theme_options = region_theme_map.get(region_id) + if region_theme_options: + candidate_theme_lists.append(region_theme_options) + if all_theme_options: + candidate_theme_lists.append(all_theme_options) + + for option_list in candidate_theme_lists: + for option in option_list: + label = option.get("name") or "" + normalized = label.lower() + if normalized in seen_theme_labels: + continue + seen_theme_labels.add(normalized) + theme_options.append( + { + "id": option.get("id") or "", + "name": label, + "source": "existing", + } + ) + + for workbook_theme in sorted(workbook_theme_names): + normalized = workbook_theme.lower() + if normalized in seen_theme_labels: + continue + seen_theme_labels.add(normalized) + theme_options.append( + { + "id": "", + "name": workbook_theme, + "source": "workbook", + } + ) + + preview_sheets.append( + { + "sheet_name": sheet_name, + "region_name": region_name, + "region_id": region_id, + "missing_region": not bool(region_id), + "theme_options": theme_options, + "permits": permit_summaries, + "duplicate_permits": list(duplicate_permits), + "new_permits": list(new_permits), + "permit_count": len(permit_summaries), + "risk_count": sheet_risk_total, + } + ) + + return { + "session_id": session_id, + "filename": session_payload.get("filename") or "", + "sheet_count": len(preview_sheets), + "permit_total": total_permits, + "risk_total": total_risks, + "sheets": preview_sheets, + } + + def _permit_sources_available(conn: pg.Connection) -> bool: """Return True if permit_sources table exists (cached).""" global _PERMIT_SOURCES_TABLE_PRESENT @@ -1484,6 +1738,45 @@ def _ensure_permit_file_schema(conn: Optional[pg.Connection] = None) -> None: _PERMIT_FILE_SCHEMA_READY = True +def _create_permit_theme_override_schema(conn: pg.Connection) -> None: + """Create region-level theme override table for all-theme bindings.""" + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS region_permit_theme_overrides ( + region_id uuid NOT NULL REFERENCES regions(id) ON DELETE CASCADE, + permit_id uuid NOT NULL REFERENCES permits(id) ON DELETE CASCADE, + binds_all_themes boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + created_by text, + PRIMARY KEY (region_id, permit_id) + ) + """ + ) + + +def _ensure_permit_theme_override_schema(conn: Optional[pg.Connection] = None) -> None: + """Ensure the override table exists; it is required for 'all theme' bindings.""" + global _PERMIT_THEME_OVERRIDE_SCHEMA_READY + if _PERMIT_THEME_OVERRIDE_SCHEMA_READY: + return + + with _PERMIT_THEME_OVERRIDE_SCHEMA_LOCK: + if _PERMIT_THEME_OVERRIDE_SCHEMA_READY: + return + if conn is not None: + original_autocommit = conn.autocommit + try: + conn.autocommit = True + _create_permit_theme_override_schema(conn) + finally: + conn.autocommit = original_autocommit + else: + with _lic_pg_conn(autocommit=True) as ensure_conn: + _create_permit_theme_override_schema(ensure_conn) + _PERMIT_THEME_OVERRIDE_SCHEMA_READY = True + + def _insert_permit_file_record( conn: pg.Connection, *, @@ -1559,6 +1852,150 @@ def _link_file_to_permit( ) +def _set_permit_bind_all_themes( + conn: pg.Connection, + region_id: str, + permit_id: str, + *, + created_by: Optional[str] = None, +) -> None: + """Mark a permit so it always links to every theme under the region.""" + if not (region_id and permit_id): + return + _ensure_permit_theme_override_schema(conn) + cur = conn.cursor() + cur.execute( + """ + INSERT INTO region_permit_theme_overrides (region_id, permit_id, binds_all_themes, created_by) + VALUES (%s, %s, TRUE, %s) + ON CONFLICT (region_id, permit_id) + DO UPDATE SET + binds_all_themes = TRUE, + created_by = COALESCE(EXCLUDED.created_by, region_permit_theme_overrides.created_by), + created_at = CASE + WHEN region_permit_theme_overrides.binds_all_themes IS DISTINCT FROM EXCLUDED.binds_all_themes + THEN now() + ELSE region_permit_theme_overrides.created_at + END + """, + (region_id, permit_id, created_by), + ) + global _PERMIT_THEME_OVERRIDE_SCHEMA_READY + _PERMIT_THEME_OVERRIDE_SCHEMA_READY = True + + +def _clear_permit_bind_all_themes(conn: pg.Connection, region_id: str, permit_id: str) -> None: + """Remove the 'all themes' override for a permit.""" + if not (region_id and permit_id): + return + if not _PERMIT_THEME_OVERRIDE_SCHEMA_READY: + try: + _ensure_permit_theme_override_schema() + except Exception: + return + cur = conn.cursor() + cur.execute( + """ + DELETE FROM region_permit_theme_overrides + WHERE region_id = %s AND permit_id = %s + """, + (region_id, permit_id), + ) + + +def _fetch_permit_all_theme_flags( + conn: pg.Connection, region_id: str, permit_ids: Iterable[str] +) -> Dict[str, bool]: + """Return permit_id -> True when the permit is flagged for all themes.""" + global _PERMIT_THEME_OVERRIDE_SCHEMA_READY + permit_list = [str(pid) for pid in permit_ids if pid] + if not permit_list: + return {} + if _PERMIT_THEME_OVERRIDE_SCHEMA_READY is False: + return {} + cur = conn.cursor() + try: + cur.execute( + """ + SELECT permit_id + FROM region_permit_theme_overrides + WHERE region_id = %s + AND permit_id = ANY(%s) + AND binds_all_themes + """, + (region_id, permit_list), + ) + except pg.DatabaseError as exc: # type: ignore[attr-defined] + sqlstate = getattr(exc, "sqlstate", "") + if sqlstate == "42P01": + try: + conn.rollback() + except Exception: + pass + _PERMIT_THEME_OVERRIDE_SCHEMA_READY = False + return {} + raise + + rows = cur.fetchall() + _PERMIT_THEME_OVERRIDE_SCHEMA_READY = True + return {str(permit_id): True for (permit_id,) in rows} + + +def _permit_binds_all_themes(conn: pg.Connection, region_id: str, permit_id: str) -> bool: + """Check override flag for a single region-permit pair.""" + global _PERMIT_THEME_OVERRIDE_SCHEMA_READY + if not (region_id and permit_id): + return False + if _PERMIT_THEME_OVERRIDE_SCHEMA_READY is False: + return False + cur = conn.cursor() + try: + cur.execute( + """ + SELECT binds_all_themes + FROM region_permit_theme_overrides + WHERE region_id = %s AND permit_id = %s + LIMIT 1 + """, + (region_id, permit_id), + ) + except pg.DatabaseError as exc: # type: ignore[attr-defined] + sqlstate = getattr(exc, "sqlstate", "") + if sqlstate == "42P01": + try: + conn.rollback() + except Exception: + pass + _PERMIT_THEME_OVERRIDE_SCHEMA_READY = False + return False + raise + row = cur.fetchone() + if row is None: + return False + _PERMIT_THEME_OVERRIDE_SCHEMA_READY = True + return bool(row[0]) + + +def _propagate_all_theme_bindings(conn: pg.Connection, region_id: str, theme_id: str) -> None: + """Ensure all-theme permits also link to a newly attached theme.""" + if not (region_id and theme_id): + return + if _PERMIT_THEME_OVERRIDE_SCHEMA_READY is False: + return + cur = conn.cursor() + cur.execute( + """ + INSERT INTO region_theme_permits (region_id, theme_id, permit_id) + SELECT %s, %s, rpto.permit_id + FROM region_permit_theme_overrides rpto + WHERE rpto.region_id = %s + AND rpto.binds_all_themes + ON CONFLICT DO NOTHING + """, + (region_id, theme_id, region_id), + ) + + def _load_permit_file_metadata( conn: pg.Connection, region_id: str, @@ -1715,14 +2152,14 @@ def list_permits_for_region(region: str) -> List[Dict[str, str]]: def list_region_permit_catalog(region_id: str) -> List[Dict[str, Any]]: - """Return permit entries for a region, including owning theme and risk count.""" + """Return permit entries for a region (deduplicated per permit) with risk totals.""" sql = """ SELECT rtp.permit_id, p.name AS permit_name, rtp.theme_id, COALESCE(t.name, '') AS theme_name, - COUNT(rpr.risk_id) AS risk_count + COUNT(rpr.risk_id) OVER (PARTITION BY rtp.permit_id) AS risk_total FROM region_theme_permits rtp JOIN permits p ON p.id = rtp.permit_id LEFT JOIN themes t ON t.id = rtp.theme_id @@ -1730,26 +2167,91 @@ def list_region_permit_catalog(region_id: str) -> List[Dict[str, Any]]: ON rpr.region_id = rtp.region_id AND rpr.permit_id = rtp.permit_id WHERE rtp.region_id = %s - GROUP BY rtp.permit_id, p.name, rtp.theme_id, t.name ORDER BY LOWER(p.name), LOWER(COALESCE(t.name, '')) """ - catalog: List[Dict[str, Any]] = [] + catalog_map: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() with _lic_pg_conn() as conn: cur = conn.cursor() cur.execute(sql, (region_id,)) - for permit_id, permit_name, theme_id, theme_name, risk_count in cur.fetchall(): - catalog.append( + for permit_id, permit_name, theme_id, theme_name, risk_total in cur.fetchall(): + pid = str(permit_id) + entry = catalog_map.setdefault( + pid, { - "id": str(permit_id), + "id": pid, "name": str(permit_name), - "theme": { - "id": str(theme_id) if theme_id else "", - "name": str(theme_name) if theme_name else "", - }, - "risk_count": int(risk_count or 0), - } + "risk_count": int(risk_total or 0), + "theme": {"id": "", "name": ""}, + "themes": [], + }, ) - return catalog + if entry["theme"].get("id") or entry["theme"].get("name"): + pass + else: + entry["theme"] = { + "id": str(theme_id) if theme_id else "", + "name": str(theme_name) if theme_name else "", + } + if theme_id or theme_name: + theme_payload = { + "id": str(theme_id) if theme_id else "", + "name": str(theme_name) if theme_name else "", + } + if not any( + candidate.get("id") == theme_payload["id"] + and candidate.get("name") == theme_payload["name"] + for candidate in entry["themes"] + ): + entry["themes"].append(theme_payload) + return list(catalog_map.values()) + + +def _fetch_all_theme_options(conn: pg.Connection) -> List[Dict[str, str]]: + """Return the list of all theme records for cross-region binding.""" + cur = conn.cursor() + cur.execute( + """ + SELECT id, name + FROM themes + ORDER BY name + """ + ) + return [{"id": str(theme_id), "name": str(theme_name)} for theme_id, theme_name in cur.fetchall()] + + +def _fetch_region_theme_map( + conn: pg.Connection, region_ids: Iterable[str] +) -> Dict[str, List[Dict[str, str]]]: + """Return mapping of region_id -> theme metadata list.""" + uuid_list: List[uuid.UUID] = [] + for region_id in region_ids: + if not region_id: + continue + try: + uuid_list.append(uuid.UUID(str(region_id))) + except (TypeError, ValueError): + continue + if not uuid_list: + return {} + + cur = conn.cursor() + cur.execute( + """ + SELECT rt.region_id, t.id, t.name + FROM region_themes rt + JOIN themes t ON t.id = rt.theme_id + WHERE rt.region_id = ANY(%s) + ORDER BY t.name + """, + (uuid_list,), + ) + region_theme_map: Dict[str, List[Dict[str, str]]] = {} + for region_uuid, theme_id, theme_name in cur.fetchall(): + rid = str(region_uuid) + region_theme_map.setdefault(rid, []).append( + {"id": str(theme_id), "name": str(theme_name)} + ) + return region_theme_map def resolve_region_permit_theme(region_id: str, permit_id: str) -> Optional[Dict[str, str]]: @@ -1878,9 +2380,10 @@ def load_permits_and_risks( WHERE rtp.region_id = %s """ params: List[Any] = [region_id] - if theme_id: + theme_filter = theme_id if (theme_id and not _is_all_theme_marker(theme_id)) else None + if theme_filter: sql += " AND rtp.theme_id = %s" - params.append(theme_id) + params.append(theme_filter) if permit_id is not None: sql += " AND rtp.permit_id = %s" params.append(permit_id) @@ -1927,6 +2430,7 @@ def load_permits_and_risks( "name": theme_name_value, }, "themes": [], + "binds_all_themes": False, }, ) if theme_id_value and not entry["theme"].get("id"): @@ -1988,6 +2492,7 @@ def load_permits_and_risks( file_meta_map = {} else: raise + all_theme_flags = _fetch_permit_all_theme_flags(conn, region_id, permit_ids) for pid in permit_ids: permits[pid]["business_scopes"] = scope_map.get(pid, []) if pid in source_map: @@ -2012,6 +2517,27 @@ def load_permits_and_risks( } if "themes" not in permits[pid] or permits[pid]["themes"] is None: permits[pid]["themes"] = [] + if all_theme_flags.get(pid): + permits[pid]["binds_all_themes"] = True + theme_list = permits[pid].get("themes") or [] + has_marker = any(_is_all_theme_marker(theme.get("id")) or _is_all_theme_marker(theme.get("name")) for theme in theme_list) + if not has_marker: + theme_list.append( + { + "id": ALL_THEMES_SENTINEL, + "name": ALL_THEMES_DISPLAY_NAME, + "is_virtual": True, + } + ) + permits[pid]["themes"] = theme_list + theme_meta = permits[pid].get("theme") or {} + if not (theme_meta.get("id") or theme_meta.get("name")): + permits[pid]["theme"] = { + "id": ALL_THEMES_SENTINEL, + "name": ALL_THEMES_DISPLAY_NAME, + } + else: + permits[pid]["binds_all_themes"] = False return list(permits.values()) @@ -2060,6 +2586,191 @@ def fetch_permit_file(region_id: str, permit_id: str) -> Optional[Dict[str, Any] } +def list_stored_permit_files( + limit: int = 20, + offset: int = 0, + keyword: Optional[str] = None, +) -> Dict[str, Any]: + """Return paginated permit file metadata with optional keyword filtering.""" + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 20 + limit = max(1, min(limit, 100)) + try: + offset = int(offset) + except (TypeError, ValueError): + offset = 0 + offset = max(0, offset) + normalized_keyword = _clean_text(keyword) or None + + with _lic_pg_conn() as conn: + _ensure_permit_file_schema(conn) + cur = conn.cursor() + + where_clauses: List[str] = [] + params: List[Any] = [] + if normalized_keyword: + like_pattern = f"%{normalized_keyword}%" + where_clauses.append( + """ + ( + pf.filename ILIKE %s + OR EXISTS ( + SELECT 1 + FROM permit_file_links sub + JOIN permits sp ON sp.id = sub.permit_id + WHERE sub.file_id = pf.id + AND sp.name ILIKE %s + ) + ) + """ + ) + params.extend([like_pattern, like_pattern]) + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + count_sql = f"SELECT COUNT(*) FROM permit_files pf {where_sql}" + cur.execute(count_sql, tuple(params)) + total_rows = int(cur.fetchone()[0] or 0) + + data_sql = f""" + SELECT + pf.id, + pf.filename, + pf.content_type, + pf.file_size, + pf.created_at, + pf.uploaded_by, + COALESCE( + json_agg( + jsonb_build_object( + 'region_id', pfl.region_id::text, + 'region_name', COALESCE(r.name, ''), + 'permit_id', pfl.permit_id::text, + 'permit_name', COALESCE(p.name, '') + ) + ) FILTER (WHERE pfl.permit_id IS NOT NULL), + '[]'::json + ) AS link_payload + FROM permit_files pf + LEFT JOIN permit_file_links pfl ON pfl.file_id = pf.id + LEFT JOIN regions r ON r.id = pfl.region_id + LEFT JOIN permits p ON p.id = pfl.permit_id + {where_sql} + GROUP BY pf.id, pf.filename, pf.content_type, pf.file_size, pf.created_at, pf.uploaded_by + ORDER BY pf.created_at DESC, pf.id DESC + LIMIT %s OFFSET %s + """ + data_params = list(params) + [limit, offset] + cur.execute(data_sql, data_params) + rows = cur.fetchall() + + files: List[Dict[str, Any]] = [] + for row in rows: + ( + file_id, + filename, + content_type, + file_size, + created_at, + uploaded_by, + link_payload, + ) = row + link_items: List[Dict[str, str]] = [] + if link_payload: + if isinstance(link_payload, str): + try: + parsed = json.loads(link_payload) + except (TypeError, ValueError): + parsed = [] + else: + parsed = link_payload if isinstance(link_payload, list) else [] + if isinstance(parsed, list): + for entry in parsed: + if not isinstance(entry, dict): + continue + link_items.append( + { + "region_id": str(entry.get("region_id") or ""), + "region_name": str(entry.get("region_name") or ""), + "permit_id": str(entry.get("permit_id") or ""), + "permit_name": str(entry.get("permit_name") or ""), + } + ) + files.append( + { + "file_id": str(file_id), + "filename": filename or "", + "content_type": content_type or "", + "file_size": int(file_size or 0), + "created_at": created_at.isoformat() if created_at else None, + "uploaded_by": uploaded_by or "", + "links": link_items, + } + ) + + return { + "files": files, + "pagination": { + "limit": limit, + "offset": offset, + "total": total_rows, + }, + } + + +def delete_stored_permit_file(file_id: str) -> bool: + """Delete a stored permit file (and cascading links).""" + normalized = _clean_text(file_id) + if not normalized: + raise ValueError("file_id 不能为空") + + with _lic_pg_conn(autocommit=True) as conn: + _ensure_permit_file_schema(conn) + cur = conn.cursor() + cur.execute("DELETE FROM permit_files WHERE id = %s", (normalized,)) + return cur.rowcount > 0 + + +def start_import_session_from_file(file_id: str, *, requested_by: Optional[str] = None) -> Dict[str, Any]: + """Create a fresh import session using an archived permit file.""" + normalized = _clean_text(file_id) + if not normalized: + raise ValueError("file_id 不能为空") + + with _lic_pg_conn() as conn: + _ensure_permit_file_schema(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT filename, content_type, file_data, uploaded_by + FROM permit_files + WHERE id = %s + """, + (normalized,), + ) + row = cur.fetchone() + + if not row: + raise ValueError("文件不存在或已删除") + + filename, content_type, file_data, uploaded_by = row + if isinstance(file_data, memoryview): + file_bytes = file_data.tobytes() + elif isinstance(file_data, bytearray): + file_bytes = bytes(file_data) + else: + file_bytes = bytes(file_data or b"") + effective_uploader = requested_by or uploaded_by + + return start_permit_import_session( + file_bytes=file_bytes, + filename=filename or "许可导入.xlsx", + content_type=content_type or "application/octet-stream", + uploaded_by=effective_uploader, + ) + + def _select_permit_file_blob(conn: pg.Connection, region_id: str, permit_id: str): """Fetch a single permit file with binary content, recreating tables if needed.""" sql = """ @@ -2841,6 +3552,10 @@ def _create_snapshot_with_connection( """Create a snapshot using an existing DB connection (no commit).""" view_record = _fetch_permit_risk_row(conn, region_id, permit_id, risk_id) payload = _normalize_snapshot_payload(view_record) + try: + payload["binds_all_themes"] = _permit_binds_all_themes(conn, region_id, permit_id) + except Exception: + payload["binds_all_themes"] = False snapshot_batch_id = batch_id or str(uuid.uuid4()) metadata = _insert_permit_risk_snapshot( conn, @@ -3467,6 +4182,7 @@ def delete_region_permit( (region_id, theme_id, permit_id), ) delete_counts["region_theme_permits"] = int(cur.rowcount or 0) + _clear_permit_bind_all_themes(conn, region_id, permit_id) cur.execute( """ @@ -3612,6 +4328,7 @@ def restore_permit_risk_snapshot_batch( theme_ids: Set[str] = set(str(t) for t in (payload0.get("theme_ids") or []) if t) scope_ids: Set[str] = set() subitem_ids: Set[str] = set() + binds_all_override = bool(payload0.get("binds_all_themes")) for snap in snapshots: payload = snap["payload"] @@ -3671,6 +4388,11 @@ def restore_permit_risk_snapshot_batch( ) insert_counts["region_theme_permits"] += cur.rowcount or 0 + if binds_all_override: + _set_permit_bind_all_themes(conn, region_id, permit_id, created_by=snapshots[0].get("edited_by")) + else: + _clear_permit_bind_all_themes(conn, region_id, permit_id) + cur.execute( """ INSERT INTO region_permit_details ( diff --git a/static/db_admin.html b/static/db_admin.html index 667a55b..f150489 100644 --- a/static/db_admin.html +++ b/static/db_admin.html @@ -1438,6 +1438,12 @@ align-items: center; gap: 6px; } + .theme-chip.all-theme { + border-color: #fcd34d; + background: #fffbeb; + color: #92400e; + font-weight: 600; + } .theme-chip.selected { background: linear-gradient(135deg, #818cf8, #6366f1); @@ -1618,6 +1624,133 @@ flex: 1; } + .file-manager-modal-content { + max-width: 960px; + width: 92%; + } + + .file-manager-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-bottom: 16px; + } + + .file-manager-search { + flex: 1; + min-width: 220px; + } + + .file-manager-search input { + width: 100%; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + } + + .file-manager-search input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + } + + .file-manager-table { + width: 100%; + border-collapse: collapse; + } + + .file-manager-table th, + .file-manager-table td { + padding: 12px 10px; + border-bottom: 1px solid #f1f5f9; + text-align: left; + vertical-align: top; + } + + .file-manager-table th { + font-size: 13px; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .file-manager-file-name { + font-weight: 600; + color: #111827; + font-size: 15px; + } + + .file-manager-file-meta { + font-size: 12px; + color: #6b7280; + margin-top: 4px; + } + + .file-manager-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .file-manager-tag { + background: #eef2ff; + color: #4338ca; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + } + + .file-manager-tag-muted { + background: #f3f4f6; + color: #6b7280; + } + + .file-manager-empty { + text-align: center; + padding: 40px 0; + color: #6b7280; + } + + .file-manager-pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 18px; + font-size: 13px; + color: #4b5563; + } + + .file-manager-pagination button { + border: none; + background: #f3f4f6; + color: #374151; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s ease; + } + + .file-manager-pagination button:hover:not(:disabled) { + background: #e5e7eb; + } + + .file-manager-pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .file-manager-actions { + display: flex; + flex-direction: column; + gap: 8px; + } + + .file-manager-toolbar button { + white-space: nowrap; + } + .details-area { background: white; border-radius: 8px; @@ -1791,14 +1924,20 @@ } .loading { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 8px; + color: #475569; + font-size: 14px; + } + + .loading-icon { width: 20px; height: 20px; border: 3px solid #f3f3f3; border-top: 3px solid #667eea; border-radius: 50%; animation: spin 1s linear infinite; - margin-right: 10px; } @keyframes spin { @@ -1897,6 +2036,9 @@ + @@ -1974,6 +2116,19 @@ + + +