feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import hashlib
import mimetypes
import re
import shutil
@@ -85,6 +86,26 @@ class ReceiptFolderService:
if not self._should_persist_source(filename, content):
enriched.append(document)
continue
duplicate_receipt = self.find_duplicate_receipt(
filename=filename,
content=content,
current_user=current_user,
)
if duplicate_receipt is not None:
warning = "已上传过同样的单据,请不要重复上传。"
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
enriched.append(
document.model_copy(
update={
"receipt_id": duplicate_receipt.id,
"receipt_status": duplicate_receipt.status,
"receipt_preview_url": duplicate_receipt.preview_url,
"receipt_source_url": duplicate_receipt.source_url,
"warnings": list(dict.fromkeys([*existing_warnings, warning])),
}
)
)
continue
receipt = self.save_receipt(
filename=filename,
content=content,
@@ -140,6 +161,7 @@ class ReceiptFolderService:
"source_file_name": normalized_name,
"media_type": resolved_media_type,
"size_bytes": len(content),
"file_sha256": self._content_hash(content),
"uploaded_at": now.isoformat(),
"status": "linked" if linked else "unlinked",
"linked_claim_id": str(linked_claim_id or "").strip(),
@@ -243,8 +265,24 @@ class ReceiptFolderService:
],
fields=self._resolve_fields(meta),
raw_meta=meta,
edit_logs=self._resolve_edit_logs(meta),
)
def find_duplicate_receipt(
self,
*,
filename: str,
content: bytes,
current_user: CurrentUserContext,
) -> ReceiptFolderItemRead | None:
if not self._should_persist_source(filename, content):
return None
file_hash = self._content_hash(content)
for meta in self._iter_owner_meta(self._owner_key(current_user)):
if file_hash and str(meta.get("file_sha256") or "").strip() == file_hash:
return self._build_item(meta)
return None
def update_receipt(
self,
*,
@@ -255,6 +293,7 @@ class ReceiptFolderService:
owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id)
meta = self._read_meta(receipt_dir)
before_meta = json.loads(json.dumps(meta, ensure_ascii=False))
updates = payload.model_dump(exclude_unset=True)
for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"):
if key in updates and updates[key] is not None:
@@ -270,6 +309,18 @@ class ReceiptFolderService:
for field in payload.fields or []
]
meta["editable_fields"] = editable
changes = self._build_edit_changes(before_meta, meta)
if changes:
logs = list(meta.get("edit_logs") or [])
logs.insert(
0,
{
"operated_at": datetime.now(UTC).isoformat(),
"operator": self._operator_label(current_user),
"changes": changes,
},
)
meta["edit_logs"] = logs[:50]
meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(receipt_dir, meta)
return self.get_receipt(receipt_id, current_user)
@@ -285,6 +336,23 @@ class ReceiptFolderService:
shutil.rmtree(receipt_dir)
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
def delete_receipts_for_claim(self, claim_id: str) -> int:
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return 0
deleted_count = 0
self.root.mkdir(parents=True, exist_ok=True)
for meta_path in list(self.root.glob("*/*/meta.json")):
try:
meta = self._read_meta(meta_path.parent)
except FileNotFoundError:
continue
if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id:
continue
shutil.rmtree(meta_path.parent, ignore_errors=True)
deleted_count += 1
return deleted_count
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
@@ -501,6 +569,14 @@ class ReceiptFolderService:
encoding="utf-8",
)
@staticmethod
def _content_hash(content: bytes) -> str:
return hashlib.sha256(content or b"").hexdigest() if content else ""
@staticmethod
def _operator_label(current_user: CurrentUserContext) -> str:
return str(current_user.name or current_user.username or "当前用户").strip() or "当前用户"
@staticmethod
def _matches_status(meta: dict[str, Any], status_filter: str) -> bool:
if status_filter in {"", "all"}:
@@ -557,6 +633,97 @@ class ReceiptFolderService:
]
return fields
def _resolve_edit_logs(self, meta: dict[str, Any]) -> list[dict[str, Any]]:
logs = []
for log in list(meta.get("edit_logs") or []):
if not isinstance(log, dict):
continue
changes = [
{
"key": str(change.get("key") or ""),
"label": str(change.get("label") or ""),
"before": str(change.get("before") or ""),
"after": str(change.get("after") or ""),
}
for change in list(log.get("changes") or [])
if isinstance(change, dict)
and str(change.get("label") or change.get("key") or "").strip()
]
if not changes:
continue
logs.append(
{
"operated_at": self._parse_datetime(log.get("operated_at")),
"operator": str(log.get("operator") or "当前用户").strip() or "当前用户",
"changes": changes,
}
)
return logs
def _build_edit_changes(self, before_meta: dict[str, Any], after_meta: dict[str, Any]) -> list[dict[str, str]]:
before_values = self._flatten_editable_receipt_values(before_meta)
after_values = self._flatten_editable_receipt_values(after_meta)
changes = []
for key in sorted(set(before_values) | set(after_values)):
before = before_values.get(key, {})
after = after_values.get(key, {})
before_value = str(before.get("value") or "").strip()
after_value = str(after.get("value") or "").strip()
if before_value == after_value:
continue
label = str(after.get("label") or before.get("label") or key).strip()
changes.append(
{
"key": key,
"label": label,
"before": before_value,
"after": after_value,
}
)
return changes
def _flatten_editable_receipt_values(self, meta: dict[str, Any]) -> dict[str, dict[str, str]]:
values = {
"document_type_label": {
"label": "票据类型",
"value": str(meta.get("document_type_label") or "").strip(),
},
"scene_label": {
"label": "费用场景",
"value": str(meta.get("scene_label") or "").strip(),
},
"summary": {
"label": "摘要",
"value": str(meta.get("summary") or "").strip(),
},
"amount": {
"label": "金额",
"value": self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
},
"document_date": {
"label": "票据日期",
"value": self._resolve_receipt_document_date(meta),
},
"merchant_name": {
"label": "商户",
"value": self._resolve_receipt_merchant_name(meta),
},
}
for index, field in enumerate(list(meta.get("document_fields") or [])):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip()
label = str(field.get("label") or "").strip()
value = str(field.get("value") or "").strip()
stable_key = key or f"field_{index}_{label}"
if not stable_key and not label:
continue
values[stable_key] = {
"label": label or stable_key,
"value": value,
}
return values
def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str:
editable = meta.get("editable_fields")
if isinstance(editable, dict):