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,
|
fetch_permit_file,
|
||||||
describe_permit_import_session,
|
describe_permit_import_session,
|
||||||
resolve_region_permit_theme,
|
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
|
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
|
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'])
|
@v2_bp.route('/admin/permit-file/download', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def admin_permit_file_download():
|
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_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
||||||
_PERMIT_IMPORT_LOCK = threading.Lock()
|
_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]] = {
|
_IMPORT_HEADER_ALIASES: Dict[str, Set[str]] = {
|
||||||
"theme_names": {
|
"theme_names": {
|
||||||
"主题",
|
"主题",
|
||||||
|
|
@ -204,6 +210,24 @@ def _clean_text(value: Any) -> str:
|
||||||
return str(value).strip()
|
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:
|
def _normalize_header_label(value: Any) -> str:
|
||||||
"""Normalise header labels by removing spaces and lowercasing."""
|
"""Normalise header labels by removing spaces and lowercasing."""
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -941,6 +965,14 @@ def _purge_region_permit_relations(conn: pg.Connection, region_id: str, permit_i
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
delete_counts: Dict[str, int] = {}
|
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 = [
|
statements = [
|
||||||
("region_permit_risks", "DELETE FROM region_permit_risks WHERE region_id = %s AND permit_id = %s"),
|
("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"),
|
("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_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"),
|
("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:
|
for key, sql in statements:
|
||||||
cur.execute(sql, (region_id, permit_id))
|
cur.execute(sql, (region_id, permit_id))
|
||||||
|
|
@ -973,6 +1012,7 @@ def commit_permit_import_session(
|
||||||
overrides: Optional[Dict[str, Iterable[str]]] = None,
|
overrides: Optional[Dict[str, Iterable[str]]] = None,
|
||||||
edited_by: Optional[str] = None,
|
edited_by: Optional[str] = None,
|
||||||
change_summary: Optional[str] = None,
|
change_summary: Optional[str] = None,
|
||||||
|
theme_bindings: Optional[Dict[str, Dict[str, Iterable[str]]]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
if not session_id:
|
if not session_id:
|
||||||
raise ValueError("导入会话无效")
|
raise ValueError("导入会话无效")
|
||||||
|
|
@ -1012,6 +1052,27 @@ def commit_permit_import_session(
|
||||||
_clean_text(name) for name in (permit_names or []) if _clean_text(name)
|
_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导入")
|
default_change_summary = change_summary or (f"Excel导入:{workbook_filename}" if workbook_filename else "Excel导入")
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
|
|
@ -1034,10 +1095,12 @@ def commit_permit_import_session(
|
||||||
|
|
||||||
stored_file_meta: Optional[Dict[str, Any]] = None
|
stored_file_meta: Optional[Dict[str, Any]] = None
|
||||||
stored_file_id: Optional[str] = None
|
stored_file_id: Optional[str] = None
|
||||||
|
region_theme_cache: Dict[str, List[Dict[str, str]]] = {}
|
||||||
|
|
||||||
with _lic_pg_conn(autocommit=False) as conn:
|
with _lic_pg_conn(autocommit=False) as conn:
|
||||||
try:
|
try:
|
||||||
_ensure_permit_sources_table(conn)
|
_ensure_permit_sources_table(conn)
|
||||||
|
_ensure_permit_theme_override_schema(conn)
|
||||||
if session_file_bytes:
|
if session_file_bytes:
|
||||||
_ensure_permit_file_schema(conn)
|
_ensure_permit_file_schema(conn)
|
||||||
stored_file_meta = _insert_permit_file_record(
|
stored_file_meta = _insert_permit_file_record(
|
||||||
|
|
@ -1135,6 +1198,18 @@ def commit_permit_import_session(
|
||||||
)
|
)
|
||||||
permit_modified = True
|
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()
|
theme_names: Set[str] = set()
|
||||||
scope_descriptions: Set[str] = set()
|
scope_descriptions: Set[str] = set()
|
||||||
subitem_names: Set[str] = set()
|
subitem_names: Set[str] = set()
|
||||||
|
|
@ -1144,7 +1219,8 @@ def commit_permit_import_session(
|
||||||
jurisdiction_scope_val: Optional[str] = None
|
jurisdiction_scope_val: Optional[str] = None
|
||||||
|
|
||||||
for row in permit_rows:
|
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 [])
|
scope_descriptions.update(row.get("scope_descriptions") or [])
|
||||||
subitem_names.update(row.get("subitem_names") or [])
|
subitem_names.update(row.get("subitem_names") or [])
|
||||||
if not permit_status_val and row.get("permit_status"):
|
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"):
|
if not jurisdiction_scope_val and row.get("jurisdiction_scope"):
|
||||||
jurisdiction_scope_val = 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:
|
if not theme_names:
|
||||||
theme_names.add("不涉及")
|
theme_names.add("不涉及")
|
||||||
|
|
||||||
theme_ids: List[str] = []
|
theme_ids: List[str] = []
|
||||||
for theme_name in sorted(theme_names):
|
for theme_name in sorted(theme_names):
|
||||||
|
if _is_all_theme_marker(theme_name):
|
||||||
|
continue
|
||||||
theme_id = _ensure_theme(conn, theme_name)
|
theme_id = _ensure_theme(conn, theme_name)
|
||||||
theme_ids.append(theme_id)
|
theme_ids.append(theme_id)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -1171,6 +1263,11 @@ def commit_permit_import_session(
|
||||||
""",
|
""",
|
||||||
(region_id, theme_id),
|
(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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO region_theme_permits (region_id, theme_id, permit_id)
|
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),
|
(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):
|
for scope_desc in sorted(scope_descriptions):
|
||||||
scope_id = _ensure_business_scope(conn, scope_desc)
|
scope_id = _ensure_business_scope(conn, scope_desc)
|
||||||
if not scope_id:
|
if not scope_id:
|
||||||
|
|
@ -1343,6 +1450,153 @@ def commit_permit_import_session(
|
||||||
return result
|
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:
|
def _permit_sources_available(conn: pg.Connection) -> bool:
|
||||||
"""Return True if permit_sources table exists (cached)."""
|
"""Return True if permit_sources table exists (cached)."""
|
||||||
global _PERMIT_SOURCES_TABLE_PRESENT
|
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
|
_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(
|
def _insert_permit_file_record(
|
||||||
conn: pg.Connection,
|
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(
|
def _load_permit_file_metadata(
|
||||||
conn: pg.Connection,
|
conn: pg.Connection,
|
||||||
region_id: str,
|
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]]:
|
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 = """
|
sql = """
|
||||||
SELECT
|
SELECT
|
||||||
rtp.permit_id,
|
rtp.permit_id,
|
||||||
p.name AS permit_name,
|
p.name AS permit_name,
|
||||||
rtp.theme_id,
|
rtp.theme_id,
|
||||||
COALESCE(t.name, '') AS theme_name,
|
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
|
FROM region_theme_permits rtp
|
||||||
JOIN permits p ON p.id = rtp.permit_id
|
JOIN permits p ON p.id = rtp.permit_id
|
||||||
LEFT JOIN themes t ON t.id = rtp.theme_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
|
ON rpr.region_id = rtp.region_id
|
||||||
AND rpr.permit_id = rtp.permit_id
|
AND rpr.permit_id = rtp.permit_id
|
||||||
WHERE rtp.region_id = %s
|
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, ''))
|
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:
|
with _lic_pg_conn() as conn:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(sql, (region_id,))
|
cur.execute(sql, (region_id,))
|
||||||
for permit_id, permit_name, theme_id, theme_name, risk_count in cur.fetchall():
|
for permit_id, permit_name, theme_id, theme_name, risk_total in cur.fetchall():
|
||||||
catalog.append(
|
pid = str(permit_id)
|
||||||
|
entry = catalog_map.setdefault(
|
||||||
|
pid,
|
||||||
{
|
{
|
||||||
"id": str(permit_id),
|
"id": pid,
|
||||||
"name": str(permit_name),
|
"name": str(permit_name),
|
||||||
"theme": {
|
"risk_count": int(risk_total or 0),
|
||||||
"id": str(theme_id) if theme_id else "",
|
"theme": {"id": "", "name": ""},
|
||||||
"name": str(theme_name) if theme_name else "",
|
"themes": [],
|
||||||
},
|
},
|
||||||
"risk_count": int(risk_count or 0),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
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]]:
|
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
|
WHERE rtp.region_id = %s
|
||||||
"""
|
"""
|
||||||
params: List[Any] = [region_id]
|
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"
|
sql += " AND rtp.theme_id = %s"
|
||||||
params.append(theme_id)
|
params.append(theme_filter)
|
||||||
if permit_id is not None:
|
if permit_id is not None:
|
||||||
sql += " AND rtp.permit_id = %s"
|
sql += " AND rtp.permit_id = %s"
|
||||||
params.append(permit_id)
|
params.append(permit_id)
|
||||||
|
|
@ -1927,6 +2430,7 @@ def load_permits_and_risks(
|
||||||
"name": theme_name_value,
|
"name": theme_name_value,
|
||||||
},
|
},
|
||||||
"themes": [],
|
"themes": [],
|
||||||
|
"binds_all_themes": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if theme_id_value and not entry["theme"].get("id"):
|
if theme_id_value and not entry["theme"].get("id"):
|
||||||
|
|
@ -1988,6 +2492,7 @@ def load_permits_and_risks(
|
||||||
file_meta_map = {}
|
file_meta_map = {}
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
all_theme_flags = _fetch_permit_all_theme_flags(conn, region_id, permit_ids)
|
||||||
for pid in permit_ids:
|
for pid in permit_ids:
|
||||||
permits[pid]["business_scopes"] = scope_map.get(pid, [])
|
permits[pid]["business_scopes"] = scope_map.get(pid, [])
|
||||||
if pid in source_map:
|
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:
|
if "themes" not in permits[pid] or permits[pid]["themes"] is None:
|
||||||
permits[pid]["themes"] = []
|
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())
|
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):
|
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."""
|
"""Fetch a single permit file with binary content, recreating tables if needed."""
|
||||||
sql = """
|
sql = """
|
||||||
|
|
@ -2841,6 +3552,10 @@ def _create_snapshot_with_connection(
|
||||||
"""Create a snapshot using an existing DB connection (no commit)."""
|
"""Create a snapshot using an existing DB connection (no commit)."""
|
||||||
view_record = _fetch_permit_risk_row(conn, region_id, permit_id, risk_id)
|
view_record = _fetch_permit_risk_row(conn, region_id, permit_id, risk_id)
|
||||||
payload = _normalize_snapshot_payload(view_record)
|
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())
|
snapshot_batch_id = batch_id or str(uuid.uuid4())
|
||||||
metadata = _insert_permit_risk_snapshot(
|
metadata = _insert_permit_risk_snapshot(
|
||||||
conn,
|
conn,
|
||||||
|
|
@ -3467,6 +4182,7 @@ def delete_region_permit(
|
||||||
(region_id, theme_id, permit_id),
|
(region_id, theme_id, permit_id),
|
||||||
)
|
)
|
||||||
delete_counts["region_theme_permits"] = int(cur.rowcount or 0)
|
delete_counts["region_theme_permits"] = int(cur.rowcount or 0)
|
||||||
|
_clear_permit_bind_all_themes(conn, region_id, permit_id)
|
||||||
|
|
||||||
cur.execute(
|
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)
|
theme_ids: Set[str] = set(str(t) for t in (payload0.get("theme_ids") or []) if t)
|
||||||
scope_ids: Set[str] = set()
|
scope_ids: Set[str] = set()
|
||||||
subitem_ids: Set[str] = set()
|
subitem_ids: Set[str] = set()
|
||||||
|
binds_all_override = bool(payload0.get("binds_all_themes"))
|
||||||
|
|
||||||
for snap in snapshots:
|
for snap in snapshots:
|
||||||
payload = snap["payload"]
|
payload = snap["payload"]
|
||||||
|
|
@ -3671,6 +4388,11 @@ def restore_permit_risk_snapshot_batch(
|
||||||
)
|
)
|
||||||
insert_counts["region_theme_permits"] += cur.rowcount or 0
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO region_permit_details (
|
INSERT INTO region_permit_details (
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue