Files
X-Financial/server/src/app/services/expense_rule_runtime.py
caoxiaozhu 8f65661809 feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
2026-05-21 09:28:33 +08:00

1101 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
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)
DOCUMENT_TYPE_LABELS = {
"flight_itinerary": "机票/航班行程单",
"train_ticket": "火车/高铁票",
"hotel_invoice": "酒店住宿票据",
"taxi_receipt": "出租车/网约车票据",
"parking_toll_receipt": "停车/通行费票据",
"meal_receipt": "餐饮票据",
"office_invoice": "办公用品票据",
"meeting_invoice": "会议/会务票据",
"training_invoice": "培训票据",
"vat_invoice": "增值税发票",
"receipt": "一般收据/凭证",
"other": "其他单据",
}
SCENE_LABELS = {
"travel": "差旅",
"hotel": "住宿",
"transport": "交通",
"meal": "餐饮",
"entertainment": "业务招待",
"office": "办公",
"meeting": "会务",
"training": "培训",
"communication": "通讯",
"welfare": "福利",
"other": "其他",
}
DEFAULT_SCENE_RULE_ASSET_CODE = "rule.expense.scene_submission_standard"
DEFAULT_TRAVEL_RULE_ASSET_CODE = "rule.expense.travel_risk_control_standard"
DEFAULT_SCENE_MATRIX_CONFIG: dict[str, Any] = {
"kind": "scene_matrix",
"version": 1,
"scenes": {
"travel": {
"label": "差旅费",
"location_required": True,
"min_attachment_count": 1,
"allowed_scene_codes": ["travel"],
"allowed_document_types": ["flight_itinerary", "train_ticket"],
"attachment_mismatch_severity": "high",
},
"hotel": {
"label": "住宿费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["hotel"],
"allowed_document_types": ["hotel_invoice", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
},
"transport": {
"label": "交通费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["transport"],
"allowed_document_types": ["taxi_receipt", "parking_toll_receipt", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
"item_amount_limit": {
"scope": "item_amount",
"warn_amount": "300.00",
"block_amount": "800.00",
"exception_keywords": ["跨城", "夜间", "应急", "无公共交通", "机场", "火车站", "超标说明"],
"metric_label": "单笔交通金额",
},
},
"meal": {
"label": "餐费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["meal"],
"allowed_document_types": ["meal_receipt", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "300.00",
"block_amount": "800.00",
"exception_keywords": ["客户接待", "团队活动", "加班", "展会", "超标说明"],
"metric_label": "餐费合计",
},
},
"entertainment": {
"label": "业务招待费",
"location_required": True,
"min_attachment_count": 1,
"allowed_scene_codes": ["meal"],
"allowed_document_types": ["meal_receipt", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "2000.00",
"block_amount": "5000.00",
"exception_keywords": ["重要客户", "商务宴请", "项目签约", "超标说明"],
"metric_label": "招待费合计",
},
},
"office": {
"label": "办公费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["office"],
"allowed_document_types": ["office_invoice", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "1500.00",
"block_amount": "5000.00",
"exception_keywords": ["批量采购", "固定资产", "部门集中采购", "超标说明"],
"metric_label": "办公费合计",
},
},
"meeting": {
"label": "会务费",
"location_required": True,
"min_attachment_count": 1,
"allowed_scene_codes": ["meeting"],
"allowed_document_types": ["meeting_invoice", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "5000.00",
"block_amount": "30000.00",
"exception_keywords": ["大型会议", "外部场地", "超标说明"],
"metric_label": "会务费合计",
},
},
"training": {
"label": "培训费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["training"],
"allowed_document_types": ["training_invoice", "vat_invoice", "receipt"],
"attachment_mismatch_severity": "high",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "3000.00",
"block_amount": "15000.00",
"exception_keywords": ["认证考试", "外部培训", "超标说明"],
"metric_label": "培训费合计",
},
},
"communication": {
"label": "通讯费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["other"],
"allowed_document_types": ["vat_invoice", "receipt"],
"attachment_mismatch_severity": "medium",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "300.00",
"block_amount": "1000.00",
"exception_keywords": ["国际漫游", "专项通信", "超标说明"],
"metric_label": "通讯费合计",
},
},
"welfare": {
"label": "福利费",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["other"],
"allowed_document_types": ["vat_invoice", "receipt"],
"attachment_mismatch_severity": "medium",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "1000.00",
"block_amount": "5000.00",
"exception_keywords": ["节日福利", "团队活动", "员工关怀", "超标说明"],
"metric_label": "福利费合计",
},
},
"other": {
"label": "其他费用",
"location_required": False,
"min_attachment_count": 1,
"allowed_scene_codes": ["other"],
"allowed_document_types": ["vat_invoice", "receipt"],
"attachment_mismatch_severity": "medium",
"always_warn": True,
"always_warn_message": "其他费用默认进入人工重点复核,请补充清晰用途说明并由审批人重点确认。",
"claim_amount_limit": {
"scope": "claim_total",
"warn_amount": "1000.00",
"block_amount": "3000.00",
"exception_keywords": ["特殊事项", "临时采购", "超标说明"],
"metric_label": "其他费用合计",
},
},
},
}
DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
"kind": "travel_policy",
"version": 1,
"relevant_expense_types": ["travel", "hotel", "transport"],
"long_distance_document_types": ["flight_itinerary", "train_ticket"],
"route_exception_keywords": [
"中转",
"转机",
"经停",
"改签",
"多地出差",
"多城市",
"多站",
"异地返程",
"异地结束",
"临时变更",
"继续前往",
"第二站",
],
"standard_exception_keywords": [
"超标说明",
"无直达",
"展会高峰",
"会议高峰",
"协议酒店满房",
"客户指定",
"临时改签",
"行程变更",
"红眼航班",
"晚到店",
],
"band_labels": {
"junior": "P1-P3",
"mid": "P4-P5",
"senior": "P6-P7",
"manager": "M1-M2",
"executive": "M3及以上 / D序列",
},
"city_tiers": {
"北京": "tier_1",
"上海": "tier_1",
"广州": "tier_1",
"深圳": "tier_1",
"杭州": "tier_2",
"南京": "tier_2",
"苏州": "tier_2",
"武汉": "tier_2",
"成都": "tier_2",
"重庆": "tier_2",
"西安": "tier_2",
"天津": "tier_2",
"宁波": "tier_2",
"厦门": "tier_2",
"青岛": "tier_2",
"长沙": "tier_2",
"郑州": "tier_2",
"合肥": "tier_2",
"济南": "tier_2",
"沈阳": "tier_2",
"大连": "tier_2",
"福州": "tier_2",
"昆明": "tier_2",
"海口": "tier_2",
"三亚": "tier_2",
"无锡": "tier_2",
"东莞": "tier_2",
"佛山": "tier_2",
},
"hotel_limits": {
"junior": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"mid": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
"senior": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"},
"manager": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"},
"executive": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"},
},
"transport_limits": {
"junior": {"flight": 1, "train": 1},
"mid": {"flight": 1, "train": 1},
"senior": {"flight": 2, "train": 2},
"manager": {"flight": 3, "train": 3},
"executive": {"flight": 4, "train": 3},
},
"flight_classes": [
{"keyword": "头等舱", "level": 4},
{"keyword": "公务舱", "level": 3},
{"keyword": "商务舱", "level": 3},
{"keyword": "超级经济舱", "level": 2},
{"keyword": "高端经济舱", "level": 2},
{"keyword": "明珠经济舱", "level": 2},
{"keyword": "经济舱", "level": 1},
],
"train_classes": [
{"keyword": "商务座", "level": 3},
{"keyword": "一等座", "level": 2},
{"keyword": "软卧", "level": 2},
{"keyword": "二等座", "level": 1},
{"keyword": "二等卧", "level": 1},
{"keyword": "硬卧", "level": 1},
],
}
class AmountLimitConfig(BaseModel):
scope: Literal["claim_total", "item_amount"] = "claim_total"
warn_amount: Decimal | None = None
block_amount: Decimal | None = None
exception_keywords: list[str] = Field(default_factory=list)
metric_label: str = "金额"
class ScenePolicyConfig(BaseModel):
label: str
location_required: bool = False
min_attachment_count: int = 1
allowed_scene_codes: list[str] = Field(default_factory=list)
allowed_document_types: list[str] = Field(default_factory=list)
attachment_mismatch_severity: Literal["low", "medium", "high"] = "high"
claim_amount_limit: AmountLimitConfig | None = None
item_amount_limit: AmountLimitConfig | None = None
always_warn: bool = False
always_warn_message: str = ""
class SceneMatrixRuleConfig(BaseModel):
kind: Literal["scene_matrix"]
version: int = 1
scenes: dict[str, ScenePolicyConfig]
class TravelClassConfig(BaseModel):
keyword: str
level: int
class TravelPolicyConfig(BaseModel):
kind: Literal["travel_policy"]
version: int = 1
relevant_expense_types: list[str] = Field(default_factory=list)
long_distance_document_types: list[str] = Field(default_factory=list)
route_exception_keywords: list[str] = Field(default_factory=list)
standard_exception_keywords: list[str] = Field(default_factory=list)
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)
class ExpenseScenePolicy(ScenePolicyConfig):
expense_type: str
rule_code: str
rule_name: str
rule_version: str
class RuntimeTravelPolicy(TravelPolicyConfig):
rule_code: str
rule_name: str
rule_version: str
@dataclass
class ExpenseRuleCatalog:
scene_policies: dict[str, ExpenseScenePolicy] = field(default_factory=dict)
travel_policy: RuntimeTravelPolicy | None = None
def get_scene_policy(self, expense_type: str | None) -> ExpenseScenePolicy | None:
normalized = str(expense_type or "").strip().lower() or "other"
return self.scene_policies.get(normalized)
def resolve_document_type_label(document_type: str | None) -> str:
normalized = str(document_type or "").strip().lower() or "other"
return DOCUMENT_TYPE_LABELS.get(normalized, normalized or "其他单据")
def build_default_expense_rule_catalog() -> ExpenseRuleCatalog:
catalog = ExpenseRuleCatalog()
scene_matrix = SceneMatrixRuleConfig.model_validate(DEFAULT_SCENE_MATRIX_CONFIG)
for expense_type, config in scene_matrix.scenes.items():
catalog.scene_policies[expense_type] = ExpenseScenePolicy(
expense_type=expense_type,
rule_code=DEFAULT_SCENE_RULE_ASSET_CODE,
rule_name="报销场景提交与附件标准",
rule_version="v1.0.0",
**config.model_dump(),
)
travel_policy = TravelPolicyConfig.model_validate(DEFAULT_TRAVEL_POLICY_CONFIG)
catalog.travel_policy = RuntimeTravelPolicy(
rule_code=DEFAULT_TRAVEL_RULE_ASSET_CODE,
rule_name="差旅报销风险管控制度",
rule_version="v1.1.0",
**travel_policy.model_dump(),
)
return catalog
def build_scene_submission_standard_markdown() -> str:
scene_matrix = SceneMatrixRuleConfig.model_validate(DEFAULT_SCENE_MATRIX_CONFIG)
sections: list[str] = [
"# 报销场景提交与附件标准",
"",
"## 模板信息",
"",
"- 模板类型:系统内置场景矩阵规则",
"- 运行时类型:`scene_matrix`",
"- 适用对象:报销提交与附件校验",
"",
"## 目标",
"",
"统一约束各报销场景的必填字段、附件类型和金额预警口径,在上传附件和提交审核两个时点直接输出可执行风险判断。",
"",
"## 适用范围",
"",
"适用于差旅、住宿、交通、餐费、业务招待、办公、会务、培训、通讯、福利和其他费用场景。",
"",
"## 输入字段",
"",
"- expense_type",
"- attachments",
"- location",
"- amount / item_amount",
"- reason",
"",
"## 判断规则",
"",
]
for index, (expense_type, config) in enumerate(scene_matrix.scenes.items(), start=1):
expected_document_labels = "".join(
resolve_document_type_label(item) for item in config.allowed_document_types
)
expected_scene_labels = "".join(
SCENE_LABELS.get(item, item) for item in config.allowed_scene_codes
)
sections.extend(
[
f"### 规则 {index} {config.label}`{expense_type}`",
"",
f"- 业务地点:{'必填' if config.location_required else '非必填'}",
f"- 最少附件数:{config.min_attachment_count}",
f"- 允许识别场景:{expected_scene_labels or '不限制'}",
f"- 允许附件类型:{expected_document_labels or '不限制'}",
f"- 附件不匹配处理:{config.attachment_mismatch_severity.upper()}",
]
)
if config.claim_amount_limit is not None:
sections.append(
f"- 合计金额阈值:预警 {config.claim_amount_limit.warn_amount or '-'} 元,"
f"拦截 {config.claim_amount_limit.block_amount or '-'}"
)
if config.item_amount_limit is not None:
sections.append(
f"- 单笔金额阈值:预警 {config.item_amount_limit.warn_amount or '-'} 元,"
f"拦截 {config.item_amount_limit.block_amount or '-'}"
)
if config.always_warn and config.always_warn_message:
sections.append(f"- 特殊处理:{config.always_warn_message}")
sections.append("")
sections.extend(
[
"## 输出",
"",
"- 命中高风险时退回待补充。",
"- 命中中风险时继续流转,并提示审批人重点复核。",
"- 命中 always_warn 场景时追加人工重点复核提示。",
"",
"## 来源依据",
"",
"- 公司报销制度中关于场景识别、附件要求、金额阈值和人工复核的统一口径。",
"",
"## 审核约束",
"",
"- 当前规则为系统内置真实运行规则,变更后需重新审核并评估回滚影响。",
"- 规则 JSON 与 Markdown 说明必须保持一致。",
"",
"## 管理员备注",
"",
"如后续制度调整附件类型、金额阈值或人工复核口径,应优先修改运行时 JSON 并同步更新说明。",
"",
"```expense-rule",
json.dumps(DEFAULT_SCENE_MATRIX_CONFIG, ensure_ascii=False, indent=2),
"```",
]
)
return "\n".join(sections)
def build_travel_risk_control_standard_markdown() -> str:
return "\n".join(
[
"# 差旅报销风险管控制度",
"",
"## 模板信息",
"",
"- 模板键:`travel_standard_v1`",
"- 运行时类型:`travel_policy`",
"- 适用对象:差旅、住宿、交通相关报销审核",
"",
"## 目标",
"",
"校验差旅行程闭环、酒店地点一致性、住宿标准、飞机舱位和火车席别是否符合制度,并对例外情况保留人工复核入口。",
"",
"## 适用范围",
"",
"适用于差旅费、住宿费和交通费相关报销单,重点覆盖跨城市出差、改签、中转和超标说明场景。",
"",
"## 输入字段",
"",
"- expense_type",
"- attachments / OCR routes",
"- location",
"- employee_grade",
"- reason",
"",
"## 判断规则",
"",
"- 两段及以上长途交通票据必须首尾衔接。",
"- 最终终点应与申报目的地一致,或返回首段出发城市。",
"- 检测到多城市行程但无说明时,按高风险退回待补充。",
"- 酒店城市必须落在目的地或交通链路停留城市中。",
"- 住宿标准、飞机舱位和火车席别按职级与城市分级执行。",
"- 超标但有说明时记为中风险;超标且无说明时记为高风险。",
"",
"## 输出",
"",
"- 行程异常时输出高风险退回。",
"- 差标超限但有合理说明时输出中风险提醒。",
"- 命中差旅制度规则时,保留 `rule_code` 和 `rule_version` 供审批链追踪。",
"",
"## 来源依据",
"",
"- 公司差旅制度关于行程闭环、酒店地点一致性、职级差标和例外说明的规定。",
"",
"## 审核约束",
"",
"- 当前规则为系统内置真实运行规则,修改前需确认差旅制度版本与灰度回滚方案。",
"- 规则 JSON 与 Markdown 说明必须保持一致。",
"",
"## 管理员备注",
"",
"如制度调整职级带、城市分级或交通等级,应先更新运行时 JSON再同步修改本说明。",
"",
"```expense-rule",
json.dumps(DEFAULT_TRAVEL_POLICY_CONFIG, ensure_ascii=False, indent=2),
"```",
]
)
class ExpenseRuleRuntimeService:
def __init__(self, db: Session) -> None:
self.db = db
def load_catalog(self) -> ExpenseRuleCatalog:
catalog = build_default_expense_rule_catalog()
assets = list(
self.db.scalars(
select(AgentAsset)
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
.where(AgentAsset.status == AgentAssetStatus.ACTIVE.value)
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
).all()
)
if not assets:
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,
runtime_payload=runtime_payload,
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
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
published_version = str(asset.published_version or asset.current_version or "").strip()
if not published_version:
return None
return self.db.scalar(
select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset.id,
AgentAssetVersion.version == published_version,
)
)
@staticmethod
def _extract_runtime_payload(
*,
markdown_content: str,
config_json: dict[str, Any] | None,
) -> dict[str, Any] | None:
match = EXPENSE_RULE_CODE_BLOCK_PATTERN.search(str(markdown_content or ""))
if match is not None:
try:
payload = json.loads(match.group(1))
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
return payload
runtime_payload = (config_json or {}).get("runtime_rule")
return runtime_payload if isinstance(runtime_payload, dict) else None
def _apply_runtime_payload(
self,
catalog: ExpenseRuleCatalog,
*,
runtime_payload: dict[str, Any],
asset: AgentAsset,
version: AgentAssetVersion,
) -> None:
kind = str(runtime_payload.get("kind") or "").strip().lower()
try:
if kind == "scene_matrix":
config = SceneMatrixRuleConfig.model_validate(runtime_payload)
for expense_type, scene_config in config.scenes.items():
catalog.scene_policies[expense_type] = ExpenseScenePolicy(
expense_type=expense_type,
rule_code=asset.code,
rule_name=asset.name,
rule_version=version.version,
**scene_config.model_dump(),
)
return
if kind == "travel_policy":
config = TravelPolicyConfig.model_validate(runtime_payload)
catalog.travel_policy = RuntimeTravelPolicy(
rule_code=asset.code,
rule_name=asset.name,
rule_version=version.version,
**config.model_dump(),
)
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)