Files
X-Financial/server/src/app/services/agent_foundation_spreadsheets.py
caoxiaozhu 9f7b8b46a3 Refine travel reimbursement steward flow
Align planner, runtime rules, and policy assets so travel guidance
matches the updated reimbursement workflow.
2026-06-15 22:55:18 +08:00

799 lines
25 KiB
Python

from __future__ import annotations
from pathlib import Path
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetType,
AgentReviewStatus,
AgentAssetStatus,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager,
)
from app.services.agent_foundation_constants import (
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
COMPANY_PREAPPROVAL_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
)
from app.services.finance_rule_catalog import (
DEPRECATED_FINANCE_RULE_CODES,
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
)
from app.services.agent_foundation_preapproval_spreadsheet import (
build_preapproval_rule_workbook_sheets,
)
logger = get_logger("app.services.agent_foundation")
class AgentFoundationSpreadsheetMixin:
def sync_finance_rule_assets_from_catalog(self) -> int:
synced_count = self._ensure_core_finance_rule_asset_metadata()
self._hide_deprecated_finance_rule_assets()
self.db.flush()
return synced_count
def _ensure_core_finance_rule_asset_metadata(self) -> int:
synced_count = 0
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="差旅住宿报销标准",
description="按地区和职级维护差旅住宿费报销上限。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅住宿费标准",
expense_types=["hotel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_lodging_rule_template(),
rule_template_label="差旅住宿 Excel 模板",
travel_policy_component="lodging",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
name="出差补助报销标准",
description="按地区维护伙食补助、基本出差补贴和补助合计。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="出差补助标准",
expense_types=["travel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_allowance_rule_template(),
rule_template_label="出差补助 Excel 模板",
travel_policy_component="allowance",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
name="交通工具等级标准",
description="按员工职级维护飞机、火车等长途交通工具等级。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="交通工具等级标准",
expense_types=["travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_rule_template(),
rule_template_label="交通工具等级 Excel 模板",
travel_policy_component="transport",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
name="交通费用预估表",
description="按出发城市、目的地和交通方式维护申请阶段预算占用的交通费用预估金额。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="交通费用预估表",
expense_types=["travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_estimate_rule_template(),
rule_template_label="交通费用预估 Excel 模板",
travel_policy_component="transport_estimate",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
name="差旅职级映射表",
description="明确 P0-P8 九级职级与住宿、交通规则列之间的对应关系,其中 P8 为董事会。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅职级映射表",
expense_types=["hotel", "travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_grade_mapping_template(),
rule_template_label="差旅职级映射 Excel 模板",
travel_policy_component="grade_mapping",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
name="地区淡旺季映射表",
description="明确住宿标准中旺季地区、旺季月份和旺季超标限额的对应关系。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="地区淡旺季映射表",
expense_types=["hotel", "travel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_season_mapping_template(),
rule_template_label="地区淡旺季映射 Excel 模板",
travel_policy_component="season_mapping",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
finance_rule_sheet="通信费报销标准",
expense_types=["communication"],
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_company_communication_rule_template(),
rule_template_label="通信费报销 Excel 模板",
finance_rule_code="expense.communication.policy",
refresh_workbook_content=True,
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="费用申请审批规则",
expense_types=["meal", "entertainment", "office", "all"],
version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣",
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
workbook_content=None,
rule_template_label="费用申请审批 Excel 模板",
finance_rule_code="expense.preapproval.policy",
tag="申请规则",
)
)
return synced_count
def _ensure_core_finance_rule_asset(
self,
*,
code: str,
name: str,
description: str,
scenario_category: str,
finance_rule_sheet: str,
expense_types: list[str],
version: str,
reviewer: str,
file_name: str,
workbook_content: bytes | None,
rule_template_label: str,
finance_rule_code: str | None = None,
travel_policy_component: str = "",
tag: str = "基础规则",
refresh_workbook_content: bool = False,
) -> bool:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
created_asset = asset is None
if asset is None:
asset = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=code,
name=name,
description=description,
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=[scenario_category],
owner="财务制度管理组",
reviewer=reviewer,
status=AgentAssetStatus.ACTIVE.value,
current_version=version,
config_json={
"severity": "medium",
"enabled": True,
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": rule_template_label,
},
)
else:
asset.name = name
asset.description = description
asset.owner = asset.owner or "财务制度管理组"
asset.reviewer = asset.reviewer or reviewer
if not str(asset.current_version or "").strip():
asset.current_version = version
if not str(asset.working_version or "").strip():
asset.working_version = asset.current_version
if not str(asset.published_version or "").strip():
asset.published_version = asset.current_version
if not str(asset.status or "").strip() or asset.status == AgentAssetStatus.DISABLED.value:
asset.status = AgentAssetStatus.ACTIVE.value
asset.scenario_json = [scenario_category]
config_json = {
**(asset.config_json or {}),
"enabled": True,
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": finance_rule_code or code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": rule_template_label,
}
if travel_policy_component:
config_json["travel_policy_component"] = travel_policy_component
asset.config_json = config_json
rule_document = (asset.config_json or {}).get("rule_document")
has_rule_document = isinstance(rule_document, dict) and bool(
str(rule_document.get("storage_key") or "").strip()
)
if workbook_content is not None and (
created_asset or not has_rule_document or refresh_workbook_content
):
self._ensure_finance_rule_asset_document(
asset,
version=version,
reviewer=reviewer,
file_name=file_name,
content=workbook_content,
force_live_document=refresh_workbook_content,
)
return True
def _ensure_finance_rule_asset_document(
self,
asset: AgentAsset,
*,
version: str,
reviewer: str,
file_name: str,
content: bytes,
force_live_document: bool = False,
) -> None:
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
rule_document = (asset.config_json or {}).get("rule_document")
storage_key = (
str(rule_document.get("storage_key") or "").strip()
if isinstance(rule_document, dict)
else ""
)
should_seed_file = force_live_document or not storage_key
if storage_key:
try:
current_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
current_path = None
should_seed_file = should_seed_file or current_path is None or not current_path.exists()
if should_seed_file:
metadata = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=file_name,
content=content,
actor_name="系统初始化",
source="rule-library",
)
asset.config_json = {
**(asset.config_json or {}),
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
metadata,
asset_version=version,
),
"storage_key": metadata.storage_key,
},
}
else:
metadata = manager.store_rule_library_spreadsheet_snapshot(
library=FINANCE_RULES_LIBRARY,
asset_id=asset.id,
version=version,
file_name=file_name,
content=content,
actor_name="系统初始化",
source="rule-library-version",
)
self._ensure_asset_version(
asset,
version=version,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=asset.name,
version=version,
metadata=metadata,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note=f"初始化{asset.name} Excel 规则表。",
created_by="系统初始化",
)
self._ensure_asset_review(
asset,
version=version,
reviewer=reviewer,
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=None,
)
def _hide_deprecated_finance_rule_assets(self) -> None:
for code in DEPRECATED_FINANCE_RULE_CODES:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
if asset is None:
continue
asset.status = AgentAssetStatus.DISABLED.value
asset.scenario_json = ["已废弃"]
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
deprecated_reason = (
"交通/住宿细分并入公司差旅费报销规则,不再作为独立基础规则展示。"
)
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
deprecated_reason = (
"申请审批阈值已并入公司费用申请审批规则,不再作为独立基础规则展示。"
)
else:
deprecated_reason = (
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
"不再作为独立基础规则表展示。"
)
asset.config_json = {
**(asset.config_json or {}),
"enabled": False,
"tag": "废弃规则",
"deprecated": True,
"deprecated_reason": deprecated_reason,
}
if replacement:
asset.config_json["replaced_by"] = replacement
def _ensure_company_travel_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
live_document = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
content=self._read_or_build_company_travel_rule_file(manager),
actor_name=actor_name,
source="rule-library",
)
existing_document = (
asset.config_json.get("rule_document")
if isinstance(asset.config_json, dict)
else None
)
storage_key = (
str(existing_document.get("storage_key") or "").strip()
if isinstance(existing_document, dict)
else ""
)
if storage_key:
try:
existing_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
existing_path = None
if existing_path is not None and existing_path.exists():
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return live_document
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return live_document
def _ensure_company_communication_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
return self._ensure_finance_rule_spreadsheet_seed(
asset,
version=version,
actor_name=actor_name,
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
fallback_sheet_name="通信费报销规则",
)
def _ensure_company_preapproval_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
return self._ensure_finance_rule_spreadsheet_seed(
asset,
version=version,
actor_name=actor_name,
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
fallback_sheet_name="费用申请审批规则",
tag="申请规则",
workbook_sheets=build_preapproval_rule_workbook_sheets(),
)
@staticmethod
def _read_or_build_company_travel_rule_file(
manager: AgentAssetSpreadsheetManager,
) -> bytes:
live_key = (
Path("rules")
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
).as_posix()
live_path = manager.resolve_storage_path(live_key)
if live_path.exists():
return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
def _ensure_finance_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
file_name: str,
fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
tag: str = "基础规则",
):
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
live_document = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=file_name,
content=self._read_or_build_finance_rule_file(
manager,
file_name=file_name,
fallback_sheet_name=fallback_sheet_name,
workbook_sheets=workbook_sheets,
),
actor_name=actor_name,
source="rule-library",
)
existing_document = (
asset.config_json.get("rule_document")
if isinstance(asset.config_json, dict)
else None
)
storage_key = (
str(existing_document.get("storage_key") or "").strip()
if isinstance(existing_document, dict)
else ""
)
if storage_key:
try:
existing_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
existing_path = None
if existing_path is not None and existing_path.exists():
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return live_document
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return live_document
@staticmethod
def _read_or_build_finance_rule_file(
manager: AgentAssetSpreadsheetManager,
*,
file_name: str,
fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
) -> bytes:
live_key = (
Path("rules")
/ FINANCE_RULES_LIBRARY
/ file_name
).as_posix()
live_path = manager.resolve_storage_path(live_key)
if live_path.exists():
return live_path.read_bytes()
if workbook_sheets is not None:
return AgentAssetSpreadsheetManager.build_rule_workbook(workbook_sheets)
return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name)