feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any, Literal
from openpyxl import load_workbook
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.services.agent_asset_spreadsheet import (
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
AgentAssetSpreadsheetManager,
)
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
band_labels: dict[str, str] = Field(default_factory=dict)
city_tiers: dict[str, str] = Field(default_factory=dict)
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
standard_rule_code: str = ""
standard_rule_name: str = ""
standard_rule_version: str = ""
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
train_classes: list[TravelClassConfig] = Field(default_factory=list)
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
).all()
)
if not assets:
return catalog
assets = []
asset_ids = {asset.id for asset in assets}
travel_spreadsheet_asset = self.db.scalar(
select(AgentAsset)
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
.limit(1)
)
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
assets.append(travel_spreadsheet_asset)
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
for asset in assets:
version = self._get_current_version(asset)
if version is None:
continue
is_travel_spreadsheet_asset = (
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
)
runtime_payload = self._extract_runtime_payload(
markdown_content=str(version.content or ""),
config_json=asset.config_json,
)
if not isinstance(runtime_payload, dict):
spreadsheet_assets.append((asset, version))
continue
self._apply_runtime_payload(
catalog,
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
asset=asset,
version=version,
)
if is_travel_spreadsheet_asset:
spreadsheet_assets.append((asset, version))
for asset, version in spreadsheet_assets:
self._apply_spreadsheet_runtime_payload(
catalog,
asset=asset,
version=version,
)
return catalog
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
)
except ValidationError:
return
def _apply_spreadsheet_runtime_payload(
self,
catalog: ExpenseRuleCatalog,
*,
asset: AgentAsset,
version: AgentAssetVersion,
) -> None:
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
return
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
return
manager = AgentAssetSpreadsheetManager()
metadata = manager.parse_version_markdown(str(version.content or ""))
rule_document = (asset.config_json or {}).get("rule_document")
if not isinstance(rule_document, dict):
rule_document = {}
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
if storage_key:
try:
workbook_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
workbook_path = None
if workbook_path is not None and not workbook_path.exists():
workbook_path = None
else:
workbook_path = None
if workbook_path is None:
fallback_storage_key = str(rule_document.get("storage_key") or "").strip()
if not fallback_storage_key:
return
try:
workbook_path = manager.resolve_storage_path(fallback_storage_key)
except FileNotFoundError:
return
if not workbook_path.exists():
return
try:
workbook = load_workbook(
workbook_path,
read_only=True,
data_only=True,
)
except (FileNotFoundError, OSError):
return
try:
standards = self._extract_travel_amount_standards_from_workbook(workbook)
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
finally:
workbook.close()
standard_rule_version = str(
rule_document.get("asset_version") or asset.current_version or version.version
).strip()
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
payload = catalog.travel_policy.model_dump()
payload["standard_rule_code"] = asset.code
payload["standard_rule_name"] = asset.name
payload["standard_rule_version"] = standard_rule_version
if hotel_city_limits:
payload["hotel_city_limits"] = {
**payload.get("hotel_city_limits", {}),
**hotel_city_limits,
}
if allowance_limits:
payload["allowance_limits"] = {
**payload.get("allowance_limits", {}),
**allowance_limits,
}
if transport_limits:
payload["transport_limits"] = {
**payload.get("transport_limits", {}),
**transport_limits,
}
catalog.travel_policy = RuntimeTravelPolicy(**payload)
for expense_type, amount in standards.items():
current = catalog.scene_policies.get(expense_type)
if current is None:
continue
limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit"
base_limit = getattr(current, limit_attr, None)
next_limit = self._replace_amount_limit_warn_amount(
base_limit,
amount=amount,
metric_label=self._spreadsheet_metric_label(expense_type),
)
payload = current.model_dump()
payload["rule_code"] = asset.code
payload["rule_name"] = asset.name
payload["rule_version"] = standard_rule_version
payload[limit_attr] = next_limit.model_dump()
catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload)
@staticmethod
def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]:
standards: dict[str, Decimal] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
category_index = -1
standard_index = -1
for index, row in enumerate(rows[:8]):
values = [str(value or "").strip() for value in row]
if "费用分类" in values and "报销标准" in values:
header_index = index
category_index = values.index("费用分类")
standard_index = values.index("报销标准")
break
if header_index < 0:
continue
for row in rows[header_index + 1 :]:
category = str(row[category_index] or "").strip() if len(row) > category_index else ""
standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else ""
amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text)
if not category or amount is None:
continue
normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category)
if normalized_type:
standards[normalized_type] = amount
return standards
@staticmethod
def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
city_limits: dict[str, dict[str, Decimal]] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
city_index = -1
band_indexes: dict[str, int] = {}
for index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
for candidate in ("地区(城市)", "城市", "地区"):
if candidate in values:
city_index = values.index(candidate)
break
if city_index < 0:
continue
for column_index, header in enumerate(values):
compact = re.sub(r"\s+", "", header)
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
band_indexes["junior"] = column_index
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
band_indexes["mid"] = column_index
band_indexes["senior"] = column_index
if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")):
band_indexes["manager"] = column_index
band_indexes["executive"] = column_index
if band_indexes:
header_index = index
break
if header_index < 0:
continue
for row in rows[header_index + 1 :]:
raw_city = str(row[city_index] or "").strip() if len(row) > city_index else ""
cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city)
if not cities:
continue
for city in cities:
city_entry = city_limits.setdefault(city, {})
for band, column_index in band_indexes.items():
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[column_index] if len(row) > column_index else None
)
if amount is not None:
city_entry[band] = amount
return city_limits
@staticmethod
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
allowance_limits: dict[str, dict[str, Decimal]] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
type_index = -1
region_indexes: dict[str, int] = {}
for index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
if "补助类型" not in values:
continue
header_index = index
type_index = values.index("补助类型")
for column_index, header in enumerate(values):
if column_index <= type_index:
continue
normalized = str(header or "").strip()
if not normalized or normalized == "项目":
continue
region_indexes[normalized] = column_index
break
if header_index < 0 or type_index < 0 or not region_indexes:
continue
for row in rows[header_index + 1 :]:
raw_type = str(row[type_index] or "").strip() if len(row) > type_index else ""
allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type)
if not allowance_key:
continue
entry: dict[str, Decimal] = {}
for region_label, column_index in region_indexes.items():
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[column_index] if len(row) > column_index else None
)
if amount is not None:
entry[region_label] = amount
if entry:
allowance_limits[allowance_key] = entry
return allowance_limits
@staticmethod
def _map_allowance_type_to_key(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if "伙食" in normalized or "" in normalized:
return "meal"
if "基本" in normalized:
return "basic"
if "合计" in normalized or "总计" in normalized:
return "total"
return ""
@staticmethod
def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]:
limits: dict[str, dict[str, int]] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
employee_index = -1
flight_index = -1
train_index = -1
for row_index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
if "员工职级" in values:
employee_index = values.index("员工职级")
for next_row in rows[row_index + 1 : row_index + 4]:
next_values = [str(value or "").strip() for value in next_row]
if "飞机" in next_values:
flight_index = next_values.index("飞机")
if "火车" in next_values:
train_index = next_values.index("火车")
if flight_index >= 0 and train_index >= 0:
break
break
if employee_index < 0 or (flight_index < 0 and train_index < 0):
continue
for row in rows:
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
if not bands:
continue
flight_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text(
row[flight_index] if len(row) > flight_index else None,
kind="flight",
)
if flight_index >= 0
else None
)
train_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text(
row[train_index] if len(row) > train_index else None,
kind="train",
)
if train_index >= 0
else None
)
for band in bands:
entry = limits.setdefault(band, {})
if flight_level is not None:
entry["flight"] = flight_level
if train_level is not None:
entry["train"] = train_level
return limits
@staticmethod
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
normalized = re.sub(r"\s+", "", str(value or "").upper())
if not normalized or normalized.startswith(""):
return []
bands: list[str] = []
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
bands.extend(["junior", "mid"])
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
bands.extend(["mid", "senior", "manager", "executive"])
return list(dict.fromkeys(bands))
@staticmethod
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
normalized = re.sub(r"\s+", "", str(value or ""))
if not normalized:
return None
if kind == "flight":
if any(keyword in normalized for keyword in ("头等舱",)):
return 4
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
return 3
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
return 2
if "经济舱" in normalized:
return 1
if kind == "train":
if "商务座" in normalized:
return 3
if any(keyword in normalized for keyword in ("一等座", "软卧")):
return 2
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
return 1
return None
@staticmethod
def _extract_city_names_from_cell(value: str) -> list[str]:
normalized = re.sub(r"[;,、/]+", "", str(value or "").strip())
if not normalized:
return []
names: list[str] = []
for part in normalized.split(""):
cleaned = re.sub(r"\s+", "", part)
cleaned = re.sub(r"[(].*?[)]", "", cleaned)
if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")):
continue
if len(cleaned) <= 12:
names.append(cleaned)
return list(dict.fromkeys(names))
@staticmethod
def _coerce_decimal_cell(value: Any) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
except (ArithmeticError, ValueError):
return None
@staticmethod
def _extract_first_standard_amount(text: str) -> Decimal | None:
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or ""))
if match is None:
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or ""))
if match is None:
return None
try:
return Decimal(match.group(1)).quantize(Decimal("0.01"))
except (ArithmeticError, ValueError):
return None
@staticmethod
def _map_spreadsheet_category_to_expense_type(category: str) -> str:
normalized = re.sub(r"\s+", "", str(category or ""))
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
return "transport"
if "招待" in normalized and "" in normalized:
return "entertainment"
if "餐补" in normalized or normalized == "餐费":
return "meal"
return ""
@staticmethod
def _spreadsheet_metric_label(expense_type: str) -> str:
return {
"transport": "单笔交通金额",
"meal": "差旅餐补金额",
"entertainment": "人均招待餐费",
}.get(expense_type, "金额")
@staticmethod
def _replace_amount_limit_warn_amount(
base_limit: AmountLimitConfig | None,
*,
amount: Decimal,
metric_label: str,
) -> AmountLimitConfig:
if base_limit is None:
return AmountLimitConfig(
warn_amount=amount,
block_amount=None,
metric_label=metric_label,
)
payload = base_limit.model_dump()
payload["warn_amount"] = amount
payload["metric_label"] = metric_label
return AmountLimitConfig(**payload)