feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -57,6 +57,7 @@ EXPENSE_TYPE_LABELS = {
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"}
MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
LOCATION_REQUIRED_EXPENSE_TYPES = {
@@ -542,14 +543,19 @@ class ExpenseClaimService:
[(normalized_name, content, media_type or "application/octet-stream")]
)
documents = list(ocr_result.documents or [])
if documents:
ocr_document = documents[0]
ocr_status = "recognized"
document_info = self._build_attachment_document_info(ocr_document)
requirement_check = self._build_attachment_requirement_check(
item=item,
document_info=document_info,
)
if documents:
ocr_document = documents[0]
ocr_status = "recognized"
document_info = self._build_attachment_document_info(ocr_document)
self._backfill_item_amount_from_attachment(
item=item,
document=ocr_document,
document_info=document_info,
)
requirement_check = self._build_attachment_requirement_check(
item=item,
document_info=document_info,
)
attachment_analysis = self._build_attachment_analysis(
document=ocr_document,
item=item,
@@ -615,13 +621,15 @@ class ExpenseClaimService:
after_json=self._serialize_claim(claim),
)
return {
"message": f"{normalized_name} 已上传并关联到当前费用明细。",
"claim_id": claim.id,
"item_id": item.id,
"invoice_id": item.invoice_id,
"attachment": self._build_attachment_payload(item),
}
return {
"message": f"{normalized_name} 已上传并关联到当前费用明细。",
"claim_id": claim.id,
"item_id": item.id,
"invoice_id": item.invoice_id,
"item_amount": item.item_amount,
"claim_amount": claim.amount,
"attachment": self._build_attachment_payload(item),
}
def get_claim_item_attachment_meta(
self,
@@ -739,16 +747,18 @@ class ExpenseClaimService:
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.submit",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.submit",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
if str(claim.status or "").strip().lower() == "submitted":
self._delete_submitted_claim_assistant_sessions(claim.id)
return claim
def save_or_submit_from_ontology(
self,
@@ -858,8 +868,10 @@ class ExpenseClaimService:
if claim is None:
return None
if not self._has_privileged_claim_access(current_user):
if not self._has_claim_delete_access(current_user):
self._ensure_draft_claim(claim)
if not self._is_claim_owned_by_current_user(claim, current_user):
raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
before_json = self._serialize_claim(claim)
resource_id = claim.id
@@ -903,7 +915,7 @@ class ExpenseClaimService:
raise ValueError("已完成单据不允许退回。")
before_json = self._serialize_claim(claim)
operator = current_user.name or current_user.username
operator = self._resolve_current_user_display_name(current_user)
previous_status = str(claim.status or "").strip()
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
previous_stage_key = self._normalize_return_stage_key(previous_stage)
@@ -987,29 +999,43 @@ class ExpenseClaimService:
if claim is None:
return None
if not self._can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted":
raise ValueError("只有审批中的报销单可以审批通过。")
previous_stage = str(claim.approval_stage or "").strip()
if previous_stage != "直属领导审批":
raise ValueError("当前节点不是直属领导审批,不能执行领导审批通过。")
if previous_stage == "直属领导审批":
if not self._can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
approval_source = "manual_approval"
event_type = "expense_claim_approval"
label = "领导审批通过"
next_status = "submitted"
next_stage = "财务审批"
default_message = "{operator} 已审批通过,流转至{next_stage}"
elif previous_stage == "财务审批":
if not self._can_approve_claim(current_user, claim):
raise ValueError("只有财务人员可以完成财务终审。")
approval_source = "finance_approval"
event_type = "expense_claim_finance_approval"
label = "财务审核通过"
next_status = "approved"
next_stage = "归档入账"
default_message = "{operator} 已完成财务审核,进入归档入账。"
else:
raise ValueError("当前节点不支持审批通过。")
before_json = self._serialize_claim(claim)
operator = current_user.name or current_user.username
leader_opinion = str(opinion or "").strip()
next_stage = "财务审批"
operator = self._resolve_current_user_display_name(current_user)
approval_opinion = str(opinion or "").strip()
approval_flag = {
"source": "manual_approval",
"event_type": "expense_claim_approval",
"source": approval_source,
"event_type": event_type,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "领导审批通过",
"message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}",
"opinion": leader_opinion,
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
@@ -1024,7 +1050,7 @@ class ExpenseClaimService:
"created_at": datetime.now(UTC).isoformat(),
}
claim.status = "submitted"
claim.status = next_status
claim.approval_stage = next_stage
if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC)
@@ -2205,16 +2231,89 @@ class ExpenseClaimService:
meta_path = self._attachment_meta_path(file_path)
meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]:
meta_path = self._attachment_meta_path(file_path)
if not meta_path.exists():
return {}
def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]:
meta_path = self._attachment_meta_path(file_path)
if not meta_path.exists():
return {}
try:
payload = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return payload if isinstance(payload, dict) else {}
return {}
return payload if isinstance(payload, dict) else {}
def _repair_pdf_text_layer_metadata_if_needed(
self,
*,
file_path: Path,
metadata: dict[str, Any],
item: ExpenseClaimItem | None = None,
) -> dict[str, Any]:
if not metadata:
return metadata
media_type = str(metadata.get("media_type") or self._resolve_attachment_media_type(file_path.name)).strip()
if media_type != "application/pdf":
return metadata
ocr_text = str(metadata.get("ocr_text") or "")
ocr_summary = str(metadata.get("ocr_summary") or "")
if OcrService._placeholder_ratio(f"{ocr_summary}\n{ocr_text}") < 0.12:
return metadata
text_layer = OcrService(self.db)._extract_pdf_text_layer(file_path)
repaired_text, used_text_layer = OcrService._choose_document_text(
ocr_text=ocr_text,
text_layer=text_layer,
)
if not used_text_layer or not repaired_text:
return metadata
repaired_summary = OcrService._summarize_text(repaired_text)
document = SimpleNamespace(
filename=str(metadata.get("file_name") or file_path.name),
text=repaired_text,
summary=repaired_summary,
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
line_count=int(metadata.get("ocr_line_count") or 0),
document_type="",
document_type_label="",
scene_code="",
scene_label="",
document_fields=[],
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
)
document_info = self._build_attachment_document_info(document)
document.document_type = document_info.get("document_type", "")
document.document_type_label = document_info.get("document_type_label", "")
document.scene_code = document_info.get("scene_code", "")
document.scene_label = document_info.get("scene_label", "")
document.document_fields = list(document_info.get("fields") or [])
metadata["ocr_text"] = repaired_text
metadata["ocr_summary"] = repaired_summary
metadata["document_info"] = document_info
metadata["previewable"] = True
metadata["preview_kind"] = "pdf"
metadata["preview_storage_key"] = str(metadata.get("storage_key") or self._to_attachment_storage_key(file_path))
metadata["preview_media_type"] = "application/pdf"
metadata["preview_file_name"] = str(metadata.get("file_name") or file_path.name)
if item is not None:
requirement_check = self._build_attachment_requirement_check(
item=item,
document_info=document_info,
)
metadata["requirement_check"] = requirement_check
metadata["analysis"] = self._build_attachment_analysis(
document=document,
item=item,
document_info=document_info,
requirement_check=requirement_check,
)
self._write_attachment_meta(file_path, metadata)
return metadata
def _build_attachment_preview_meta(
self,
@@ -2262,12 +2361,17 @@ class ExpenseClaimService:
"preview_file_name": "",
}
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._read_attachment_meta(file_path)
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._read_attachment_meta(file_path)
metadata = self._repair_pdf_text_layer_metadata_if_needed(
file_path=file_path,
metadata=metadata,
item=item,
)
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
if preview_storage_key:
preview_path = self._resolve_attachment_path(preview_storage_key)
@@ -2284,10 +2388,15 @@ class ExpenseClaimService:
raise FileNotFoundError("Attachment preview not found")
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._read_attachment_meta(file_path)
uploaded_at_value = metadata.get("uploaded_at")
def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]:
file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._read_attachment_meta(file_path)
metadata = self._repair_pdf_text_layer_metadata_if_needed(
file_path=file_path,
metadata=metadata,
item=item,
)
uploaded_at_value = metadata.get("uploaded_at")
uploaded_at = None
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
try:
@@ -2402,11 +2511,11 @@ class ExpenseClaimService:
return normalized_next
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
insight = build_document_insight(
filename=str(getattr(document, "filename", "") or ""),
summary=str(getattr(document, "summary", "") or ""),
text=str(getattr(document, "text", "") or ""),
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
insight = build_document_insight(
filename=str(getattr(document, "filename", "") or ""),
summary=str(getattr(document, "summary", "") or ""),
text=str(getattr(document, "text", "") or ""),
)
raw_fields = list(getattr(document, "document_fields", []) or [])
normalized_fields: list[dict[str, str]] = []
@@ -2463,14 +2572,35 @@ class ExpenseClaimService:
"document_type_label": document_type_label,
"scene_code": scene_code,
"scene_label": scene_label,
"fields": normalized_fields,
}
def _build_attachment_requirement_check(
self,
*,
item: ExpenseClaimItem,
document_info: dict[str, Any],
"fields": normalized_fields,
}
def _backfill_item_amount_from_attachment(
self,
*,
item: ExpenseClaimItem,
document: Any,
document_info: dict[str, Any],
) -> None:
current_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
if current_amount > Decimal("0.00"):
return
amount = self._resolve_document_item_amount(
{
"document_fields": document_info.get("fields") or [],
"summary": str(getattr(document, "summary", "") or ""),
"text": str(getattr(document, "text", "") or ""),
}
)
if amount is not None and amount > Decimal("0.00"):
item.item_amount = amount
def _build_attachment_requirement_check(
self,
*,
item: ExpenseClaimItem,
document_info: dict[str, Any],
) -> dict[str, Any]:
expense_type = str(item.item_type or "").strip().lower() or "other"
policy = self._get_expense_scene_policy(expense_type)
@@ -2932,8 +3062,17 @@ class ExpenseClaimService:
def _ensure_draft_claim(self, claim: ExpenseClaim) -> None:
if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None:
from app.services.agent_conversations import AgentConversationService
AgentConversationService(self.db).delete_conversations_for_draft_claim(
claim_id=claim_id,
source="user_message",
session_type="expense",
)
def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]:
base_flags = list(claim.risk_flags_json or [])
attachment_flags = [
flag
@@ -4593,7 +4732,7 @@ class ExpenseClaimService:
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES
return bool(policy.location_required)
@staticmethod
@staticmethod
def _has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
@@ -4604,6 +4743,17 @@ class ExpenseClaimService:
}
return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES)
@staticmethod
def _has_claim_delete_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
role_codes = {
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
}
return bool(role_codes & CLAIM_DELETE_ROLE_CODES)
def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if self._has_privileged_claim_access(current_user):
return True
@@ -4636,7 +4786,41 @@ class ExpenseClaimService:
return self._resolve_claim_manager_name(claim) == approver_name
def _can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
return self._can_return_claim(current_user, claim)
stage = str(claim.approval_stage or "").strip()
if stage == "直属领导审批":
return self._is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
role_codes = self._normalize_role_codes(current_user)
return current_user.is_admin or "finance" in role_codes
return False
def _is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self._normalize_role_codes(current_user)
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
return False
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != "直属领导审批":
return False
current_employee = self._resolve_current_employee(current_user)
if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id:
return False
claim_employee = claim.employee
if current_employee is not None and claim_employee is not None:
if claim_employee.manager_id == current_employee.id:
return True
if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id:
return True
approver_name = str(
current_employee.name if current_employee is not None and current_employee.name else current_user.name or ""
).strip()
if not approver_name:
return False
return self._resolve_claim_manager_name(claim) == approver_name
@staticmethod
def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
@@ -4654,6 +4838,44 @@ class ExpenseClaimService:
]
)
def _resolve_current_user_display_name(self, current_user: CurrentUserContext) -> str:
current_employee = self._resolve_current_employee(current_user)
if current_employee is not None and str(current_employee.name or "").strip():
return str(current_employee.name).strip()
for candidate in (current_user.name, current_user.username):
normalized = str(candidate or "").strip()
if normalized and not self._is_email_like(normalized):
return normalized
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
def _is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
current_employee = self._resolve_current_employee(current_user)
if current_employee is not None:
if str(claim.employee_id or "").strip() == current_employee.id:
return True
identity_values = {
str(current_employee.name or "").strip(),
str(current_employee.email or "").strip(),
str(current_employee.employee_no or "").strip(),
}
else:
identity_values = set()
identity_values.update(
{
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
}
)
identity_values.discard("")
return str(claim.employee_name or "").strip() in identity_values
@staticmethod
def _is_email_like(value: str) -> bool:
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", str(value or "").strip()))
def _resolve_claim_employee_for_backfill(self, claim: ExpenseClaim) -> Employee | None:
if claim.employee is not None:
employee = self.db.scalar(
@@ -4850,8 +5072,14 @@ class ExpenseClaimService:
return conditions
def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
if self._has_privileged_claim_access(current_user):
role_codes = self._normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
return stmt.where(ExpenseClaim.status == "submitted")
if "finance" in role_codes:
return stmt.where(
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "财务审批",
)
conditions = self._build_approval_claim_conditions(current_user)
if not conditions: