feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -12,6 +12,7 @@ from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse,
ExpenseClaimActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimApprovalPayload,
ExpenseClaimItemCreate,
ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate,
@@ -59,6 +60,16 @@ def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[Expens
return ExpenseClaimService(db).list_claims(current_user)
@router.get(
"/claims/approvals",
response_model=list[ExpenseClaimRead],
summary="查询当前用户审批待办报销单列表",
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
)
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_approval_claims(current_user)
@router.get(
"/claims/{claim_id}",
response_model=ExpenseClaimRead,
@@ -420,7 +431,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员高级管理人员可将可见报销单退回到待提交状态。",
description="财务人员高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -440,7 +451,40 @@ def return_expense_claim(
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.return_claim(claim_id, current_user, reason=payload.reason)
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="直属领导审批通过报销单",
description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许审批通过。",
},
},
)
def approve_expense_claim(
claim_id: str,
payload: ExpenseClaimApprovalPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error

View File

@@ -150,6 +150,11 @@ class ExpenseClaimActionResponse(BaseModel):
class ExpenseClaimReturnPayload(BaseModel):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
class ExpenseClaimApprovalPayload(BaseModel):
opinion: str | None = Field(default=None, max_length=500)
class ExpenseClaimAttachmentActionResponse(BaseModel):

View File

@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
scene_code="transport",
scene_label="交通票据",
expense_type="transport",
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
score_bias=0.38,
),
DocumentRule(

File diff suppressed because it is too large Load Diff

View File

@@ -167,11 +167,16 @@ EXPENSE_TYPE_KEYWORDS = {
"出差": "travel",
"住宿": "hotel",
"酒店": "hotel",
"交通": "transport",
"打车": "transport",
"网约车": "transport",
"出租车": "transport",
"停车费": "transport",
"交通": "transport",
"打车": "transport",
"网约车": "transport",
"出租车": "transport",
"乘车": "transport",
"乘车费": "transport",
"用车": "transport",
"叫车": "transport",
"车资": "transport",
"停车费": "transport",
"餐费": "meal",
"用餐": "meal",
"会务": "meeting",
@@ -202,9 +207,14 @@ EXPENSE_NARRATIVE_KEYWORDS = (
"花了",
"支出",
"垫付",
"打车",
"车费",
"餐费",
"打车",
"车费",
"乘车",
"乘车费",
"用车",
"叫车",
"车资",
"餐费",
"吃饭",
"用餐",
"宴请",
@@ -1190,8 +1200,11 @@ class SemanticOntologyService:
)
)
if any(keyword in query for keyword in ("打车", "网约车", "出租车", "车费", "停车费", "过路费")):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
if any(
keyword in query
for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费")
):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):
upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88))

View File

@@ -226,6 +226,16 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
"查看报销草稿",
"请解释一下当前这笔报销的合规风险和待补充项",
)
LEADING_REASON_TIME_PATTERNS = (
re.compile(
r"^\s*(?:识别事项(?:有)?[:]\s*)?"
r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[:]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]?\s*"
),
re.compile(
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]\s*"
),
)
AMOUNT_UNIT_ALIASES = {
"": "",
"": "",
@@ -2298,8 +2308,11 @@ class UserAgentService:
@staticmethod
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
raw_reasons = payload.tool_payload.get("submission_blocked_reasons")
if raw_reasons is None:
submission_blocked = bool(payload.tool_payload.get("submission_blocked"))
if raw_reasons is None and submission_blocked:
raw_reasons = payload.tool_payload.get("missing_fields")
if raw_reasons is None and not submission_blocked:
return []
reasons: list[str] = []
if isinstance(raw_reasons, list):
@@ -2311,11 +2324,18 @@ class UserAgentService:
if item.strip()
)
if not reasons:
if not reasons and submission_blocked:
message = str(payload.tool_payload.get("message") or "").strip()
prefix = "提交前请先补全信息:"
if message.startswith(prefix):
message = message[len(prefix):].strip()
for prefix in (
"提交前请先补全信息:",
"AI预审暂未通过原因如下",
"AI预审未通过原因如下",
"AI预审暂未通过",
"AI预审未通过",
):
if message.startswith(prefix):
message = message[len(prefix):].strip()
break
if message:
reasons.extend(
item.strip()
@@ -2769,7 +2789,7 @@ class UserAgentService:
@classmethod
def _resolve_reason_text(cls, message: str) -> str:
reason = cls._extract_message_reason(message)
reason = cls._strip_leading_time_from_reason(cls._extract_message_reason(message))
if not reason:
return ""
@@ -2799,6 +2819,15 @@ class UserAgentService:
return reason
@staticmethod
def _strip_leading_time_from_reason(value: str) -> str:
reason = str(value or "").strip()
for pattern in LEADING_REASON_TIME_PATTERNS:
next_reason = pattern.sub("", reason).strip()
if next_reason != reason:
return next_reason
return reason
@staticmethod
def _should_skip_model_answer(
payload: UserAgentRequest,
@@ -3490,7 +3519,7 @@ class UserAgentService:
return "travel", "差旅费"
if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")):
return "hotel", "住宿费"
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")):
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
return "transport", "交通费"
if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
return "meal", "餐费"
@@ -3698,7 +3727,7 @@ class UserAgentService:
"group_code": "travel",
"scene_label": "住宿票据",
}
if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")):
if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "乘车", "用车", "叫车", "车费", "车资", "的士", "过路费", "停车")):
return {
"document_type": "transport_receipt",
"expense_type": "transport",