feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View File

@@ -327,7 +327,11 @@ class ExpenseClaimDraftFlowMixin:
)
self._sync_claim_from_items(claim)
elif skip_primary_item:
self._sync_application_link_draft_without_items(claim)
self._clear_application_link_placeholder_items(claim, context_json=context_json)
if claim.items:
self._sync_claim_from_items(claim)
else:
self._sync_application_link_draft_without_items(claim)
else:
self._upsert_primary_item(
claim=claim,
@@ -394,6 +398,61 @@ class ExpenseClaimDraftFlowMixin:
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
def _clear_application_link_placeholder_items(
self,
claim: ExpenseClaim,
*,
context_json: dict[str, Any],
) -> None:
application_amounts = self._resolve_application_amount_candidates(context_json)
for item in list(claim.items or []):
if not self._is_application_link_placeholder_item(
item,
claim=claim,
context_json=context_json,
application_amounts=application_amounts,
):
continue
claim.items.remove(item)
self.db.delete(item)
def _is_application_link_placeholder_item(
self,
item: ExpenseClaimItem,
*,
claim: ExpenseClaim,
context_json: dict[str, Any],
application_amounts: set[Decimal],
) -> bool:
if str(item.invoice_id or "").strip():
return False
item_type = str(item.item_type or "").strip().lower()
if item_type in DOCUMENT_FACT_ITEM_TYPES:
return False
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
return True
claim_type = str(claim.expense_type or "").strip().lower()
if item_type and claim_type and item_type != claim_type:
return False
amount = self._parse_context_money_amount(item.item_amount)
if application_amounts and amount is not None and amount > Decimal("0.00") and amount not in application_amounts:
return False
reason = str(item.item_reason or "").strip()
if not reason or reason == "待补充":
return True
review_values = self._normalize_context_object(context_json.get("review_form_values"))
linked_reasons = {
str(review_values.get(key) or "").strip()
for key in ("application_reason", "reason", "business_reason")
}
linked_reasons.add(str(claim.reason or "").strip())
return reason in {value for value in linked_reasons if value}
def _should_skip_application_link_placeholder_item(
self,
*,
@@ -405,23 +464,10 @@ class ExpenseClaimDraftFlowMixin:
) -> bool:
if document_specs or attachment_count > 0:
return False
if claim is not None and list(claim.items or []):
return False
if self._build_application_link_flag(context_json) is None:
return False
application_amounts = self._resolve_application_amount_candidates(context_json)
review_values = self._normalize_context_object(context_json.get("review_form_values"))
raw_amount = str(review_values.get("amount") or "").strip()
if raw_amount:
parsed_amount = self._parse_context_money_amount(raw_amount)
if parsed_amount is None:
return True
return bool(application_amounts and parsed_amount in application_amounts)
if amount is None or amount <= Decimal("0.00"):
return True
return bool(application_amounts and amount in application_amounts)
return True
@classmethod
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
@@ -497,7 +543,26 @@ class ExpenseClaimDraftFlowMixin:
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
application_reason = pick("application_reason", "applicationReason", "reason")
application_location = pick("application_location", "applicationLocation", "location")
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
application_time = pick(
"application_business_time",
"applicationBusinessTime",
"application_time",
"applicationTime",
"business_time",
"businessTime",
"time_range",
"timeRange",
"time",
)
application_date = pick("application_date", "applicationDate")
application_days = pick("application_days", "applicationDays", "days")
application_transport_mode = pick("application_transport_mode", "applicationTransportMode", "transport_mode", "transportMode")
application_lodging_daily_cap = pick("application_lodging_daily_cap", "applicationLodgingDailyCap", "lodging_daily_cap", "lodgingDailyCap")
application_subsidy_daily_cap = pick("application_subsidy_daily_cap", "applicationSubsidyDailyCap", "subsidy_daily_cap", "subsidyDailyCap")
application_transport_policy = pick("application_transport_policy", "applicationTransportPolicy", "transport_policy", "transportPolicy")
application_policy_estimate = pick("application_policy_estimate", "applicationPolicyEstimate", "policy_estimate", "policyEstimate")
application_rule_name = pick("application_rule_name", "applicationRuleName", "rule_name", "ruleName")
application_rule_version = pick("application_rule_version", "applicationRuleVersion", "rule_version", "ruleVersion")
application_status = pick("application_status", "applicationStatus")
application_status_label = pick("application_status_label", "applicationStatusLabel")
@@ -517,7 +582,17 @@ class ExpenseClaimDraftFlowMixin:
"application_location": application_location,
"application_amount": application_amount,
"application_amount_label": application_amount_label,
"application_time": application_date,
"application_time": application_time or application_date,
"application_business_time": application_time,
"application_date": application_date,
"application_days": application_days,
"application_transport_mode": application_transport_mode,
"application_lodging_daily_cap": application_lodging_daily_cap,
"application_subsidy_daily_cap": application_subsidy_daily_cap,
"application_transport_policy": application_transport_policy,
"application_policy_estimate": application_policy_estimate,
"application_rule_name": application_rule_name,
"application_rule_version": application_rule_version,
},
"review_form_values": review_values,
"expense_scene_selection": scene_selection,