新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
1101 lines
44 KiB
Python
1101 lines
44 KiB
Python
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)
|