feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
{
|
||||
"file_name": "发票_3_京S98876.pdf",
|
||||
"storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf",
|
||||
"storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 61170,
|
||||
"uploaded_at": "2026-05-20T02:21:35.637474+00:00",
|
||||
"uploaded_at": "2026-05-20T12:25:49.243144+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png",
|
||||
"preview_storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "发票_3_京S98876.preview.png",
|
||||
"analysis": {
|
||||
"severity": "medium",
|
||||
"label": "中风险",
|
||||
"headline": "AI提示:附件存在明显待整改项",
|
||||
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"用途字段:当前费用项目为其他,但附件内容更像住宿、交通相关票据。"
|
||||
"票据类型:已识别为增值税发票。",
|
||||
"附件类型要求:当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。",
|
||||
"金额字段:已识别到与当前明细接近的金额 121.54 元。"
|
||||
],
|
||||
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "vat_invoice",
|
||||
@@ -49,23 +51,25 @@
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "other",
|
||||
"current_expense_type_label": "其他费用",
|
||||
"current_expense_type": "transport",
|
||||
"current_expense_type_label": "交通费",
|
||||
"allowed_scene_labels": [
|
||||
"其他票据"
|
||||
"交通"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"停车/通行费票据",
|
||||
"一般收据/凭证",
|
||||
"出租车/网约车票据",
|
||||
"增值税发票"
|
||||
],
|
||||
"recognized_scene_code": "other",
|
||||
"recognized_scene_label": "通用发票",
|
||||
"recognized_document_type": "vat_invoice",
|
||||
"recognized_document_type_label": "增值税发票",
|
||||
"mismatch_severity": "medium",
|
||||
"mismatch_severity": "high",
|
||||
"rule_code": "rule.expense.scene_submission_standard",
|
||||
"rule_name": "报销场景提交与附件标准",
|
||||
"message": "当前费用项目为其他费用,已识别为增值税发票,符合当前其他费用场景的附件要求。"
|
||||
"message": "当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。"
|
||||
},
|
||||
"ocr_status": "recognized",
|
||||
"ocr_error": "",
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
@@ -14,8 +14,8 @@
|
||||
"updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 1,
|
||||
"ingest_status_updated_at": "2026-05-20T06:29:01.123795+00:00",
|
||||
"ingest_status": 3,
|
||||
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
@@ -110,6 +111,19 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
|
||||
assert expense_type == "office"
|
||||
|
||||
|
||||
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
|
||||
expense_type = ExpenseClaimService._resolve_expense_type(
|
||||
[],
|
||||
context_json={
|
||||
"review_form_values": {
|
||||
"expense_type": "乘车费用"
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert expense_type == "transport"
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
|
||||
user_id = "zhangsan@example.com"
|
||||
|
||||
@@ -238,6 +252,48 @@ def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upl
|
||||
assert claim.reason == "待补充"
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None:
|
||||
user_id = "transport-time@example.com"
|
||||
message = "业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用"
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5004",
|
||||
name="赵六",
|
||||
email=user_id,
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "赵六",
|
||||
"user_input_text": message,
|
||||
},
|
||||
)
|
||||
|
||||
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert claim is not None
|
||||
assert claim.occurred_at.date() == date(2026, 3, 4)
|
||||
assert claim.reason == "送客户去林萃小区办事,请报销乘车费用"
|
||||
assert len(claim.items) == 1
|
||||
assert claim.items[0].item_date == date(2026, 3, 4)
|
||||
assert claim.items[0].item_reason == "送客户去林萃小区办事,请报销乘车费用"
|
||||
assert "客户单位" not in result["message"]
|
||||
assert "票据附件" not in result["message"]
|
||||
assert "费用明细" not in result["message"]
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
|
||||
user_id = "lisi@example.com"
|
||||
|
||||
@@ -348,6 +404,100 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
|
||||
assert float(new_claim.amount) == 50.5
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
|
||||
user_id = "returned-owner@example.com"
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"return_event_id": "return-event-1",
|
||||
"message": "第一次退回:附件缺失。",
|
||||
"reason": "附件缺失。",
|
||||
"return_count": 1,
|
||||
"return_stage": "直属领导审批",
|
||||
"return_stage_key": "direct_manager",
|
||||
"risk_points": ["附件缺失或不清晰"],
|
||||
}
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5004",
|
||||
name="赵六",
|
||||
email=user_id,
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
existing_claim = ExpenseClaim(
|
||||
claim_no="EXP-202605-012",
|
||||
employee_id=employee.id,
|
||||
employee_name="赵六",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="transport",
|
||||
reason="原有交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("20.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
||||
status="returned",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[return_flag],
|
||||
)
|
||||
existing_claim.items = [
|
||||
ExpenseClaimItem(
|
||||
claim_id=existing_claim.id,
|
||||
item_date=date(2026, 5, 13),
|
||||
item_type="transport",
|
||||
item_reason="原有交通报销",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("20.00"),
|
||||
invoice_id="old-trip.png",
|
||||
)
|
||||
]
|
||||
db.add(existing_claim)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我补充了交通票据,更新这张退回单据",
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
ontology.risk_flags = ["系统识别:票据金额待人工核对。"]
|
||||
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message="我补充了交通票据,更新这张退回单据",
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "赵六",
|
||||
"draft_claim_id": existing_claim.id,
|
||||
"attachment_names": ["new-trip.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "new-trip.png",
|
||||
"summary": "滴滴出行 支付金额 32 元",
|
||||
"text": "滴滴出行 支付金额 32 元",
|
||||
"document_type": "taxi_receipt",
|
||||
"scene_code": "transport",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
db.refresh(existing_claim)
|
||||
assert result["claim_id"] == existing_claim.id
|
||||
assert existing_claim.status == "draft"
|
||||
assert "系统识别:票据金额待人工核对。" in existing_claim.risk_flags_json
|
||||
manual_returns = [
|
||||
flag
|
||||
for flag in list(existing_claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "manual_return"
|
||||
]
|
||||
assert manual_returns == [return_flag]
|
||||
|
||||
|
||||
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
@@ -642,6 +792,44 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
|
||||
assert not attachment_root.exists()
|
||||
|
||||
|
||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="transport", location="上海")
|
||||
claim.items[0].invoice_id = "legacy-ticket.pdf"
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
attachment_dir = tmp_path / claim.id / claim.items[0].id
|
||||
attachment_dir.mkdir(parents=True)
|
||||
file_path = attachment_dir / "legacy-ticket.pdf"
|
||||
file_path.write_bytes(b"legacy-pdf-bytes")
|
||||
(attachment_dir / "legacy-ticket.pdf.meta.json").write_text(
|
||||
'{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
resolved_path, media_type, filename = payload
|
||||
assert resolved_path == file_path
|
||||
assert media_type == "application/pdf"
|
||||
assert filename == "legacy-ticket.pdf"
|
||||
|
||||
|
||||
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
@@ -677,6 +865,43 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E7100",
|
||||
name="李经理",
|
||||
email="manager-returned@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E7101",
|
||||
name="张三",
|
||||
email="emp-submit@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(expense_type="transport", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待补充"
|
||||
claim.items[0].invoice_id = "taxi-ticket.png"
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_submit_claim_backfills_department_from_current_employee() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-dept@example.com",
|
||||
@@ -1327,7 +1552,377 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
|
||||
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-return@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8100",
|
||||
name="李经理",
|
||||
email="manager-return@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8101",
|
||||
name="张三",
|
||||
email="zhangsan-return@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-201",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
|
||||
|
||||
assert returned is not None
|
||||
assert returned.status == "returned"
|
||||
assert returned.approval_stage == "待提交"
|
||||
assert returned.submitted_at is None
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_return"
|
||||
and flag.get("message") == "请补充行程说明"
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-approve@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8110",
|
||||
name="李经理",
|
||||
email="manager-approve@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8111",
|
||||
name="张三",
|
||||
email="zhangsan-approve@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-APP-201",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="情况属实,同意报销。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == "财务审批"
|
||||
assert approved.submitted_at is not None
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_claim_approval"
|
||||
and flag.get("opinion") == "情况属实,同意报销。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_approval_stage") == "财务审批"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"return_event_id": "return-event-existing",
|
||||
"message": "请补充附件。",
|
||||
"return_count": 1,
|
||||
"return_stage": "直属领导审批",
|
||||
"return_stage_key": "direct_manager",
|
||||
}
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-202",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[return_flag],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="无需重复退回"):
|
||||
ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回")
|
||||
|
||||
db.refresh(claim)
|
||||
manual_returns = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "manual_return"
|
||||
]
|
||||
assert manual_returns == [return_flag]
|
||||
|
||||
|
||||
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-return@example.com",
|
||||
name="财务复核",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-301",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
first_returned = service.return_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
reason="发票金额与明细金额不一致,请重新核对。",
|
||||
reason_codes=["invoice_mismatch", "business_explanation"],
|
||||
)
|
||||
assert first_returned is not None
|
||||
|
||||
first_returned.status = "submitted"
|
||||
first_returned.approval_stage = "财务审批"
|
||||
first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC)
|
||||
db.commit()
|
||||
|
||||
second_returned = service.return_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
reason="超标说明仍不完整,请补充制度例外依据。",
|
||||
reason_codes=["over_policy"],
|
||||
)
|
||||
|
||||
assert second_returned is not None
|
||||
return_events = [
|
||||
flag
|
||||
for flag in list(second_returned.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "manual_return"
|
||||
]
|
||||
assert len(return_events) == 2
|
||||
assert return_events[0]["return_count"] == 1
|
||||
assert return_events[0]["stage_return_count"] == 1
|
||||
assert return_events[0]["return_stage"] == "直属领导审批"
|
||||
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
|
||||
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
|
||||
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
|
||||
assert return_events[0]["operator_role_codes"] == ["finance"]
|
||||
assert return_events[1]["return_count"] == 2
|
||||
assert return_events[1]["stage_return_count"] == 1
|
||||
assert return_events[1]["return_stage"] == "财务审批"
|
||||
assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"]
|
||||
|
||||
|
||||
def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit-returned@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"return_event_id": "return-event-submit",
|
||||
"message": "第一次退回:业务说明不完整。",
|
||||
"reason": "业务说明不完整。",
|
||||
"return_count": 1,
|
||||
"return_stage": "直属领导审批",
|
||||
"return_stage_key": "direct_manager",
|
||||
"risk_points": ["业务事由/地点/人员信息不完整"],
|
||||
}
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8200",
|
||||
name="李经理",
|
||||
email="manager-submit-returned@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8201",
|
||||
name="张三",
|
||||
email="emp-submit-returned@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(expense_type="office", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = "张三"
|
||||
claim.department_name = "市场部"
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = [return_flag]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_return"
|
||||
and flag.get("return_event_id") == "return-event-submit"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
|
||||
|
||||
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-personal@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8300",
|
||||
name="李经理",
|
||||
email="manager-personal@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8301",
|
||||
name="张三",
|
||||
email="zhangsan-personal@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-MGR-OWN",
|
||||
employee_id=manager.id,
|
||||
employee_name="李经理",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-MGR",
|
||||
expense_type="office",
|
||||
reason="本人报销",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-MGR-SUB",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-MGR",
|
||||
expense_type="transport",
|
||||
reason="下属待审批报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
personal_claims = service.list_claims(current_user)
|
||||
approval_claims = service.list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"]
|
||||
assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"]
|
||||
|
||||
|
||||
def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager@example.com",
|
||||
name="李经理",
|
||||
@@ -1402,7 +1997,7 @@ def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval()
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-MGR-201"
|
||||
|
||||
@@ -433,11 +433,11 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
|
||||
assert result.time_range.end_date == "2026-05-11"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我买了办公用品和文具,花了88元,帮我报销",
|
||||
user_id="pytest",
|
||||
)
|
||||
@@ -446,15 +446,33 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type()
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert any(
|
||||
item.type == "expense_type" and item.normalized_value == "office"
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
item.type == "expense_type" and item.normalized_value == "office"
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert any(
|
||||
item.type == "expense_type" and item.normalized_value == "transport"
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_parse_with_model",
|
||||
|
||||
@@ -289,6 +289,67 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke
|
||||
assert any("附件内容" in point for point in analysis["points"])
|
||||
|
||||
|
||||
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
id="mgr-approve-1",
|
||||
employee_no="E21001",
|
||||
name="李经理",
|
||||
email="manager-approve-api@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-approve-1",
|
||||
employee_no="E11001",
|
||||
name="张三",
|
||||
email="zhangsan-approve-api@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-approve-1",
|
||||
claim_no="EXP-APP-API-001",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id="dept-1",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={"opinion": "情况属实,同意报销。"},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "submitted"
|
||||
assert payload["approval_stage"] == "财务审批"
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["opinion"] == "情况属实,同意报销。"
|
||||
and item["next_approval_stage"] == "财务审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
preview_bytes = b"fake-preview-png"
|
||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||
|
||||
@@ -546,11 +546,11 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
|
||||
assert slot_map["amount"].value == "1000.00元"
|
||||
|
||||
|
||||
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我前天请客户吃饭花了200元",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
@@ -571,15 +571,106 @@ def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
|
||||
|
||||
assert response.review_payload is not None
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["time_range"].raw_value == "前天"
|
||||
assert slot_map["time_range"].value == "2026-05-11"
|
||||
assert "时间为 2026-05-11" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
assert slot_map["time_range"].raw_value == "前天"
|
||||
assert slot_map["time_range"].value == "2026-05-11"
|
||||
assert "时间为 2026-05-11" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
message = "业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用"
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["expense_type"].value == "交通费"
|
||||
assert slot_map["expense_type"].normalized_value == "transport"
|
||||
assert slot_map["time_range"].value == "2026-03-04"
|
||||
assert slot_map["reason"].value == "送客户去林萃小区办事,请报销乘车费用"
|
||||
assert "业务发生时间" not in slot_map["reason"].raw_value
|
||||
assert "“交通费”" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
message = "业务发生时间:2026-03-04,送客户去林萃小区办事,打车花了32元,请报销乘车费用"
|
||||
context_json = {
|
||||
"name": "赵六",
|
||||
"attachment_names": ["didi-trip.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "didi-trip.png",
|
||||
"summary": "滴滴出行 支付金额 32 元",
|
||||
"text": "滴滴出行 支付金额 32 元",
|
||||
"document_type": "taxi_receipt",
|
||||
"scene_code": "transport",
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "支付金额", "value": "32.00"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={
|
||||
"draft_only": True,
|
||||
"claim_id": "claim-1",
|
||||
"claim_no": "EXP-202603-001",
|
||||
"status": "draft",
|
||||
"message": (
|
||||
"已创建报销草稿 EXP-202603-001,当前状态为 draft。"
|
||||
"你可以继续补充费用明细、客户单位和票据附件。"
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.can_proceed is True
|
||||
assert "客户名称" not in response.review_payload.missing_slots
|
||||
assert "参与人员" not in response.review_payload.missing_slots
|
||||
assert "票据附件" not in response.review_payload.missing_slots
|
||||
risk_text = "\n".join(
|
||||
f"{item.title}\n{item.content}" for item in response.review_payload.risk_briefs
|
||||
)
|
||||
assert "AI预审未通过" not in risk_text
|
||||
assert "已创建报销草稿" not in risk_text
|
||||
|
||||
|
||||
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
|
||||
user_id="pytest",
|
||||
|
||||
Reference in New Issue
Block a user