feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user