feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user