2026-05-22 10:42:31 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.agent_enums import AgentAssetType
|
|
|
|
|
|
from app.models.agent_asset import AgentAsset
|
|
|
|
|
|
from app.schemas.agent_asset import (
|
|
|
|
|
|
AgentAssetSpreadsheetDiffCellRead,
|
|
|
|
|
|
AgentAssetSpreadsheetDiffSheetRead,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.agent_asset_spreadsheet import (
|
|
|
|
|
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
|
|
|
|
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
2026-06-15 22:55:18 +08:00
|
|
|
|
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
|
|
|
|
|
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
2026-05-22 10:42:31 +08:00
|
|
|
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
|
|
|
|
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
2026-06-15 22:55:18 +08:00
|
|
|
|
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,
|
2026-05-22 10:42:31 +08:00
|
|
|
|
FINANCE_RULES_LIBRARY,
|
|
|
|
|
|
RULE_LIBRARY_NAMES,
|
|
|
|
|
|
SPREADSHEET_MIME_TYPE,
|
|
|
|
|
|
AgentAssetSpreadsheetManager,
|
|
|
|
|
|
RuleSpreadsheetMeta,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AgentAssetSpreadsheetHelperMixin:
|
|
|
|
|
|
def _require_spreadsheet_rule(self, asset_id: str) -> AgentAsset:
|
|
|
|
|
|
asset = self.repository.get(asset_id)
|
|
|
|
|
|
if asset is None:
|
|
|
|
|
|
raise LookupError("Asset not found")
|
|
|
|
|
|
if asset.asset_type != AgentAssetType.RULE.value:
|
|
|
|
|
|
raise ValueError("仅规则资产支持 Excel 规则表。")
|
|
|
|
|
|
detail_mode = str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
|
|
|
|
|
if detail_mode != "spreadsheet":
|
|
|
|
|
|
raise ValueError("当前规则未配置 Excel 规则表。")
|
|
|
|
|
|
return asset
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_spreadsheet_version_meta(
|
|
|
|
|
|
self,
|
|
|
|
|
|
asset: AgentAsset,
|
|
|
|
|
|
*,
|
|
|
|
|
|
version: str | None = None,
|
|
|
|
|
|
) -> tuple[str, RuleSpreadsheetMeta]:
|
|
|
|
|
|
resolved_version = str(version or self._resolve_working_version(asset) or "").strip()
|
|
|
|
|
|
if not resolved_version:
|
|
|
|
|
|
raise ValueError("当前规则尚未配置表格版本。")
|
|
|
|
|
|
|
|
|
|
|
|
version_row = self.repository.get_version(asset.id, resolved_version)
|
|
|
|
|
|
if version_row is None:
|
|
|
|
|
|
raise LookupError(f"版本 {resolved_version} 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
# 版本记录中的快照才是不变的事实来源。`/rules` 下的工作簿只是当前
|
|
|
|
|
|
# 可编辑副本,后续写入不应该反向污染某个已存在版本的内容。
|
|
|
|
|
|
metadata = self.spreadsheet_manager.parse_version_markdown(str(version_row.content or ""))
|
|
|
|
|
|
if metadata is None and self._resolve_working_version(asset) == resolved_version:
|
|
|
|
|
|
metadata = self._read_current_rule_document_meta(asset)
|
|
|
|
|
|
if metadata is None:
|
|
|
|
|
|
raise FileNotFoundError("规则表版本快照不存在。")
|
|
|
|
|
|
return resolved_version, metadata
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_current_spreadsheet_meta(
|
|
|
|
|
|
self,
|
|
|
|
|
|
asset: AgentAsset,
|
|
|
|
|
|
) -> tuple[str, RuleSpreadsheetMeta]:
|
|
|
|
|
|
config_json = dict(asset.config_json or {})
|
|
|
|
|
|
current_meta = self._read_current_rule_document_meta(asset)
|
|
|
|
|
|
file_name = (
|
|
|
|
|
|
current_meta.file_name
|
|
|
|
|
|
if current_meta is not None and current_meta.file_name
|
|
|
|
|
|
else self._resolve_default_spreadsheet_file_name(asset)
|
|
|
|
|
|
)
|
|
|
|
|
|
library = self._resolve_spreadsheet_rule_library(asset)
|
|
|
|
|
|
storage_key = (Path("rules") / library / file_name).as_posix()
|
|
|
|
|
|
file_path = self.spreadsheet_manager.resolve_storage_path(storage_key)
|
|
|
|
|
|
|
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
content: bytes | None = None
|
|
|
|
|
|
if current_meta is not None and current_meta.storage_key:
|
|
|
|
|
|
try:
|
|
|
|
|
|
legacy_path = self.spreadsheet_manager.resolve_storage_path(
|
|
|
|
|
|
current_meta.storage_key
|
|
|
|
|
|
)
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
legacy_path = None
|
|
|
|
|
|
if legacy_path is not None and legacy_path.exists():
|
|
|
|
|
|
content = legacy_path.read_bytes()
|
|
|
|
|
|
if content is None:
|
|
|
|
|
|
content = AgentAssetSpreadsheetManager.build_blank_rule_workbook(
|
|
|
|
|
|
Path(file_name).stem or "规则表"
|
|
|
|
|
|
)
|
|
|
|
|
|
meta = self.spreadsheet_manager.store_rule_library_spreadsheet(
|
|
|
|
|
|
library=library,
|
|
|
|
|
|
file_name=file_name,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
actor_name=(
|
|
|
|
|
|
current_meta.updated_by
|
|
|
|
|
|
if current_meta is not None and current_meta.updated_by
|
|
|
|
|
|
else "system"
|
|
|
|
|
|
),
|
|
|
|
|
|
source="current-rule",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
content = file_path.read_bytes()
|
|
|
|
|
|
meta = RuleSpreadsheetMeta(
|
|
|
|
|
|
file_name=file_name,
|
|
|
|
|
|
storage_key=storage_key,
|
|
|
|
|
|
mime_type=(
|
|
|
|
|
|
current_meta.mime_type
|
|
|
|
|
|
if current_meta is not None and current_meta.mime_type
|
|
|
|
|
|
else SPREADSHEET_MIME_TYPE
|
|
|
|
|
|
),
|
|
|
|
|
|
size_bytes=file_path.stat().st_size,
|
|
|
|
|
|
checksum=self._hash_bytes(content),
|
|
|
|
|
|
updated_at=datetime.fromtimestamp(file_path.stat().st_mtime, UTC).isoformat(),
|
|
|
|
|
|
updated_by=(
|
|
|
|
|
|
current_meta.updated_by
|
|
|
|
|
|
if current_meta is not None and current_meta.updated_by
|
|
|
|
|
|
else "system"
|
|
|
|
|
|
),
|
|
|
|
|
|
source=(
|
|
|
|
|
|
current_meta.source
|
|
|
|
|
|
if current_meta is not None and current_meta.source
|
|
|
|
|
|
else "current-rule"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
expected_document = {
|
|
|
|
|
|
**self.spreadsheet_manager.build_rule_document_config(
|
|
|
|
|
|
meta,
|
|
|
|
|
|
asset_version="current",
|
|
|
|
|
|
),
|
|
|
|
|
|
"storage_key": meta.storage_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
if config_json.get("rule_document") != expected_document:
|
|
|
|
|
|
config_json["detail_mode"] = "spreadsheet"
|
2026-06-15 22:55:18 +08:00
|
|
|
|
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
|
2026-05-22 10:42:31 +08:00
|
|
|
|
config_json["rule_library"] = library
|
|
|
|
|
|
config_json["rule_document"] = expected_document
|
|
|
|
|
|
asset.config_json = config_json
|
|
|
|
|
|
self.repository.save_asset(asset)
|
|
|
|
|
|
|
|
|
|
|
|
return "current", meta
|
|
|
|
|
|
|
|
|
|
|
|
def _store_current_rule_spreadsheet(
|
|
|
|
|
|
self,
|
|
|
|
|
|
asset: AgentAsset,
|
|
|
|
|
|
*,
|
|
|
|
|
|
file_name: str,
|
|
|
|
|
|
content: bytes,
|
|
|
|
|
|
actor: str,
|
|
|
|
|
|
source: str,
|
|
|
|
|
|
) -> RuleSpreadsheetMeta:
|
|
|
|
|
|
library = self._resolve_spreadsheet_rule_library(asset)
|
|
|
|
|
|
metadata = self.spreadsheet_manager.store_rule_library_spreadsheet(
|
|
|
|
|
|
library=library,
|
|
|
|
|
|
file_name=file_name,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
actor_name=actor,
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
)
|
|
|
|
|
|
config_json = dict(asset.config_json or {})
|
|
|
|
|
|
config_json["detail_mode"] = "spreadsheet"
|
2026-06-15 22:55:18 +08:00
|
|
|
|
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
|
2026-05-22 10:42:31 +08:00
|
|
|
|
config_json["rule_library"] = library
|
|
|
|
|
|
config_json["rule_document"] = {
|
|
|
|
|
|
**self.spreadsheet_manager.build_rule_document_config(
|
|
|
|
|
|
metadata,
|
|
|
|
|
|
asset_version="current",
|
|
|
|
|
|
),
|
|
|
|
|
|
"storage_key": metadata.storage_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
asset.config_json = config_json
|
|
|
|
|
|
self.repository.save_asset(asset)
|
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_spreadsheet_rule_library(asset: AgentAsset) -> str:
|
|
|
|
|
|
config_json = dict(asset.config_json or {})
|
|
|
|
|
|
library = str(config_json.get("rule_library") or FINANCE_RULES_LIBRARY).strip()
|
|
|
|
|
|
if library not in RULE_LIBRARY_NAMES:
|
|
|
|
|
|
return FINANCE_RULES_LIBRARY
|
|
|
|
|
|
return library
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_default_spreadsheet_file_name(asset: AgentAsset) -> str:
|
|
|
|
|
|
if asset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
|
|
|
|
|
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
|
|
|
|
|
|
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
|
|
|
|
|
|
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
|
2026-06-15 22:55:18 +08:00
|
|
|
|
if asset.code == COMPANY_TRAVEL_ALLOWANCE_RULE_CODE:
|
|
|
|
|
|
return COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME
|
|
|
|
|
|
if asset.code == COMPANY_TRAVEL_TRANSPORT_RULE_CODE:
|
|
|
|
|
|
return COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME
|
|
|
|
|
|
if asset.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
|
|
|
|
|
|
return COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
|
|
|
|
|
|
if asset.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE:
|
|
|
|
|
|
return COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
|
|
|
|
|
|
if asset.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE:
|
|
|
|
|
|
return COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
|
2026-05-22 10:42:31 +08:00
|
|
|
|
fallback = Path(str(asset.name or "规则表").strip()).name
|
|
|
|
|
|
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_spreadsheet_for_compare(self, metadata: RuleSpreadsheetMeta):
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
|
|
|
|
|
|
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
raise FileNotFoundError(metadata.file_name)
|
|
|
|
|
|
return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False)
|
|
|
|
|
|
|
|
|
|
|
|
def _collect_workbook_changes_from_content(
|
|
|
|
|
|
self,
|
|
|
|
|
|
base_metadata: RuleSpreadsheetMeta,
|
|
|
|
|
|
target_content: bytes,
|
|
|
|
|
|
) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]:
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
|
|
|
|
|
|
base_workbook = self._load_spreadsheet_for_compare(base_metadata)
|
|
|
|
|
|
target_workbook = load_workbook(BytesIO(target_content), read_only=False, data_only=False)
|
|
|
|
|
|
return self._collect_workbook_changes(base_workbook, target_workbook)
|
|
|
|
|
|
|
|
|
|
|
|
def _collect_workbook_changes(
|
|
|
|
|
|
self, base_workbook, target_workbook
|
|
|
|
|
|
) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]:
|
|
|
|
|
|
base_sheet_names = set(base_workbook.sheetnames)
|
|
|
|
|
|
target_sheet_names = set(target_workbook.sheetnames)
|
|
|
|
|
|
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = []
|
|
|
|
|
|
for sheet_name in sorted(target_sheet_names - base_sheet_names):
|
|
|
|
|
|
sheet_changes.append(
|
|
|
|
|
|
AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="added")
|
|
|
|
|
|
)
|
|
|
|
|
|
for sheet_name in sorted(base_sheet_names - target_sheet_names):
|
|
|
|
|
|
sheet_changes.append(
|
|
|
|
|
|
AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="removed")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = []
|
|
|
|
|
|
|
|
|
|
|
|
for sheet_name in sorted(base_sheet_names & target_sheet_names):
|
|
|
|
|
|
base_sheet = base_workbook[sheet_name]
|
|
|
|
|
|
target_sheet = target_workbook[sheet_name]
|
|
|
|
|
|
max_row = max(base_sheet.max_row, target_sheet.max_row)
|
|
|
|
|
|
max_column = max(base_sheet.max_column, target_sheet.max_column)
|
|
|
|
|
|
for row_index in range(1, max_row + 1):
|
|
|
|
|
|
for column_index in range(1, max_column + 1):
|
|
|
|
|
|
before_value = base_sheet.cell(row=row_index, column=column_index).value
|
|
|
|
|
|
after_value = target_sheet.cell(row=row_index, column=column_index).value
|
|
|
|
|
|
if before_value == after_value:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if before_value in (None, ""):
|
|
|
|
|
|
change_type = "added"
|
|
|
|
|
|
elif after_value in (None, ""):
|
|
|
|
|
|
change_type = "removed"
|
|
|
|
|
|
else:
|
|
|
|
|
|
change_type = "modified"
|
|
|
|
|
|
cell_changes.append(
|
|
|
|
|
|
AgentAssetSpreadsheetDiffCellRead(
|
|
|
|
|
|
sheet_name=sheet_name,
|
|
|
|
|
|
cell=target_sheet.cell(row=row_index, column=column_index).coordinate,
|
|
|
|
|
|
change_type=change_type,
|
|
|
|
|
|
before_value=before_value,
|
|
|
|
|
|
after_value=after_value,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for sheet_name in sorted({item.sheet_name for item in cell_changes}):
|
|
|
|
|
|
sheet_changes.append(
|
|
|
|
|
|
AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="modified")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return sheet_changes, cell_changes
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _count_changed_sheets(
|
|
|
|
|
|
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
|
|
|
|
|
|
cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
return len(
|
|
|
|
|
|
{item.sheet_name for item in sheet_changes}
|
|
|
|
|
|
| {item.sheet_name for item in cell_changes}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_spreadsheet_change_summary(
|
|
|
|
|
|
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
|
|
|
|
|
|
cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
sheet_names = sorted(
|
|
|
|
|
|
{item.sheet_name for item in sheet_changes}
|
|
|
|
|
|
| {item.sheet_name for item in cell_changes}
|
|
|
|
|
|
)
|
|
|
|
|
|
if not sheet_names:
|
|
|
|
|
|
return "文件内容已保存,未发现单元格级差异。"
|
|
|
|
|
|
|
|
|
|
|
|
preview = "、".join(sheet_names[:3])
|
|
|
|
|
|
if len(sheet_names) > 3:
|
|
|
|
|
|
preview = f"{preview} 等"
|
|
|
|
|
|
sheet_text = f"涉及 {len(sheet_names)} 个工作表({preview})"
|
|
|
|
|
|
if cell_changes:
|
|
|
|
|
|
return f"{sheet_text},共 {len(cell_changes)} 处单元格改动。"
|
|
|
|
|
|
return f"{sheet_text},工作表结构发生变化。"
|
|
|
|
|
|
|
|
|
|
|
|
|