feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -1375,6 +1375,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
payload=ExpenseClaimItemUpdate(
item_reason="",
item_location="",
item_note="票据行程存在改签,已核对业务真实发生。",
item_amount=Decimal("0.00"),
),
current_user=current_user,
@@ -1385,6 +1386,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
assert claim.items[0].item_date == date(2026, 5, 13)
assert claim.items[0].item_reason == ""
assert claim.items[0].item_location == ""
assert claim.items[0].item_note == "票据行程存在改签,已核对业务真实发生。"
assert claim.items[0].item_amount == Decimal("0.00")
@@ -1606,7 +1608,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
service = ExpenseClaimService(db)
updated = service.create_claim_item(
claim_id=claim.id,
payload=ExpenseClaimItemCreate(),
payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"),
current_user=current_user,
)
@@ -1619,6 +1621,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
assert new_item.item_type == "office"
assert new_item.item_reason == ""
assert new_item.item_location == ""
assert new_item.item_note == "待上传异常票据说明"
assert new_item.item_amount == Decimal("0.00")
assert new_item.invoice_id is None
@@ -2808,6 +2811,154 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-round-trip@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
documents: list[OcrRecognizeDocumentRead] = []
for filename, _, media_type in files:
if filename == "outbound.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ¥354.00",
summary="武汉到上海高铁票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="铁路电子客票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "amount", "label": "金额", "value": "354元"},
{"key": "date", "label": "日期", "value": "2026-02-20"},
],
warnings=[],
)
)
elif filename == "return.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ¥354.00",
summary="上海到武汉高铁票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="铁路电子客票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "上海-武汉"},
{"key": "amount", "label": "金额", "value": "354元"},
{"key": "date", "label": "日期", "value": "2026-02-23"},
],
warnings=[],
)
)
return OcrRecognizeBatchRead(
total_file_count=len(files),
success_count=len(documents),
documents=documents,
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
manager = Employee(
employee_no="E7210",
name="李经理",
email="manager-round-trip@example.com",
)
employee = Employee(
employee_no="E7211",
name="张三",
email="emp-round-trip@example.com",
grade="P4",
location="上海",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = build_claim(expense_type="travel", location="上海")
claim.reason = "支撑国网仿生产环境部署"
claim.employee = employee
claim.employee_id = employee.id
claim.items = [
ExpenseClaimItem(
id="round-trip-item-1",
claim_id=claim.id,
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
invoice_id=None,
),
ExpenseClaimItem(
id="round-trip-item-2",
claim_id=claim.id,
item_date=date(2026, 2, 23),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
invoice_id=None,
),
]
claim.amount = Decimal("708.00")
claim.invoice_count = 0
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="round-trip-item-1",
filename="outbound.png",
content=b"outbound-image",
media_type="image/png",
current_user=current_user,
)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="round-trip-item-2",
filename="return.png",
content=b"return-image",
media_type="image/png",
current_user=current_user,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert not any(
isinstance(flag, dict)
and str(flag.get("rule_code") or "").strip() == "risk.travel.high.city_mismatch"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
monkeypatch,
tmp_path,
@@ -4051,6 +4202,44 @@ def test_application_submit_blocks_when_budget_insufficient_without_state_change
assert db.query(BudgetTransaction).count() == 0
def test_reimbursement_submit_keeps_budget_insufficient_as_review_risk() -> None:
current_user = CurrentUserContext(
username="reimbursement-budget-risk@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-1",
department_name="市场部",
subject_code="office",
amount=Decimal("1000.00"),
)
claim = build_claim(expense_type="office", location="待补充")
claim.amount = Decimal("1200.00")
claim.items[0].item_amount = Decimal("1200.00")
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.submitted_at is not None
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_insufficient"
and flag.get("business_stage") == "reimbursement"
for flag in submitted.risk_flags_json
)
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
def test_application_submit_skips_budget_for_non_demo_subject() -> None:
current_user = CurrentUserContext(
username="application-budget-skip@example.com",