feat: add permit file management UI and APIs
This commit is contained in:
parent
66cc871e47
commit
0076d2db2f
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,6 +1219,7 @@ def commit_permit_import_session(
|
|||
jurisdiction_scope_val: Optional[str] = None
|
||||
|
||||
for row in permit_rows:
|
||||
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 [])
|
||||
|
|
@ -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": {
|
||||
"risk_count": int(risk_total or 0),
|
||||
"theme": {"id": "", "name": ""},
|
||||
"themes": [],
|
||||
},
|
||||
)
|
||||
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 "",
|
||||
},
|
||||
"risk_count": int(risk_count or 0),
|
||||
}
|
||||
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 catalog
|
||||
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
Loading…
Reference in New Issue