feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user