feat: 报销预审会话状态管理与工作台交互增强

- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 11:03:29 +08:00
parent 87da5df91b
commit 1cbf3fee44
60 changed files with 4156 additions and 393 deletions

View File

@@ -1918,6 +1918,77 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="submitter",
role_codes=[],
is_admin=False,
)
review_calls: list[str] = []
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="receipt.png",
media_type="image/png",
text="office receipt amount 88 2026-05-13",
summary="recognized office receipt",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
def fake_review(self, reviewed_claim):
review_calls.append(reviewed_claim.id)
return {
"risk_flags": [
*list(reviewed_claim.risk_flags_json or []),
{
"source": "submission_review",
"severity": "high",
"label": "upload-time-risk",
"message": "risk generated after attachment upload",
},
]
}
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fake_review)
with build_session() as db:
claim = build_claim(expense_type="office", location="Shanghai")
claim.invoice_count = 0
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
payload = ExpenseClaimService(db).upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="receipt.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
flags = payload["claim_risk_flags"]
assert review_calls == [claim.id]
assert any(flag.get("label") == "upload-time-risk" for flag in flags)
pre_review = next(flag for flag in flags if flag.get("source") == "ai_pre_review")
assert pre_review["status"] == "failed"
assert pre_review["blocking_risk_count"] >= 1
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
@@ -2619,6 +2690,60 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypatch) -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="submitter",
role_codes=[],
is_admin=False,
)
def fail_review(self, reviewed_claim):
raise AssertionError("submit should reuse upload-time pre-review")
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fail_review)
with build_session() as db:
manager = Employee(
employee_no="E7010",
name="Manager",
email="manager-reuse@example.com",
)
employee = Employee(
employee_no="E7011",
name="submitter",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="Shanghai")
claim.employee = employee
claim.employee_id = employee.id
claim.items[0].invoice_id = "taxi-ticket.png"
claim.risk_flags_json = [
{
"source": "submission_review",
"severity": "medium",
"label": "upload-time-warning",
"message": "generated before submit",
},
{
"source": "ai_pre_review",
"status": "passed",
"passed": True,
"severity": "info",
"blocking_risk_count": 0,
},
]
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 any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json)
assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json)
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext(
@@ -2669,28 +2794,92 @@ def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_s
)
assert adjusted is not None
assert adjusted.amount == Decimal("600.00")
assert adjusted.amount == Decimal("450.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00"
assert standard_flag["employee_absorbed_amount"] == "280.00"
assert standard_flag["reimbursable_amount"] == "450.00"
assert standard_flag["employee_absorbed_amount"] == "430.00"
assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00")
assert submitted.amount == Decimal("450.00")
assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json
)
def test_accept_standard_adjustment_uses_policy_amount_when_payload_has_no_downgrade() -> None:
current_user = CurrentUserContext(
username="emp-policy-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
grade="P4",
)
with build_session() as db:
manager = Employee(
employee_no="E7032",
name="李经理",
email="manager-policy-standard@example.com",
)
employee = Employee(
employee_no="E7033",
name="张三",
email="emp-policy-standard@example.com",
grade="P4",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("1000.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("1000.00")
db.add_all([manager, employee, claim])
db.commit()
adjusted = ExpenseClaimService(db).accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-policy-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿票据金额超过职级标准。",
"application_days": 2,
"original_amount": Decimal("1000.00"),
"reimbursable_amount": Decimal("1000.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("900.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "1000.00"
assert standard_flag["reimbursable_amount"] == "900.00"
assert standard_flag["employee_absorbed_amount"] == "100.00"
assert standard_flag["visibility_scope"] == "leader"
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext(
username="emp-pre-review@example.com",