feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构 - 增强风险观测服务与运行时聊天上下文作用域 - 优化工作台图标资源、助理意图识别与摘要工具 - 完善报销创建视图样式与差旅详情页标准调整交互 - 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user