feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user