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)