feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
@@ -31,11 +32,14 @@ from app.services.expense_claim_attachment_storage import ExpenseClaimAttachment
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
)
from app.services.ontology import SemanticOntologyService
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
@@ -3907,6 +3911,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
approval_stage="审批完成",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525121000-ARCHIVED",
employee_name="",
department_name="E部",
project_code="PRJ-E",
expense_type="travel_application",
reason="E 申请",
location="广州",
amount=Decimal("600.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
status="approved",
approval_stage=APPLICATION_ARCHIVE_STAGE,
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-20260525123000-HGFEDCBA",
employee_name="",
@@ -3933,7 +3954,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
assert {claim.claim_no for claim in claims} == {
"EXP-ARCH-101",
"EXP-ARCH-PAID",
"AP-20260525120000-ABCDEFGH",
"AP-20260525121000-ARCHIVED",
}
@@ -4288,6 +4309,65 @@ def test_admin_can_delete_archived_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
receipt_owner = CurrentUserContext(
username="emp-1",
name="Employee",
role_codes=[],
is_admin=False,
)
admin_user = CurrentUserContext(
username="superadmin",
name="Admin",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = build_claim(expense_type="travel", location="Shanghai")
db.add(claim)
db.commit()
claim_id = claim.id
claim_no = claim.claim_no
item_id = claim.items[0].id
receipt_service = ReceiptFolderService()
receipt = receipt_service.save_receipt(
filename="admin-delete-linked-receipt.pdf",
content=b"%PDF-1.4 linked",
media_type="application/pdf",
current_user=receipt_owner,
linked_claim_id=claim_id,
linked_claim_no=claim_no,
linked_item_id=item_id,
document=OcrRecognizeDocumentRead(
filename="admin-delete-linked-receipt.pdf",
media_type="application/pdf",
text="invoice number 123 amount 100",
document_type="vat_invoice",
document_type_label="invoice",
scene_code="other",
scene_label="receipt",
),
)
assert receipt.status == "linked"
deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner)
assert unlinked_receipt.status == "unlinked"
assert unlinked_receipt.linked_claim_id == ""
assert unlinked_receipt.linked_claim_no == ""
assert unlinked_receipt.linked_at is None
finally:
get_settings.cache_clear()
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
current_user = CurrentUserContext(
username="manager-return@example.com",
@@ -4842,7 +4922,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert approved.approval_stage == "关联单据状态"
archived_claims = ExpenseClaimService(db).list_archived_claims(
CurrentUserContext(
username="finance-archive@example.com",
@@ -4851,7 +4931,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
is_admin=False,
)
)
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交"
@@ -4891,7 +4971,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("generated_draft_claim_id") == generated_draft.id
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
for flag in approved.risk_flags_json
@@ -5002,7 +5082,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
@@ -5147,7 +5227,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == APPROVAL_DONE_STAGE
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
@@ -5158,7 +5238,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
@@ -5235,7 +5315,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved is not None
assert approved.status == "approved"
assert approved.approval_stage == "审批完成"
assert approved.approval_stage == "关联单据状态"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
assert not any(
isinstance(flag, dict)
@@ -5250,7 +5330,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json
@@ -5819,6 +5899,94 @@ def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
)
def test_marking_linked_reimbursement_paid_archives_application_claim() -> None:
current_user = CurrentUserContext(
username="finance-pay-linked-application@example.com",
name="财务付款",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
application_claim = ExpenseClaim(
claim_no="AP-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel_application",
reason="支撑国网部署",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC),
status="approved",
approval_stage=APPROVAL_DONE_STAGE,
risk_flags_json=[],
)
db.add(application_claim)
db.flush()
reimbursement_claim = ExpenseClaim(
claim_no="RE-202606050001-ARCHIVE",
employee_name="张三",
department_name="交付部",
project_code="PRJ-APP",
expense_type="travel",
reason="支撑国网部署报销",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC),
status="pending_payment",
approval_stage="待付款",
risk_flags_json=[
{
"source": "application_handoff",
"event_type": "expense_application_to_reimbursement_draft",
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
}
],
)
db.add(reimbursement_claim)
db.commit()
archived_before = ExpenseClaimService(db).list_archived_claims(current_user)
assert all(claim.claim_no != application_claim.claim_no for claim in archived_before)
paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)
assert paid is not None
db.refresh(application_claim)
assert application_claim.status == "approved"
assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE
assert any(
isinstance(flag, dict)
and flag.get("source") == "application_archive_sync"
and flag.get("event_type") == "expense_application_archived_by_reimbursement"
and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no
and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE
for flag in application_claim.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "payment"
and any(
item.get("application_claim_no") == application_claim.claim_no
for item in flag.get("archived_application_claims", [])
if isinstance(item, dict)
)
for flag in paid.risk_flags_json
)
archived_after = ExpenseClaimService(db).list_archived_claims(current_user)
assert any(claim.claim_no == application_claim.claim_no for claim in archived_after)
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
current_user = CurrentUserContext(
username="finance-returned@example.com",