Files
X-Financial/server/src/app/services/expense_rule_runtime.py

1101 lines
44 KiB
Python
Raw Normal View History

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:
2026-05-18 02:53:06 +00:00
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,
2026-05-18 02:53:06 +00:00
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)