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

@@ -158,21 +158,139 @@ class ExpenseClaimItemSyncMixin:
end_date = start_date
days = (end_date - start_date).days + 1
application_days = self._resolve_travel_allowance_days_from_application_link(claim)
explicit_days = max(
(self._extract_travel_day_count(item.item_reason) for item in business_items),
default=0,
)
unique_dates = {value for value in dated_items}
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
return application_days
if explicit_days > 0:
days = explicit_days
end_date = start_date + timedelta(days=days - 1)
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
return application_days
return max(1, days), start_date, end_date
existing_days = self._extract_travel_allowance_days(existing_allowance)
unique_dates = {value for value in dated_items}
if existing_days > days and len(unique_dates) <= 1:
days = existing_days
end_date = start_date + timedelta(days=days - 1)
return max(1, days), start_date, end_date
def _resolve_travel_allowance_days_from_application_link(
self,
claim: ExpenseClaim,
) -> tuple[int, date, date] | None:
values = self._collect_application_link_values(claim)
if not values:
return None
time_text = str(
values.get("application_business_time")
or values.get("business_time")
or values.get("time_range")
or values.get("application_time")
or values.get("time")
or ""
).strip()
dates = self._extract_application_link_dates(time_text)
if len(dates) >= 2:
start_date, end_date = dates[0], dates[-1]
if end_date < start_date:
start_date, end_date = end_date, start_date
return max(1, (end_date - start_date).days + 1), start_date, end_date
days = self._extract_travel_day_count(
str(values.get("application_days") or values.get("days") or "").strip()
)
if days <= 0:
return None
start_date = dates[0] if dates else claim.occurred_at.date() if claim.occurred_at is not None else date.today()
end_date = start_date + timedelta(days=days - 1)
return days, start_date, end_date
def _collect_application_link_values(self, claim: ExpenseClaim) -> dict[str, Any]:
values: dict[str, Any] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() not in {"application_link", "application_handoff"}:
continue
for source in (
flag.get("expense_scene_selection"),
flag.get("review_form_values"),
flag.get("application_detail"),
flag,
):
if isinstance(source, dict):
values.update(source)
linked_detail = self._resolve_linked_application_detail_values(values)
for key, value in linked_detail.items():
values.setdefault(key, value)
return values
def _resolve_linked_application_detail_values(self, values: dict[str, Any]) -> dict[str, Any]:
application_claim = self._find_linked_application_claim(values)
if application_claim is None:
return {}
detail: dict[str, Any] = {}
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail":
continue
payload = flag.get("application_detail") or flag.get("applicationDetail") or {}
if isinstance(payload, dict):
detail.update(payload)
if detail.get("time"):
detail.setdefault("application_time", detail.get("time"))
if detail.get("days"):
detail.setdefault("application_days", detail.get("days"))
if detail.get("transport_mode"):
detail.setdefault("application_transport_mode", detail.get("transport_mode"))
if detail.get("location"):
detail.setdefault("application_location", detail.get("location"))
if detail.get("reason"):
detail.setdefault("application_reason", detail.get("reason"))
if application_claim.occurred_at is not None:
detail.setdefault("application_time", application_claim.occurred_at.date().isoformat())
detail.setdefault("time", application_claim.occurred_at.date().isoformat())
detail.setdefault("application_reason", str(application_claim.reason or "").strip())
detail.setdefault("application_location", str(application_claim.location or "").strip())
return {str(key): value for key, value in detail.items() if str(value or "").strip()}
def _find_linked_application_claim(self, values: dict[str, Any]) -> ExpenseClaim | None:
application_claim_id = str(
values.get("application_claim_id")
or values.get("applicationClaimId")
or ""
).strip()
if application_claim_id:
linked_claim = self.db.get(ExpenseClaim, application_claim_id)
if linked_claim is not None:
return linked_claim
application_claim_no = str(
values.get("application_claim_no")
or values.get("applicationClaimNo")
or ""
).strip()
if not application_claim_no:
return None
return self.db.scalar(
select(ExpenseClaim).where(ExpenseClaim.claim_no == application_claim_no)
)
@staticmethod
def _extract_application_link_dates(value: str) -> list[date]:
dates: list[date] = []
for matched in re.findall(r"\d{4}-\d{2}-\d{2}", str(value or "")):
try:
dates.append(date.fromisoformat(matched))
except ValueError:
continue
return dates
@staticmethod
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
if item is None: