feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -17,14 +17,50 @@ from app.schemas.ontology import OntologyParseResult
|
||||
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
|
||||
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
|
||||
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
|
||||
EXPENSE_QUERY_PREVIEW_LIMIT = 20
|
||||
EXPENSE_QUERY_PREVIEW_LIMIT = 5
|
||||
EXPENSE_STATUS_LABELS = {
|
||||
"archived": "归档",
|
||||
"draft": "草稿",
|
||||
"supplement": "待补充",
|
||||
"returned": "已退回",
|
||||
"submitted": "已提交",
|
||||
"review": "审核中",
|
||||
"approved": "已通过",
|
||||
"rejected": "已驳回",
|
||||
"paid": "已付款",
|
||||
"paid": "归档",
|
||||
}
|
||||
EXPENSE_QUERY_STATUS_KEYWORDS = (
|
||||
(("归档", "已归档", "入账", "已入账", "已付款"), ("archived",)),
|
||||
(("审批通过", "审核通过", "已通过", "已审核"), ("approved",)),
|
||||
(("审批中", "审核中", "进行中", "流程中"), ("submitted", "review")),
|
||||
(("已提交", "提交了"), ("submitted",)),
|
||||
(("草稿", "待报销", "待提交"), ("draft",)),
|
||||
(("待补充", "待完善", "退回", "已退回"), ("supplement", "returned")),
|
||||
(("驳回", "已驳回", "拒绝"), ("rejected",)),
|
||||
)
|
||||
EXPENSE_STATUS_ALIASES = {
|
||||
"归档": "archived",
|
||||
"已归档": "archived",
|
||||
"入账": "archived",
|
||||
"已入账": "archived",
|
||||
"已付款": "archived",
|
||||
"已通过": "approved",
|
||||
"审批通过": "approved",
|
||||
"审核通过": "approved",
|
||||
"已审核": "approved",
|
||||
"审批中": "review",
|
||||
"审核中": "review",
|
||||
"进行中": "review",
|
||||
"已提交": "submitted",
|
||||
"草稿": "draft",
|
||||
"待报销": "draft",
|
||||
"待提交": "draft",
|
||||
"待补充": "supplement",
|
||||
"待完善": "supplement",
|
||||
"已退回": "returned",
|
||||
"退回": "returned",
|
||||
"驳回": "rejected",
|
||||
"已驳回": "rejected",
|
||||
}
|
||||
EXPENSE_STATUS_GROUP_LABELS = {
|
||||
"draft": "草稿",
|
||||
@@ -33,6 +69,13 @@ EXPENSE_STATUS_GROUP_LABELS = {
|
||||
"other": "其他状态",
|
||||
}
|
||||
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other")
|
||||
EXPENSE_RISK_LEVEL_LABELS = {
|
||||
"high": "高风险",
|
||||
"medium": "中风险",
|
||||
"warning": "中风险",
|
||||
"low": "低风险",
|
||||
"info": "低风险",
|
||||
}
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"travel": "差旅费",
|
||||
"hotel": "住宿费",
|
||||
@@ -95,7 +138,7 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
total_count = int(self.db.scalar(count_stmt) or 0)
|
||||
total_amount = float(self.db.scalar(amount_stmt) or 0)
|
||||
|
||||
recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology)
|
||||
recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology, message)
|
||||
display_count = total_count
|
||||
display_amount = total_amount
|
||||
older_record_count = 0
|
||||
@@ -146,12 +189,14 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
"record_count": display_count,
|
||||
"total_amount": round(display_amount, 2),
|
||||
"scope_label": scope_label,
|
||||
"title": f"最近 {len(preview_claims)} 条{scope_label}" if preview_claims else f"{scope_label}筛选结果",
|
||||
"scoped_to_current_user": scoped_to_current_user,
|
||||
"recent_window_applied": recent_window_applied,
|
||||
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
|
||||
"window_start_date": window_start_date,
|
||||
"window_end_date": window_end_date,
|
||||
"preview_count": len(preview_claims),
|
||||
"preview_limit": EXPENSE_QUERY_PREVIEW_LIMIT,
|
||||
"older_record_count": older_record_count,
|
||||
"records": [
|
||||
self._build_expense_query_record(claim)
|
||||
@@ -199,6 +244,7 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
@staticmethod
|
||||
def _should_limit_expense_query_to_recent_window(
|
||||
ontology: OntologyParseResult,
|
||||
message: str = "",
|
||||
) -> bool:
|
||||
has_explicit_claim_no = any(
|
||||
item.type == "expense_claim"
|
||||
@@ -208,7 +254,12 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
has_explicit_time_range = bool(
|
||||
ontology.time_range.start_date or ontology.time_range.end_date
|
||||
)
|
||||
return not has_explicit_claim_no and not has_explicit_time_range
|
||||
compact_message = str(message or "").replace(" ", "")
|
||||
asks_recent_window = any(
|
||||
keyword in compact_message
|
||||
for keyword in ("近", "最近", "本周", "上周", "过去", "前几天", "这几天")
|
||||
)
|
||||
return asks_recent_window and not has_explicit_claim_no and not has_explicit_time_range
|
||||
|
||||
@staticmethod
|
||||
def _resolve_reference_now(context_json: dict[str, Any]) -> datetime:
|
||||
@@ -294,6 +345,12 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
) -> dict[str, Any]:
|
||||
status_group, status_group_label = self._resolve_expense_status_group(claim.status)
|
||||
document_datetime = self._resolve_expense_query_document_datetime(claim)
|
||||
approval_stage = str(claim.approval_stage or "").strip()
|
||||
status_label = (
|
||||
"已归档"
|
||||
if "归档" in approval_stage
|
||||
else EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中")
|
||||
)
|
||||
return {
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
@@ -302,16 +359,63 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"),
|
||||
"amount": round(float(claim.amount), 2),
|
||||
"status": claim.status,
|
||||
"status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"),
|
||||
"status_label": status_label,
|
||||
"status_group": status_group,
|
||||
"status_group_label": status_group_label,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"approval_stage": approval_stage,
|
||||
"document_date": document_datetime.date().isoformat() if document_datetime else "",
|
||||
"occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "",
|
||||
"reason": claim.reason,
|
||||
"location": claim.location,
|
||||
"risk_flags": self._normalize_expense_query_risk_flags(claim.risk_flags_json),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_expense_query_risk_flags(raw_flags: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(raw_flags, list):
|
||||
return []
|
||||
|
||||
normalized_flags: list[dict[str, str]] = []
|
||||
for index, raw_flag in enumerate(raw_flags, start=1):
|
||||
if isinstance(raw_flag, dict):
|
||||
raw_level = str(raw_flag.get("severity") or raw_flag.get("level") or "").strip().lower()
|
||||
level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium"
|
||||
summary = str(
|
||||
raw_flag.get("message")
|
||||
or raw_flag.get("summary")
|
||||
or raw_flag.get("title")
|
||||
or raw_flag.get("label")
|
||||
or ""
|
||||
).strip()
|
||||
detail = ";".join(
|
||||
str(point or "").strip()
|
||||
for point in list(raw_flag.get("points") or [])
|
||||
if str(point or "").strip()
|
||||
)
|
||||
title = str(raw_flag.get("label") or EXPENSE_RISK_LEVEL_LABELS[level]).strip()
|
||||
else:
|
||||
raw_text = str(raw_flag or "").strip()
|
||||
if not raw_text:
|
||||
continue
|
||||
level = "high" if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) else "medium"
|
||||
summary = raw_text
|
||||
detail = raw_text
|
||||
title = EXPENSE_RISK_LEVEL_LABELS[level]
|
||||
|
||||
if not summary:
|
||||
continue
|
||||
normalized_flags.append(
|
||||
{
|
||||
"key": f"risk-{index}",
|
||||
"level": level,
|
||||
"level_label": EXPENSE_RISK_LEVEL_LABELS.get(level, "中风险"),
|
||||
"title": title or EXPENSE_RISK_LEVEL_LABELS.get(level, "中风险"),
|
||||
"summary": summary,
|
||||
"detail": detail or summary,
|
||||
}
|
||||
)
|
||||
return normalized_flags
|
||||
|
||||
def _build_expense_query_scope(
|
||||
self,
|
||||
*,
|
||||
@@ -344,12 +448,13 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
)
|
||||
project_values = self._collect_expense_query_filter_values(ontology, "project")
|
||||
location_values = self._collect_expense_query_filter_values(ontology, "location")
|
||||
status_values = list(
|
||||
dict.fromkeys(
|
||||
status_values = self._resolve_expense_query_status_values(
|
||||
[
|
||||
str(item.value).strip()
|
||||
for item in ontology.constraints
|
||||
if item.field == "status" and item.operator == "=" and str(item.value).strip()
|
||||
)
|
||||
],
|
||||
message,
|
||||
)
|
||||
amount_constraints = [
|
||||
item
|
||||
@@ -363,8 +468,16 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
|
||||
if expense_types:
|
||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||
if status_values:
|
||||
conditions.append(ExpenseClaim.status.in_(status_values))
|
||||
direct_status_values = [status for status in status_values if status != "archived"]
|
||||
if "archived" in status_values:
|
||||
conditions.append(
|
||||
or_(
|
||||
ExpenseClaim.approval_stage.ilike("%归档%"),
|
||||
ExpenseClaim.status.in_(["approved", "paid"]),
|
||||
)
|
||||
)
|
||||
if direct_status_values:
|
||||
conditions.append(ExpenseClaim.status.in_(direct_status_values))
|
||||
if project_values:
|
||||
project_conditions = []
|
||||
for value in project_values:
|
||||
@@ -438,7 +551,49 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
else:
|
||||
scope_label = "全部报销单"
|
||||
|
||||
return conditions, scope_label, scoped_to_current_user
|
||||
return conditions, self._compose_expense_scope_label(scope_label, status_values), scoped_to_current_user
|
||||
|
||||
@staticmethod
|
||||
def _resolve_expense_query_status_values(
|
||||
raw_values: list[str],
|
||||
message: str,
|
||||
) -> list[str]:
|
||||
values: list[str] = []
|
||||
for raw_value in raw_values:
|
||||
normalized = str(raw_value or "").strip()
|
||||
if not normalized:
|
||||
continue
|
||||
values.append(EXPENSE_STATUS_ALIASES.get(normalized, normalized))
|
||||
|
||||
compact_message = str(message or "").replace(" ", "")
|
||||
for keywords, statuses in EXPENSE_QUERY_STATUS_KEYWORDS:
|
||||
if any(keyword in compact_message for keyword in keywords):
|
||||
values.extend(statuses)
|
||||
|
||||
return [
|
||||
status
|
||||
for status in dict.fromkeys(values)
|
||||
if status in EXPENSE_STATUS_LABELS
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _compose_expense_scope_label(scope_label: str, status_values: list[str]) -> str:
|
||||
normalized_scope = str(scope_label or "").strip() or "报销单"
|
||||
if not status_values:
|
||||
return normalized_scope
|
||||
|
||||
status_labels = [
|
||||
EXPENSE_STATUS_LABELS.get(status, status)
|
||||
for status in status_values
|
||||
if status in EXPENSE_STATUS_LABELS
|
||||
]
|
||||
if not status_labels:
|
||||
return normalized_scope
|
||||
|
||||
status_text = "或".join(dict.fromkeys(status_labels))
|
||||
if "报销单" in normalized_scope:
|
||||
return normalized_scope.replace("报销单", f"{status_text}报销单")
|
||||
return f"{normalized_scope}({status_text})"
|
||||
|
||||
@staticmethod
|
||||
def _collect_expense_query_filter_values(
|
||||
|
||||
Reference in New Issue
Block a user