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

@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.budget_support import BudgetSupportMixin
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
SLA_TARGET_HOURS = Decimal("8.0")
@@ -30,6 +30,17 @@ PENDING_STATUSES = {
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
@@ -55,6 +66,17 @@ RISK_SIGNAL_LABELS = {
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}
@@ -83,31 +105,34 @@ class FinanceDashboardService(BudgetSupportMixin):
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
department_start, department_end = self._resolve_department_scope(department_range, now)
claims = self._fetch_claims()
observations = self._fetch_risk_observations()
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
]
scope_claims = self._claims_between(claims, start, end)
previous_claims = self._claims_between(claims, previous_start, start)
trend_claims = self._claims_between(claims, trend_start, trend_end)
department_claims = self._claims_between(claims, department_start, department_end)
scope_observations = self._observations_between(observations, start, end)
totals = self._totals(scope_claims, scope_observations, now)
previous_totals = self._totals(previous_claims, [], now)
totals = self._totals(scope_claims)
previous_totals = self._totals(previous_claims)
return FinanceDashboardRead(
range_key=resolved_key,
start_date=start.date().isoformat(),
end_date=(end - timedelta(days=1)).date().isoformat(),
generated_at=now.isoformat(),
has_real_data=bool(claims or observations or self._fetch_budget_allocations(now.year)),
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
totals=totals,
metric_meta=self._metric_meta(totals, previous_totals),
trend=self._trend(trend_labels, trend_claims, now),
spend_by_category=self._spend_by_category(scope_claims),
exception_mix=self._exception_mix(scope_claims, scope_observations),
exception_mix=self._payment_status_mix(scope_claims),
department_ranking=self._department_ranking(department_claims),
bottlenecks=self._bottlenecks(scope_claims, now),
employee_ranking=self._employee_ranking(department_claims),
top_claims=self._top_claims(department_claims),
bottlenecks=self._bottlenecks(scope_claims),
budget_summary=self._budget_summary(now.year),
budget_metrics=self._budget_metrics(now.year),
)
def _ensure_storage_ready(self) -> None:
@@ -117,10 +142,6 @@ class FinanceDashboardService(BudgetSupportMixin):
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
return list(self.db.scalars(stmt).all())
def _fetch_risk_observations(self) -> list[RiskObservation]:
stmt = select(RiskObservation).order_by(RiskObservation.created_at.asc())
return list(self.db.scalars(stmt).all())
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
stmt = (
select(BudgetAllocation)
@@ -192,50 +213,49 @@ class FinanceDashboardService(BudgetSupportMixin):
) -> list[ExpenseClaim]:
return [claim for claim in claims if start <= self._claim_time(claim) < end]
def _observations_between(
self,
observations: list[RiskObservation],
start: datetime,
end: datetime,
) -> list[RiskObservation]:
return [item for item in observations if start <= self._as_utc(item.created_at) < end]
def _totals(
self,
claims: list[ExpenseClaim],
observations: list[RiskObservation],
now: datetime,
) -> dict[str, Any]:
active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}]
pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES]
success_claims = [claim for claim in active_claims if self._status(claim) in SUCCESS_STATUSES]
risk_claim_keys = {self._claim_key(claim) for claim in active_claims if self._has_claim_risk(claim)}
observation_keys = {
str(item.claim_no or item.subject_key or item.id).strip()
for item in observations
if str(item.status or "").strip().lower() != "false_positive"
}
sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at]
sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS)
clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim))
active_claims = [
claim for claim in claims if self._status(claim) not in {"draft", "deleted"}
]
spend_claims = [
claim for claim in active_claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
pending_payment_claims = [
claim for claim in spend_claims if self._status(claim) == "pending_payment"
]
paid_claims = [claim for claim in spend_claims if self._status(claim) == "paid"]
total_amount = sum((self._claim_amount(claim) for claim in spend_claims), Decimal("0.00"))
pending_payment_amount = sum(
(self._claim_amount(claim) for claim in pending_payment_claims),
Decimal("0.00"),
)
budget_summary = self._budget_summary(datetime.now(UTC).year)
avg_amount = (
total_amount / Decimal(str(len(spend_claims)))
if spend_claims
else Decimal("0.00")
)
return {
"pendingCount": len(pending_claims),
"pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))),
"avgSla": self._decimal_number(self._average(sla_hours)),
"autoPassRate": self._percent(clean_success, len(active_claims)),
"riskCount": len({key for key in risk_claim_keys | observation_keys if key}),
"slaRate": self._percent(sla_met, len(sla_hours)),
"reimbursementAmount": self._decimal_number(total_amount),
"reimbursementCount": len(spend_claims),
"pendingPaymentAmount": self._decimal_number(pending_payment_amount),
"avgClaimAmount": self._decimal_number(avg_amount),
"budgetUsageRate": float(budget_summary.get("ratio") or 0),
"paymentClearanceRate": self._percent(len(paid_claims), len(spend_claims)),
}
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
unit_by_key = {
"pendingCount": "",
"pendingAmount": "",
"avgSla": "h",
"autoPassRate": "%",
"riskCount": "",
"slaRate": "%",
"reimbursementAmount": "",
"reimbursementCount": "",
"pendingPaymentAmount": "",
"avgClaimAmount": "",
"budgetUsageRate": "%",
"paymentClearanceRate": "%",
}
meta: dict[str, Any] = {}
for key, current_value in current.items():
@@ -257,28 +277,34 @@ class FinanceDashboardService(BudgetSupportMixin):
claims: list[ExpenseClaim],
now: datetime,
) -> dict[str, Any]:
applications = [0 for _ in labels]
approved = [0 for _ in labels]
claim_count = [0 for _ in labels]
claim_amount = [Decimal("0.00") for _ in labels]
success_count = [0 for _ in labels]
hours: list[list[Decimal]] = [[] for _ in labels]
index = {label: idx for idx, label in enumerate(labels)}
for claim in claims:
if self._status(claim) == "draft":
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
label = self._date_label(self._claim_time(claim).date())
if label not in index:
continue
bucket = index[label]
applications[bucket] += 1
claim_count[bucket] += 1
claim_amount[bucket] += self._claim_amount(claim)
if self._status(claim) in SUCCESS_STATUSES:
approved[bucket] += 1
success_count[bucket] += 1
if claim.submitted_at:
hours[bucket].append(self._claim_sla_hours(claim, now))
return {
"labels": labels,
"applications": applications,
"approved": approved,
"claimCount": claim_count,
"claimAmount": [self._decimal_number(value) for value in claim_amount],
"successCount": success_count,
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
"applications": claim_count,
"approved": success_count,
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
}
@@ -287,79 +313,178 @@ class FinanceDashboardService(BudgetSupportMixin):
for claim in claims:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type)
buckets[str(label or "其他费用")] += self._claim_amount(claim)
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
rows = [
{"name": name, "value": self._decimal_number(value), "color": CHART_COLORS[index % len(CHART_COLORS)]}
for index, (name, value) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
{
"name": name,
"value": self._decimal_number(value),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, (name, value) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _exception_mix(
self,
claims: list[ExpenseClaim],
observations: list[RiskObservation],
) -> list[dict[str, Any]]:
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, int] = defaultdict(int)
for observation in observations:
key = str(observation.risk_signal or observation.risk_type or "").strip()
buckets[RISK_SIGNAL_LABELS.get(key, key.replace("_", " ") or "风险观察")] += 1
if not buckets:
for claim in claims:
if self._status(claim) in {"draft", "deleted"}:
continue
for label in self._claim_risk_labels(claim):
buckets[label] += 1
for claim in claims:
status = self._status(claim)
if status in {"draft", "deleted"}:
continue
buckets[self._finance_status_label(status)] += 1
rows = [
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
for index, (name, count) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
for index, (name, count) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
for claim in claims:
if self._status(claim) not in PENDING_STATUSES:
status = self._status(claim)
if status in EXCLUDED_SPEND_STATUSES:
continue
buckets[str(claim.department_name or "未归属部门")] += self._claim_amount(claim)
department_name = str(claim.department_name or "").strip()
if self._is_missing_finance_dimension(department_name):
continue
amount = self._claim_amount(claim)
buckets[department_name] += amount
counts[department_name] += 1
if status in PENDING_STATUSES:
pending_amounts[department_name] += amount
rows = [
{
"name": name,
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"pendingAmount": self._decimal_number(pending_amounts[name]),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, (name, amount) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:5])
for index, (name, amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows
def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]:
buckets: dict[str, list[Decimal]] = defaultdict(list)
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
departments: dict[str, str] = {}
for claim in claims:
if self._status(claim) not in PENDING_STATUSES:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
stage = self._stage_label(claim)
buckets[stage].append(self._claim_sla_hours(claim, now))
employee_name = str(claim.employee_name or "").strip()
if self._is_missing_finance_dimension(employee_name):
continue
amount = self._claim_amount(claim)
buckets[employee_name] += amount
counts[employee_name] += 1
departments.setdefault(employee_name, str(claim.department_name or "").strip())
rows: list[dict[str, Any]] = []
for index, (stage, values) in enumerate(sorted(buckets.items(), key=lambda item: self._average(item[1]), reverse=True)[:3]):
avg_hours = self._average(values)
rows.append(
{
"name": stage,
"role": "审批节点",
"duration": f"{self._decimal_number(avg_hours):.1f} h",
"status": self._duration_status(avg_hours),
"tone": self._duration_tone(avg_hours),
"avatar": stage[:1] or str(index + 1),
}
return [
{
"name": name,
"department": departments.get(name, ""),
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"avgAmount": self._decimal_number(amount / Decimal(str(counts[name]))),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, (name, amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
return rows
]
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
spend_claims = [
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
return [
{
"claimNo": claim.claim_no,
"employeeName": claim.employee_name,
"departmentName": self._display_finance_dimension(
claim.department_name,
fallback="未归属部门",
),
"expenseTypeLabel": self._expense_type_label(claim.expense_type),
"amount": self._decimal_number(self._claim_amount(claim)),
"amountLabel": self._currency(self._claim_amount(claim)),
"statusLabel": self._finance_status_label(self._status(claim)),
}
for claim in sorted(spend_claims, key=self._claim_amount, reverse=True)[:6]
]
def _bottlenecks(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
active_claims = [
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
pending_payment_claims = [
claim for claim in active_claims if self._status(claim) == "pending_payment"
]
paid_claims = [claim for claim in active_claims if self._status(claim) == "paid"]
submitted_claims = [
claim for claim in active_claims if self._status(claim) in PENDING_STATUSES
]
budget_rows = self._budget_focus_rows()
pending_payment_amount = sum(
(self._claim_amount(claim) for claim in pending_payment_claims),
Decimal("0.00"),
)
high_claim = max(
(self._claim_amount(claim) for claim in active_claims),
default=Decimal("0.00"),
)
payment_clearance = self._percent(len(paid_claims), len(active_claims))
rows = [
*budget_rows,
self._focus_item(
name="待付款",
role="资金计划",
duration=self._currency(pending_payment_amount),
status=f"{len(pending_payment_claims)}",
tone="warning" if pending_payment_claims else "success",
avatar="",
),
self._focus_item(
name="高额单据",
role="费用集中度",
duration=self._currency(high_claim),
status="本期最高",
tone="warning" if high_claim >= Decimal("10000") else "success",
avatar="",
),
self._focus_item(
name="待入账",
role="月结准备",
duration=f"{len(submitted_claims)}",
status="待流转" if submitted_claims else "已清理",
tone="warning" if submitted_claims else "success",
avatar="",
),
self._focus_item(
name="付款完成率",
role="付款执行",
duration=f"{payment_clearance:.1f}%",
status=f"{len(paid_claims)} 单已付",
tone="success" if payment_clearance >= 80 else "warning",
avatar="",
),
]
priority = {"danger": 0, "warning": 1, "success": 2}
return sorted(rows, key=lambda item: priority.get(str(item.get("tone")), 3))[:6]
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
allocations = self._fetch_budget_allocations(fiscal_year)
@@ -384,6 +509,149 @@ class FinanceDashboardService(BudgetSupportMixin):
"left": self._currency(available),
}
def _budget_metrics(self, fiscal_year: int) -> list[dict[str, Any]]:
allocations = self._fetch_budget_allocations(fiscal_year)
total = Decimal("0.00")
consumed = Decimal("0.00")
reserved = Decimal("0.00")
available = Decimal("0.00")
over_count = 0
warning_count = 0
for allocation in allocations:
balance = self.get_balance(allocation)
total += balance.total_amount
consumed += balance.consumed_amount
reserved += balance.reserved_amount
available += balance.available_amount
if balance.available_amount < Decimal("0.00"):
over_count += 1
continue
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
warning_count += 1
used = consumed + reserved
usage_rate = Decimal("0.00")
if total > Decimal("0.00"):
usage_rate = (used / total) * Decimal("100")
return [
self._budget_metric(
label="预算池数量",
value=f"{len(allocations)}",
detail="年度有效预算池",
tone="neutral",
icon="mdi mdi-database-outline",
),
self._budget_metric(
label="总预算",
value=self._currency(total),
detail="原始预算 + 调整",
tone="neutral",
icon="mdi mdi-cash-register",
),
self._budget_metric(
label="已用预算",
value=self._currency(used),
detail=f"使用率 {self._decimal_number(usage_rate):.1f}%",
tone="warning" if usage_rate >= Decimal("80") else "success",
icon="mdi mdi-chart-arc",
),
self._budget_metric(
label="预占预算",
value=self._currency(reserved),
detail="待流转单据占用",
tone="warning" if reserved > Decimal("0.00") else "success",
icon="mdi mdi-lock-outline",
),
self._budget_metric(
label="可用预算",
value=self._currency(available),
detail="可继续使用额度",
tone="danger" if available < Decimal("0.00") else "success",
icon="mdi mdi-wallet-outline",
),
self._budget_metric(
label="预警预算池",
value=f"{warning_count}",
detail=f"超支 {over_count}",
tone="danger" if over_count else "warning" if warning_count else "success",
icon="mdi mdi-alert-outline",
),
]
def _budget_metric(
self,
*,
label: str,
value: str,
detail: str,
tone: str,
icon: str,
) -> dict[str, Any]:
return {
"label": label,
"value": value,
"detail": detail,
"tone": tone,
"icon": icon,
}
def _budget_focus_rows(self) -> list[dict[str, Any]]:
allocations = self._fetch_budget_allocations(datetime.now(UTC).year)
over_count = 0
warning_count = 0
over_amount = Decimal("0.00")
warning_used = Decimal("0.00")
for allocation in allocations:
balance = self.get_balance(allocation)
if balance.available_amount < Decimal("0.00"):
over_count += 1
over_amount += abs(balance.available_amount)
continue
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
warning_count += 1
warning_used += balance.reserved_amount + balance.consumed_amount
return [
self._focus_item(
name="预算超支",
role="预算控制",
duration=f"{over_count} 个池",
status=self._currency(over_amount),
tone="danger" if over_count else "success",
avatar="",
),
self._focus_item(
name="预算预警",
role="预算控制",
duration=f"{warning_count} 个池",
status=self._currency(warning_used),
tone="warning" if warning_count else "success",
avatar="",
),
]
def _focus_item(
self,
*,
name: str,
role: str,
duration: str,
status: str,
tone: str,
avatar: str,
) -> dict[str, Any]:
return {
"name": name,
"role": role,
"duration": duration,
"status": status,
"tone": tone,
"avatar": avatar,
}
def _claim_time(self, claim: ExpenseClaim) -> datetime:
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
@@ -410,10 +678,14 @@ class FinanceDashboardService(BudgetSupportMixin):
labels.append("风险扫描命中")
for flag in self._risk_flags(claim):
if isinstance(flag, dict):
label = str(flag.get("label") or flag.get("message") or flag.get("type") or "").strip()
label = str(flag.get("label") or flag.get("message") or "").strip()
if not label:
label = self._risk_signal_label(
flag.get("type") or flag.get("risk_signal") or ""
)
else:
label = str(flag or "").strip()
labels.append(label or "规则异常")
label = self._risk_signal_label(flag)
labels.append(self._display_risk_label(label))
return labels
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
@@ -424,6 +696,70 @@ class FinanceDashboardService(BudgetSupportMixin):
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
def _finance_status_label(self, status: str) -> str:
labels = {
"submitted": "审批中",
"review": "审批中",
"pending_review": "审批中",
"manager_review": "审批中",
"budget_review": "审批中",
"finance_review": "审批中",
"approving": "审批中",
"approved": "已入账",
"pending_payment": "待付款",
"paid": "已付款",
"returned": "待补充",
"rejected": "已驳回",
}
return labels.get(str(status or "").strip().lower(), "其他")
def _expense_type_label(self, value: str | None) -> str:
raw = str(value or "").strip()
normalized = raw.lower().replace(" ", "_").replace("-", "_")
normalized = EXPENSE_TYPE_ALIASES.get(normalized, normalized)
if normalized.endswith("_application"):
normalized = normalized.removesuffix("_application")
return EXPENSE_TYPE_LABELS.get(normalized, "其他费用")
def _is_missing_finance_dimension(self, value: str | None) -> bool:
normalized = str(value or "").strip()
return not normalized or normalized in {
"待补充",
"待确认",
"未归属部门",
"未归属",
"N/A",
"n/a",
"-",
}
def _display_finance_dimension(self, value: str | None, *, fallback: str) -> str:
text = str(value or "").strip()
return fallback if self._is_missing_finance_dimension(text) else text
def _risk_signal_label(self, value: Any) -> str:
normalized = str(value or "").strip()
if not normalized:
return "风险观察"
key = normalized.lower().replace(" ", "_").replace("-", "_")
if key in RISK_SIGNAL_LABELS:
return RISK_SIGNAL_LABELS[key]
return self._display_risk_label(normalized)
def _display_risk_label(self, value: Any) -> str:
text = str(value or "").strip()
if not text:
return "风险观察"
key = text.lower().replace(" ", "_").replace("-", "_")
if key in RISK_SIGNAL_LABELS:
return RISK_SIGNAL_LABELS[key]
if self._contains_cjk(text):
return text
return "风险观察"
def _contains_cjk(self, value: str) -> bool:
return any("\u4e00" <= char <= "\u9fff" for char in value)
def _status(self, claim: ExpenseClaim) -> str:
return str(claim.status or "").strip().lower()