feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user