feat(expenses): secure timeline and draft events

This commit is contained in:
caoxiaozhu
2026-07-14 00:07:07 +08:00
parent 22669a9071
commit 1347366b95
14 changed files with 1044 additions and 59 deletions

View File

@@ -1114,9 +1114,147 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
assert db.scalar(
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
) is None
assert db.scalar(
event = db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
) is None
assert db.scalar(
)
assert event is not None
assert event.event_type == "claim_draft_created"
assert event.payload_json["previous_status"] == ""
assert event.payload_json["next_status"] == "draft"
link = db.scalar(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
)
assert link is not None
assert link.relation_type == "application"
def test_application_preview_action_rejects_forged_identity_when_editing_other_claim() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
outsider = Employee(
id="emp-outsider",
employee_no="E90001",
name="其他员工",
email="outsider@example.com",
)
outsider_claim = ExpenseClaim(
id="application-outsider-1",
claim_no="AP-OUTSIDER-001",
employee_id=outsider.id,
employee_name=outsider.name,
department_name="交付部",
expense_type="travel_application",
reason="其他员工原申请",
location="北京",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
submitted_at=None,
status="returned",
approval_stage="退回补充",
risk_flags_json=[],
)
db.add_all([outsider, outsider_claim])
db.commit()
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
},
json={
"source": "user_message",
"user_id": "outsider@example.com",
"conversation_id": "conversation-forged-identity",
"message": "费用申请保存草稿\n申请时间2026-07-13 至 2026-07-14\n地点:上海\n事由:恶意修改\n申请金额880元\n保存草稿",
"context_json": {
"session_type": "application",
"application_action": "save_draft",
"application_save_mode": True,
"application_edit_mode": True,
"application_edit_claim_id": "application-outsider-1",
"username": "outsider@example.com",
"name": "其他员工",
"employee_no": "E90001",
"role_codes": ["admin"],
"is_admin": True,
},
},
)
assert response.status_code == 400
assert response.json()["detail"] == "只能修改本人被退回的申请单。"
with session_factory() as db:
persisted = db.get(ExpenseClaim, "application-outsider-1")
assert persisted is not None
assert persisted.reason == "其他员工原申请"
assert persisted.status == "returned"
assert persisted.approval_stage == "退回补充"
assert db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
) is None
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
request_payload = {
"source": "user_message",
"user_id": "zhangsan@example.com",
"conversation_id": "conversation-fast-save-retry",
"message": "费用申请保存草稿\n地点:上海\n事由:项目验收\n申请金额880元\n保存草稿",
"context_json": {
"session_type": "application",
"application_action": "save_draft",
"application_save_mode": True,
},
}
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
first_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=headers,
json=request_payload,
)
second_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=headers,
json=request_payload,
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first_draft = first_response.json()["result"]["draft_payload"]
second_draft = second_response.json()["result"]["draft_payload"]
assert second_draft["claim_id"] == first_draft["claim_id"]
assert second_draft["claim_no"] == first_draft["claim_no"]
with session_factory() as db:
application_claims = list(
db.scalars(
select(ExpenseClaim).where(
ExpenseClaim.expense_type == "travel_application"
)
).all()
)
assert len(application_claims) == 1
events = list(
db.scalars(
select(BusinessEvent).where(
BusinessEvent.aggregate_id == application_claims[0].id
)
).all()
)
assert len(events) == 1
assert events[0].event_type == "claim_draft_created"