feat: add permit file management UI and APIs

This commit is contained in:
Codex Agent 2025-11-14 10:32:23 +08:00
parent 66cc871e47
commit 0076d2db2f
3 changed files with 1468 additions and 106 deletions

View File

@ -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/<file_id>/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/<file_id>', 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():

View File

@ -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 (

File diff suppressed because it is too large Load Diff