refactor: enforce 800 line source limits
This commit is contained in:
@@ -46,17 +46,305 @@ from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_sco
|
||||
logger = get_logger("app.services.agent_assets")
|
||||
|
||||
|
||||
class AgentAssetService(
|
||||
AgentAssetOnlyOfficeMixin,
|
||||
AgentAssetSpreadsheetHelperMixin,
|
||||
AgentAssetRiskRuleLevelMixin,
|
||||
AgentAssetRiskRulePublishMixin,
|
||||
AgentAssetRiskRuleFeedbackMixin,
|
||||
AgentAssetRiskRuleTestingMixin,
|
||||
AgentAssetRiskRuleSimulationMixin,
|
||||
AgentAssetTimelineMixin,
|
||||
AgentAssetJsonRuleMixin,
|
||||
):
|
||||
class AgentAssetVersionMixin:
|
||||
def _validate_version_payload(
|
||||
self, asset: AgentAsset, payload: AgentAssetVersionCreate
|
||||
) -> None:
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and payload.content_type != AgentAssetContentType.MARKDOWN
|
||||
):
|
||||
raise ValueError("规则资产版本内容必须使用 markdown。")
|
||||
if (
|
||||
asset.asset_type not in {AgentAssetType.RULE.value, AgentAssetType.TASK.value}
|
||||
and payload.content_type != AgentAssetContentType.JSON
|
||||
):
|
||||
raise ValueError("技能、MCP 资产版本内容必须使用 json。")
|
||||
if payload.content_type == AgentAssetContentType.MARKDOWN and not isinstance(
|
||||
payload.content, str
|
||||
):
|
||||
raise ValueError("Markdown 内容必须是字符串。")
|
||||
if payload.content_type == AgentAssetContentType.JSON and not isinstance(
|
||||
payload.content, (dict, list)
|
||||
):
|
||||
raise ValueError("JSON 内容必须是对象或数组。")
|
||||
|
||||
def restore_version_as_working_copy(
|
||||
self,
|
||||
asset_id: str,
|
||||
source_version: str,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRead:
|
||||
self._ensure_ready()
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
|
||||
source = self.repository.get_version(asset_id, source_version)
|
||||
if source is None:
|
||||
raise LookupError(f"版本 {source_version} 不存在")
|
||||
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
):
|
||||
metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
|
||||
if metadata is None:
|
||||
raise FileNotFoundError("历史规则表快照不存在,无法恢复。")
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(metadata.file_name)
|
||||
restored = self.upload_rule_spreadsheet(
|
||||
asset.id,
|
||||
filename=metadata.file_name,
|
||||
content=file_path.read_bytes(),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
source="restore",
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": restored.working_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored
|
||||
|
||||
next_version = self._increment_version(self._resolve_working_version(asset))
|
||||
self.create_version(
|
||||
asset.id,
|
||||
AgentAssetVersionCreate(
|
||||
version=next_version,
|
||||
content=self._deserialize_content(source),
|
||||
content_type=AgentAssetContentType(source.content_type),
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
created_by=actor,
|
||||
),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
restored = self.get_asset(asset.id)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": next_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored # type: ignore[return-value]
|
||||
|
||||
def _serialize_version(
|
||||
self, version: AgentAssetVersion, asset: AgentAsset
|
||||
) -> AgentAssetVersionRead:
|
||||
latest_review = self.repository.get_review(asset.id, version.version)
|
||||
working_version = self._resolve_working_version(asset)
|
||||
published_version = self._resolve_published_version(asset)
|
||||
return AgentAssetVersionRead(
|
||||
id=version.id,
|
||||
asset_id=version.asset_id,
|
||||
version=version.version,
|
||||
content=self._deserialize_content(version),
|
||||
content_type=version.content_type,
|
||||
change_note=version.change_note,
|
||||
created_by=version.created_by,
|
||||
created_at=version.created_at,
|
||||
is_current=version.version == working_version,
|
||||
is_published=version.version == published_version,
|
||||
is_working=version.version == working_version,
|
||||
lifecycle_state=self._resolve_version_lifecycle_state(
|
||||
version.version,
|
||||
working_version=working_version,
|
||||
published_version=published_version,
|
||||
latest_review_status=latest_review.review_status if latest_review else "",
|
||||
),
|
||||
)
|
||||
|
||||
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
|
||||
asset_ids = [item.id for item in assets]
|
||||
versions = self.repository.list_versions_for_assets(asset_ids)
|
||||
reviews = self.repository.list_reviews_for_assets(asset_ids)
|
||||
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_ids=[
|
||||
item.id
|
||||
for item in assets
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
],
|
||||
action="edit_rule_spreadsheet",
|
||||
)
|
||||
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
|
||||
version_counts: dict[str, int] = defaultdict(int)
|
||||
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
||||
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
|
||||
published_by: dict[str, str | None] = {}
|
||||
published_at: dict[str, datetime | None] = {}
|
||||
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
||||
spreadsheet_last_actor: dict[str, str | None] = {}
|
||||
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
||||
|
||||
for version in versions:
|
||||
version_counts[version.asset_id] += 1
|
||||
if modified_by.get(
|
||||
version.asset_id
|
||||
) is None and version.version == working_versions.get(version.asset_id):
|
||||
modified_by[version.asset_id] = version.created_by
|
||||
|
||||
for review in reviews:
|
||||
if review.asset_id in published_at:
|
||||
continue
|
||||
if review.version != published_versions.get(review.asset_id):
|
||||
continue
|
||||
if review.review_status != AgentReviewStatus.APPROVED.value:
|
||||
continue
|
||||
published_by[review.asset_id] = review.reviewer
|
||||
published_at[review.asset_id] = review.reviewed_at or review.created_at
|
||||
|
||||
for log in spreadsheet_logs:
|
||||
spreadsheet_edit_counts[log.resource_id] += 1
|
||||
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
||||
if last_changed_at is None or log.created_at >= last_changed_at:
|
||||
spreadsheet_last_changed_at[log.resource_id] = log.created_at
|
||||
spreadsheet_last_actor[log.resource_id] = log.actor
|
||||
|
||||
return {
|
||||
item.id: {
|
||||
"change_count": (
|
||||
spreadsheet_edit_counts.get(item.id, 0)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_edit_counts.get(item.id, 0) > 0
|
||||
else max(version_counts.get(item.id, 0) - 1, 0)
|
||||
),
|
||||
"modified_by": (
|
||||
spreadsheet_last_actor.get(item.id)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_last_actor.get(item.id)
|
||||
else modified_by.get(item.id)
|
||||
),
|
||||
"published_by": published_by.get(item.id),
|
||||
"published_at": published_at.get(item.id),
|
||||
}
|
||||
for item in assets
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list_item(
|
||||
asset: AgentAsset,
|
||||
version_stats: dict[str, int | str | None] | None = None,
|
||||
) -> AgentAssetListItem:
|
||||
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
||||
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
||||
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
|
||||
payload["published_by"] = (
|
||||
str((version_stats or {}).get("published_by") or "").strip() or None
|
||||
)
|
||||
payload["published_at"] = (version_stats or {}).get("published_at")
|
||||
return AgentAssetListItem.model_validate(payload)
|
||||
|
||||
@staticmethod
|
||||
def _sort_versions(
|
||||
versions: list[AgentAssetVersion], current_version: str | None
|
||||
) -> list[AgentAssetVersion]:
|
||||
return sorted(
|
||||
versions,
|
||||
key=lambda item: (item.version == current_version, item.created_at),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content(content: Any, content_type: str) -> str:
|
||||
if content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return str(content)
|
||||
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
|
||||
if version is None:
|
||||
return None
|
||||
if version.content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return version.content
|
||||
return json.loads(version.content)
|
||||
|
||||
@staticmethod
|
||||
def _increment_version(version: str | None) -> str:
|
||||
normalized = str(version or "").strip().removeprefix("v")
|
||||
parts = normalized.split(".")
|
||||
if len(parts) != 3 or not all(item.isdigit() for item in parts):
|
||||
return "v1.0.0"
|
||||
major, minor, patch = [int(item) for item in parts]
|
||||
return f"v{major}.{minor}.{patch + 1}"
|
||||
|
||||
@staticmethod
|
||||
def _hash_bytes(content: bytes) -> str:
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||||
return {
|
||||
"asset_type": asset.asset_type,
|
||||
"code": asset.code,
|
||||
"name": asset.name,
|
||||
"status": asset.status,
|
||||
"current_version": asset.current_version,
|
||||
"published_version": asset.published_version,
|
||||
"working_version": asset.working_version,
|
||||
"domain": asset.domain,
|
||||
"owner": asset.owner,
|
||||
"reviewer": asset.reviewer,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_working_version(asset: AgentAsset) -> str:
|
||||
return str(asset.working_version or asset.current_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_published_version(asset: AgentAsset) -> str:
|
||||
return str(asset.published_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_version_lifecycle_state(
|
||||
version: str,
|
||||
*,
|
||||
working_version: str,
|
||||
published_version: str,
|
||||
latest_review_status: str,
|
||||
) -> str:
|
||||
if version == published_version:
|
||||
return "published"
|
||||
if version != working_version:
|
||||
return "history"
|
||||
if latest_review_status == AgentReviewStatus.PENDING.value:
|
||||
return "pending_review"
|
||||
if latest_review_status == AgentReviewStatus.APPROVED.value:
|
||||
return "approved"
|
||||
if latest_review_status == AgentReviewStatus.REJECTED.value:
|
||||
return "rejected"
|
||||
return "draft"
|
||||
|
||||
def _next_available_version(self, asset: AgentAsset) -> str:
|
||||
candidate = self._increment_version(self._resolve_working_version(asset))
|
||||
while self.repository.get_version(asset.id, candidate) is not None:
|
||||
candidate = self._increment_version(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, AgentAssetSpreadsheetHelperMixin, AgentAssetRiskRuleLevelMixin, AgentAssetRiskRulePublishMixin, AgentAssetRiskRuleFeedbackMixin, AgentAssetRiskRuleTestingMixin, AgentAssetRiskRuleSimulationMixin, AgentAssetTimelineMixin, AgentAssetJsonRuleMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.repository = AgentAssetRepository(db)
|
||||
@@ -559,298 +847,3 @@ class AgentAssetService(
|
||||
self.db.commit()
|
||||
return synced_count
|
||||
|
||||
def _validate_version_payload(
|
||||
self, asset: AgentAsset, payload: AgentAssetVersionCreate
|
||||
) -> None:
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and payload.content_type != AgentAssetContentType.MARKDOWN
|
||||
):
|
||||
raise ValueError("规则资产版本内容必须使用 markdown。")
|
||||
if (
|
||||
asset.asset_type not in {AgentAssetType.RULE.value, AgentAssetType.TASK.value}
|
||||
and payload.content_type != AgentAssetContentType.JSON
|
||||
):
|
||||
raise ValueError("技能、MCP 资产版本内容必须使用 json。")
|
||||
if payload.content_type == AgentAssetContentType.MARKDOWN and not isinstance(
|
||||
payload.content, str
|
||||
):
|
||||
raise ValueError("Markdown 内容必须是字符串。")
|
||||
if payload.content_type == AgentAssetContentType.JSON and not isinstance(
|
||||
payload.content, (dict, list)
|
||||
):
|
||||
raise ValueError("JSON 内容必须是对象或数组。")
|
||||
|
||||
def restore_version_as_working_copy(
|
||||
self,
|
||||
asset_id: str,
|
||||
source_version: str,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRead:
|
||||
self._ensure_ready()
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise LookupError("Asset not found")
|
||||
|
||||
source = self.repository.get_version(asset_id, source_version)
|
||||
if source is None:
|
||||
raise LookupError(f"版本 {source_version} 不存在")
|
||||
|
||||
if (
|
||||
asset.asset_type == AgentAssetType.RULE.value
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
):
|
||||
metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
|
||||
if metadata is None:
|
||||
raise FileNotFoundError("历史规则表快照不存在,无法恢复。")
|
||||
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(metadata.file_name)
|
||||
restored = self.upload_rule_spreadsheet(
|
||||
asset.id,
|
||||
filename=metadata.file_name,
|
||||
content=file_path.read_bytes(),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
source="restore",
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": restored.working_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored
|
||||
|
||||
next_version = self._increment_version(self._resolve_working_version(asset))
|
||||
self.create_version(
|
||||
asset.id,
|
||||
AgentAssetVersionCreate(
|
||||
version=next_version,
|
||||
content=self._deserialize_content(source),
|
||||
content_type=AgentAssetContentType(source.content_type),
|
||||
change_note=f"基于历史版本 {source_version} 恢复生成工作稿",
|
||||
created_by=actor,
|
||||
),
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
restored = self.get_asset(asset.id)
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="restore_agent_asset_version",
|
||||
resource_type=asset.asset_type,
|
||||
resource_id=asset.id,
|
||||
before_json={"source_version": source_version},
|
||||
after_json={"working_version": next_version},
|
||||
request_id=request_id,
|
||||
)
|
||||
return restored # type: ignore[return-value]
|
||||
|
||||
def _serialize_version(
|
||||
self, version: AgentAssetVersion, asset: AgentAsset
|
||||
) -> AgentAssetVersionRead:
|
||||
latest_review = self.repository.get_review(asset.id, version.version)
|
||||
working_version = self._resolve_working_version(asset)
|
||||
published_version = self._resolve_published_version(asset)
|
||||
return AgentAssetVersionRead(
|
||||
id=version.id,
|
||||
asset_id=version.asset_id,
|
||||
version=version.version,
|
||||
content=self._deserialize_content(version),
|
||||
content_type=version.content_type,
|
||||
change_note=version.change_note,
|
||||
created_by=version.created_by,
|
||||
created_at=version.created_at,
|
||||
is_current=version.version == working_version,
|
||||
is_published=version.version == published_version,
|
||||
is_working=version.version == working_version,
|
||||
lifecycle_state=self._resolve_version_lifecycle_state(
|
||||
version.version,
|
||||
working_version=working_version,
|
||||
published_version=published_version,
|
||||
latest_review_status=latest_review.review_status if latest_review else "",
|
||||
),
|
||||
)
|
||||
|
||||
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
|
||||
asset_ids = [item.id for item in assets]
|
||||
versions = self.repository.list_versions_for_assets(asset_ids)
|
||||
reviews = self.repository.list_reviews_for_assets(asset_ids)
|
||||
spreadsheet_logs = self.audit_service.repository.list_for_resources(
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_ids=[
|
||||
item.id
|
||||
for item in assets
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
],
|
||||
action="edit_rule_spreadsheet",
|
||||
)
|
||||
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
|
||||
version_counts: dict[str, int] = defaultdict(int)
|
||||
modified_by: dict[str, str | None] = {item.id: None for item in assets}
|
||||
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
|
||||
published_by: dict[str, str | None] = {}
|
||||
published_at: dict[str, datetime | None] = {}
|
||||
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
|
||||
spreadsheet_last_actor: dict[str, str | None] = {}
|
||||
spreadsheet_last_changed_at: dict[str, datetime] = {}
|
||||
|
||||
for version in versions:
|
||||
version_counts[version.asset_id] += 1
|
||||
if modified_by.get(
|
||||
version.asset_id
|
||||
) is None and version.version == working_versions.get(version.asset_id):
|
||||
modified_by[version.asset_id] = version.created_by
|
||||
|
||||
for review in reviews:
|
||||
if review.asset_id in published_at:
|
||||
continue
|
||||
if review.version != published_versions.get(review.asset_id):
|
||||
continue
|
||||
if review.review_status != AgentReviewStatus.APPROVED.value:
|
||||
continue
|
||||
published_by[review.asset_id] = review.reviewer
|
||||
published_at[review.asset_id] = review.reviewed_at or review.created_at
|
||||
|
||||
for log in spreadsheet_logs:
|
||||
spreadsheet_edit_counts[log.resource_id] += 1
|
||||
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
|
||||
if last_changed_at is None or log.created_at >= last_changed_at:
|
||||
spreadsheet_last_changed_at[log.resource_id] = log.created_at
|
||||
spreadsheet_last_actor[log.resource_id] = log.actor
|
||||
|
||||
return {
|
||||
item.id: {
|
||||
"change_count": (
|
||||
spreadsheet_edit_counts.get(item.id, 0)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_edit_counts.get(item.id, 0) > 0
|
||||
else max(version_counts.get(item.id, 0) - 1, 0)
|
||||
),
|
||||
"modified_by": (
|
||||
spreadsheet_last_actor.get(item.id)
|
||||
if item.asset_type == AgentAssetType.RULE.value
|
||||
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
|
||||
== "spreadsheet"
|
||||
and spreadsheet_last_actor.get(item.id)
|
||||
else modified_by.get(item.id)
|
||||
),
|
||||
"published_by": published_by.get(item.id),
|
||||
"published_at": published_at.get(item.id),
|
||||
}
|
||||
for item in assets
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list_item(
|
||||
asset: AgentAsset,
|
||||
version_stats: dict[str, int | str | None] | None = None,
|
||||
) -> AgentAssetListItem:
|
||||
payload = AgentAssetListItem.model_validate(asset).model_dump()
|
||||
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
|
||||
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
|
||||
payload["published_by"] = (
|
||||
str((version_stats or {}).get("published_by") or "").strip() or None
|
||||
)
|
||||
payload["published_at"] = (version_stats or {}).get("published_at")
|
||||
return AgentAssetListItem.model_validate(payload)
|
||||
|
||||
@staticmethod
|
||||
def _sort_versions(
|
||||
versions: list[AgentAssetVersion], current_version: str | None
|
||||
) -> list[AgentAssetVersion]:
|
||||
return sorted(
|
||||
versions,
|
||||
key=lambda item: (item.version == current_version, item.created_at),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content(content: Any, content_type: str) -> str:
|
||||
if content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return str(content)
|
||||
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
|
||||
if version is None:
|
||||
return None
|
||||
if version.content_type == AgentAssetContentType.MARKDOWN.value:
|
||||
return version.content
|
||||
return json.loads(version.content)
|
||||
|
||||
@staticmethod
|
||||
def _increment_version(version: str | None) -> str:
|
||||
normalized = str(version or "").strip().removeprefix("v")
|
||||
parts = normalized.split(".")
|
||||
if len(parts) != 3 or not all(item.isdigit() for item in parts):
|
||||
return "v1.0.0"
|
||||
major, minor, patch = [int(item) for item in parts]
|
||||
return f"v{major}.{minor}.{patch + 1}"
|
||||
|
||||
@staticmethod
|
||||
def _hash_bytes(content: bytes) -> str:
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||||
return {
|
||||
"asset_type": asset.asset_type,
|
||||
"code": asset.code,
|
||||
"name": asset.name,
|
||||
"status": asset.status,
|
||||
"current_version": asset.current_version,
|
||||
"published_version": asset.published_version,
|
||||
"working_version": asset.working_version,
|
||||
"domain": asset.domain,
|
||||
"owner": asset.owner,
|
||||
"reviewer": asset.reviewer,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_working_version(asset: AgentAsset) -> str:
|
||||
return str(asset.working_version or asset.current_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_published_version(asset: AgentAsset) -> str:
|
||||
return str(asset.published_version or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_version_lifecycle_state(
|
||||
version: str,
|
||||
*,
|
||||
working_version: str,
|
||||
published_version: str,
|
||||
latest_review_status: str,
|
||||
) -> str:
|
||||
if version == published_version:
|
||||
return "published"
|
||||
if version != working_version:
|
||||
return "history"
|
||||
if latest_review_status == AgentReviewStatus.PENDING.value:
|
||||
return "pending_review"
|
||||
if latest_review_status == AgentReviewStatus.APPROVED.value:
|
||||
return "approved"
|
||||
if latest_review_status == AgentReviewStatus.REJECTED.value:
|
||||
return "rejected"
|
||||
return "draft"
|
||||
|
||||
def _next_available_version(self, asset: AgentAsset) -> str:
|
||||
candidate = self._increment_version(self._resolve_working_version(asset))
|
||||
while self.repository.get_version(asset.id, candidate) is not None:
|
||||
candidate = self._increment_version(candidate)
|
||||
return candidate
|
||||
|
||||
@@ -153,53 +153,68 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
media_type=media_type,
|
||||
item=item,
|
||||
)
|
||||
source_receipt_document = self._resolve_source_receipt_document(
|
||||
source_receipt_id=source_receipt_id,
|
||||
current_user=current_user,
|
||||
fallback_filename=normalized_name,
|
||||
fallback_media_type=resolved_media_type,
|
||||
)
|
||||
ocr_document = None
|
||||
document_info = None
|
||||
requirement_check = None
|
||||
ocr_status = "empty"
|
||||
ocr_error = ""
|
||||
upload_ocr_document = None
|
||||
try:
|
||||
ocr_result = OcrService(self.db).recognize_files(
|
||||
[(normalized_name, content, media_type or "application/octet-stream")]
|
||||
)
|
||||
documents = list(ocr_result.documents or [])
|
||||
if documents:
|
||||
ocr_document = documents[0]
|
||||
ocr_status = "recognized"
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
self._backfill_item_type_from_attachment(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
self._backfill_item_amount_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
self._backfill_item_date_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
self._backfill_item_reason_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
attachment_analysis = self._build_attachment_analysis(
|
||||
document=ocr_document,
|
||||
item=item,
|
||||
claim=claim,
|
||||
document_info=document_info,
|
||||
requirement_check=requirement_check,
|
||||
)
|
||||
upload_ocr_document = documents[0]
|
||||
except Exception as exc: # pragma: no cover - fallback path depends on OCR runtime
|
||||
ocr_status = "failed"
|
||||
ocr_error = str(exc)
|
||||
|
||||
ocr_document = self._choose_attachment_ocr_document(
|
||||
source_receipt_document=source_receipt_document,
|
||||
upload_ocr_document=upload_ocr_document,
|
||||
)
|
||||
if ocr_document is not None:
|
||||
ocr_status = "recognized"
|
||||
ocr_error = ""
|
||||
document_info = self._build_attachment_document_info(ocr_document)
|
||||
self._backfill_item_type_from_attachment(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
self._backfill_item_amount_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
self._backfill_item_date_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
self._backfill_item_reason_from_attachment(
|
||||
item=item,
|
||||
document=ocr_document,
|
||||
document_info=document_info,
|
||||
)
|
||||
requirement_check = self._build_attachment_requirement_check(
|
||||
item=item,
|
||||
document_info=document_info,
|
||||
)
|
||||
attachment_analysis = self._build_attachment_analysis(
|
||||
document=ocr_document,
|
||||
item=item,
|
||||
claim=claim,
|
||||
document_info=document_info,
|
||||
requirement_check=requirement_check,
|
||||
)
|
||||
elif ocr_error:
|
||||
ocr_status = "failed"
|
||||
attachment_analysis = self._build_failed_ocr_attachment_analysis(
|
||||
media_type=media_type,
|
||||
error_message=ocr_error,
|
||||
@@ -240,6 +255,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
if str(item).strip()
|
||||
],
|
||||
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
|
||||
"source_receipt_id": str(source_receipt_id or "").strip(),
|
||||
}
|
||||
self._attachment_storage.write_meta(file_path, meta)
|
||||
ReceiptFolderService().save_linked_attachment(
|
||||
@@ -283,6 +299,143 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
|
||||
def _resolve_source_receipt_document(
|
||||
self,
|
||||
*,
|
||||
source_receipt_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
fallback_filename: str,
|
||||
fallback_media_type: str,
|
||||
) -> SimpleNamespace | None:
|
||||
normalized_receipt_id = str(source_receipt_id or "").strip()
|
||||
if not normalized_receipt_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
receipt = ReceiptFolderService().get_receipt(normalized_receipt_id, current_user)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
raw_meta = receipt.raw_meta if isinstance(receipt.raw_meta, dict) else {}
|
||||
fields = self._normalize_receipt_document_fields(
|
||||
[field.model_dump() for field in list(receipt.fields or [])]
|
||||
)
|
||||
if not fields:
|
||||
fields = self._normalize_receipt_document_fields(raw_meta.get("document_fields"))
|
||||
|
||||
document = SimpleNamespace(
|
||||
filename=str(receipt.file_name or fallback_filename or "").strip(),
|
||||
media_type=str(receipt.media_type or fallback_media_type or "application/octet-stream").strip(),
|
||||
engine=str(receipt.engine or raw_meta.get("engine") or ""),
|
||||
model=str(receipt.model or raw_meta.get("model") or ""),
|
||||
text=str(receipt.ocr_text or raw_meta.get("ocr_text") or ""),
|
||||
summary=str(receipt.summary or raw_meta.get("summary") or ""),
|
||||
avg_score=float(receipt.avg_score or raw_meta.get("ocr_avg_score") or 0.0),
|
||||
line_count=int(receipt.line_count or raw_meta.get("ocr_line_count") or 0),
|
||||
page_count=max(1, int(receipt.page_count or raw_meta.get("page_count") or 1)),
|
||||
document_type=str(receipt.document_type or raw_meta.get("document_type") or "other").strip(),
|
||||
document_type_label=str(
|
||||
receipt.document_type_label or raw_meta.get("document_type_label") or "其他单据"
|
||||
).strip(),
|
||||
scene_code=str(receipt.scene_code or raw_meta.get("scene_code") or "other").strip(),
|
||||
scene_label=str(receipt.scene_label or raw_meta.get("scene_label") or "其他票据").strip(),
|
||||
classification_source=str(raw_meta.get("ocr_classification_source") or "receipt_folder"),
|
||||
classification_confidence=float(
|
||||
receipt.classification_confidence
|
||||
or raw_meta.get("ocr_classification_confidence")
|
||||
or 0.0
|
||||
),
|
||||
classification_evidence=[
|
||||
str(value)
|
||||
for value in list(
|
||||
receipt.classification_evidence
|
||||
or raw_meta.get("ocr_classification_evidence")
|
||||
or []
|
||||
)
|
||||
if str(value).strip()
|
||||
],
|
||||
document_fields=fields,
|
||||
preview_kind=str(raw_meta.get("preview_kind") or ""),
|
||||
preview_data_url="",
|
||||
warnings=[
|
||||
str(value)
|
||||
for value in list(receipt.warnings or raw_meta.get("ocr_warnings") or [])
|
||||
if str(value).strip()
|
||||
],
|
||||
)
|
||||
return document if self._attachment_ocr_signal_score(document) > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_receipt_document_fields(raw_fields: Any) -> list[dict[str, str]]:
|
||||
fields: list[dict[str, str]] = []
|
||||
for field in list(raw_fields or []):
|
||||
if isinstance(field, dict):
|
||||
key = str(field.get("key") or "").strip()
|
||||
label = str(field.get("label") or "").strip()
|
||||
value = str(field.get("value") or "").strip()
|
||||
else:
|
||||
key = str(getattr(field, "key", "") or "").strip()
|
||||
label = str(getattr(field, "label", "") or "").strip()
|
||||
value = str(getattr(field, "value", "") or "").strip()
|
||||
if label and value:
|
||||
fields.append({"key": key, "label": label, "value": value})
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def _choose_attachment_ocr_document(
|
||||
cls,
|
||||
*,
|
||||
source_receipt_document: Any | None,
|
||||
upload_ocr_document: Any | None,
|
||||
) -> Any | None:
|
||||
source_score = cls._attachment_ocr_signal_score(source_receipt_document)
|
||||
upload_score = cls._attachment_ocr_signal_score(upload_ocr_document)
|
||||
if source_score <= 0:
|
||||
return upload_ocr_document if upload_score > 0 else None
|
||||
if upload_score <= 0:
|
||||
return source_receipt_document
|
||||
|
||||
source_type = cls._attachment_document_type(source_receipt_document)
|
||||
upload_type = cls._attachment_document_type(upload_ocr_document)
|
||||
if source_type not in {"", "other"} and upload_type in {"", "other"}:
|
||||
return source_receipt_document
|
||||
if (
|
||||
source_type == upload_type
|
||||
and cls._attachment_document_field_count(source_receipt_document)
|
||||
> cls._attachment_document_field_count(upload_ocr_document)
|
||||
):
|
||||
return source_receipt_document
|
||||
if source_score > upload_score + 2:
|
||||
return source_receipt_document
|
||||
return upload_ocr_document
|
||||
|
||||
@classmethod
|
||||
def _attachment_ocr_signal_score(cls, document: Any | None) -> int:
|
||||
if document is None:
|
||||
return 0
|
||||
score = 0
|
||||
document_type = cls._attachment_document_type(document)
|
||||
if document_type not in {"", "other"}:
|
||||
score += 4
|
||||
score += min(3, cls._attachment_document_field_count(document))
|
||||
if str(getattr(document, "text", "") or "").strip():
|
||||
score += 2
|
||||
if str(getattr(document, "summary", "") or "").strip():
|
||||
score += 1
|
||||
if int(getattr(document, "line_count", 0) or 0) > 0:
|
||||
score += 1
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _attachment_document_type(document: Any | None) -> str:
|
||||
return str(getattr(document, "document_type", "") or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _attachment_document_field_count(document: Any | None) -> int:
|
||||
if document is None:
|
||||
return 0
|
||||
return len(list(getattr(document, "document_fields", []) or []))
|
||||
|
||||
def get_claim_item_attachment_meta(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -114,294 +114,7 @@ APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
|
||||
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
|
||||
|
||||
|
||||
class ExpenseClaimDraftFlowMixin:
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_ready()
|
||||
context_json = dict(context_json or {})
|
||||
retry_count = self._resolve_claim_no_retry_count(context_json)
|
||||
|
||||
review_action = str(context_json.get("review_action") or "").strip()
|
||||
attachment_names = self._resolve_attachment_names(context_json)
|
||||
context_documents = self._resolve_context_documents(context_json)
|
||||
|
||||
employee = self._resolve_employee(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
draft_owner_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
association_candidate = self._find_association_candidate(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
employee=employee,
|
||||
)
|
||||
if self._should_defer_multi_document_association(
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
context_documents=context_documents,
|
||||
):
|
||||
document_count = max(len(context_documents), len(attachment_names), self._resolve_attachment_count(context_json))
|
||||
return {
|
||||
"message": (
|
||||
f"检测到你已有草稿 {association_candidate.claim_no},"
|
||||
f"当前新上传了 {document_count} 张票据,请先选择关联到现有草稿,或单独建立新的报销单。"
|
||||
),
|
||||
"draft_only": False,
|
||||
"status": "pending_association_decision",
|
||||
"pending_association_decision": True,
|
||||
"association_candidate_claim_id": association_candidate.id,
|
||||
"association_candidate_claim_no": association_candidate.claim_no,
|
||||
}
|
||||
|
||||
claim = self._find_target_claim(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
)
|
||||
is_new_claim = claim is None
|
||||
before_json = self._serialize_claim(claim) if claim is not None else None
|
||||
application_link_block_result = self._build_application_link_block_result(
|
||||
context_json=context_json,
|
||||
target_claim=claim,
|
||||
)
|
||||
if application_link_block_result is not None:
|
||||
return application_link_block_result
|
||||
if is_new_claim:
|
||||
existing_draft_count = self._count_draft_claims_for_owner(
|
||||
employee=employee,
|
||||
user_id=user_id,
|
||||
)
|
||||
if existing_draft_count >= MAX_DRAFT_CLAIMS_PER_USER:
|
||||
return {
|
||||
"message": (
|
||||
f"你当前已保存 {MAX_DRAFT_CLAIMS_PER_USER} 个草稿,请先完成已保存的草稿,"
|
||||
"才能再次新建草稿。"
|
||||
),
|
||||
"draft_limit_reached": True,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"draft_count": existing_draft_count,
|
||||
"max_draft_count": MAX_DRAFT_CLAIMS_PER_USER,
|
||||
}
|
||||
|
||||
amount = self._resolve_amount(ontology.entities, context_json=context_json)
|
||||
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
|
||||
explicit_expense_type = self._resolve_explicit_review_expense_type(context_json)
|
||||
inferred_expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
|
||||
locked_expense_type = explicit_expense_type
|
||||
if not locked_expense_type and claim is not None and review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
|
||||
locked_expense_type = str(claim.expense_type or "").strip()
|
||||
expense_type = locked_expense_type or inferred_expense_type
|
||||
location = self._resolve_location(message=message, context_json=context_json)
|
||||
reason = self._resolve_reason(
|
||||
message=message,
|
||||
context_json=context_json,
|
||||
allow_message_fallback=is_new_claim,
|
||||
)
|
||||
attachment_count = len(attachment_names) or self._resolve_attachment_count(context_json)
|
||||
|
||||
final_amount = amount if amount is not None else (claim.amount if claim is not None else Decimal("0.00"))
|
||||
final_occurred_at = (
|
||||
occurred_at if occurred_at is not None else (claim.occurred_at if claim is not None else datetime.now(UTC))
|
||||
)
|
||||
final_expense_type = expense_type or (claim.expense_type if claim is not None else "other")
|
||||
final_location = location or (claim.location if claim is not None else "待补充")
|
||||
final_reason = reason or (claim.reason if claim is not None else "待补充")
|
||||
final_attachment_count = (
|
||||
attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0
|
||||
)
|
||||
final_risk_flags = self._merge_persistent_claim_risk_flags(
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
final_risk_flags = self._merge_application_link_flag(
|
||||
final_risk_flags,
|
||||
context_json=context_json,
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
attachment_names=attachment_names,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
context_json=context_json,
|
||||
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
document_specs = []
|
||||
|
||||
if claim is not None and review_action == "link_to_existing_draft" and document_specs:
|
||||
duplicate_result = self._build_duplicate_attachment_block_result(
|
||||
claim=claim,
|
||||
document_specs=document_specs,
|
||||
context_documents=context_documents,
|
||||
)
|
||||
if duplicate_result is not None:
|
||||
return duplicate_result
|
||||
|
||||
try:
|
||||
if claim is None:
|
||||
claim = ExpenseClaim(
|
||||
claim_no=self._generate_claim_no(final_occurred_at),
|
||||
employee_id=employee.id if employee is not None else None,
|
||||
employee_name=draft_owner_name,
|
||||
department_id=employee.organization_unit_id if employee is not None else None,
|
||||
department_name=self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
),
|
||||
project_code=self._resolve_project_code(ontology.entities),
|
||||
expense_type=final_expense_type,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
amount=final_amount,
|
||||
currency="CNY",
|
||||
invoice_count=final_attachment_count,
|
||||
occurred_at=final_occurred_at,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=final_risk_flags,
|
||||
)
|
||||
self.db.add(claim)
|
||||
else:
|
||||
claim.employee_id = employee.id if employee is not None else claim.employee_id
|
||||
claim.employee_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
fallback=claim.employee_name,
|
||||
)
|
||||
)
|
||||
claim.department_id = employee.organization_unit_id if employee is not None else claim.department_id
|
||||
claim.department_name = self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
fallback=claim.department_name,
|
||||
)
|
||||
claim.project_code = self._resolve_project_code(ontology.entities) or claim.project_code
|
||||
claim.expense_type = final_expense_type
|
||||
claim.reason = final_reason
|
||||
claim.location = final_location
|
||||
claim.amount = final_amount
|
||||
claim.invoice_count = final_attachment_count
|
||||
claim.occurred_at = final_occurred_at
|
||||
claim.status = "draft"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = final_risk_flags
|
||||
|
||||
self.db.flush()
|
||||
skip_primary_item = self._should_skip_application_link_placeholder_item(
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
document_specs=document_specs,
|
||||
attachment_count=attachment_count,
|
||||
amount=amount,
|
||||
)
|
||||
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||
if review_action == "link_to_existing_draft" and claim.items:
|
||||
self._append_document_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
else:
|
||||
self._replace_claim_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
elif skip_primary_item:
|
||||
self._clear_application_link_placeholder_items(claim, context_json=context_json)
|
||||
if claim.items:
|
||||
self._sync_claim_from_items(claim)
|
||||
else:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
attachment_names=attachment_names,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
if locked_expense_type:
|
||||
claim.expense_type = locked_expense_type
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
except IntegrityError as exc:
|
||||
self.db.rollback()
|
||||
if (
|
||||
is_new_claim
|
||||
and retry_count < MAX_CLAIM_NO_RETRY_ATTEMPTS
|
||||
and self._is_claim_no_conflict_error(exc)
|
||||
):
|
||||
retry_context = dict(context_json)
|
||||
retry_context["_claim_no_retry_count"] = retry_count + 1
|
||||
return self.upsert_draft_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=user_id or claim.employee_name or "anonymous",
|
||||
action="expense_claim.draft_upsert",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。"
|
||||
"请核对识别结果,确认无误后继续提交。"
|
||||
),
|
||||
"draft_only": True,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"status": claim.status,
|
||||
"amount": float(claim.amount),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
class ExpenseClaimApplicationLinkMixin:
|
||||
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
@@ -826,6 +539,8 @@ class ExpenseClaimDraftFlowMixin:
|
||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ExpenseClaimDraftAttachmentAssociationMixin:
|
||||
def _find_target_claim(
|
||||
self,
|
||||
*,
|
||||
@@ -1062,3 +777,293 @@ class ExpenseClaimDraftFlowMixin:
|
||||
"amount": float(claim.amount or Decimal("0.00")),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
|
||||
class ExpenseClaimDraftFlowMixin(ExpenseClaimApplicationLinkMixin, ExpenseClaimDraftAttachmentAssociationMixin):
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_ready()
|
||||
context_json = dict(context_json or {})
|
||||
retry_count = self._resolve_claim_no_retry_count(context_json)
|
||||
|
||||
review_action = str(context_json.get("review_action") or "").strip()
|
||||
attachment_names = self._resolve_attachment_names(context_json)
|
||||
context_documents = self._resolve_context_documents(context_json)
|
||||
|
||||
employee = self._resolve_employee(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
draft_owner_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
association_candidate = self._find_association_candidate(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
employee=employee,
|
||||
)
|
||||
if self._should_defer_multi_document_association(
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
context_documents=context_documents,
|
||||
):
|
||||
document_count = max(len(context_documents), len(attachment_names), self._resolve_attachment_count(context_json))
|
||||
return {
|
||||
"message": (
|
||||
f"检测到你已有草稿 {association_candidate.claim_no},"
|
||||
f"当前新上传了 {document_count} 张票据,请先选择关联到现有草稿,或单独建立新的报销单。"
|
||||
),
|
||||
"draft_only": False,
|
||||
"status": "pending_association_decision",
|
||||
"pending_association_decision": True,
|
||||
"association_candidate_claim_id": association_candidate.id,
|
||||
"association_candidate_claim_no": association_candidate.claim_no,
|
||||
}
|
||||
|
||||
claim = self._find_target_claim(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
)
|
||||
is_new_claim = claim is None
|
||||
before_json = self._serialize_claim(claim) if claim is not None else None
|
||||
application_link_block_result = self._build_application_link_block_result(
|
||||
context_json=context_json,
|
||||
target_claim=claim,
|
||||
)
|
||||
if application_link_block_result is not None:
|
||||
return application_link_block_result
|
||||
if is_new_claim:
|
||||
existing_draft_count = self._count_draft_claims_for_owner(
|
||||
employee=employee,
|
||||
user_id=user_id,
|
||||
)
|
||||
if existing_draft_count >= MAX_DRAFT_CLAIMS_PER_USER:
|
||||
return {
|
||||
"message": (
|
||||
f"你当前已保存 {MAX_DRAFT_CLAIMS_PER_USER} 个草稿,请先完成已保存的草稿,"
|
||||
"才能再次新建草稿。"
|
||||
),
|
||||
"draft_limit_reached": True,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"draft_count": existing_draft_count,
|
||||
"max_draft_count": MAX_DRAFT_CLAIMS_PER_USER,
|
||||
}
|
||||
|
||||
amount = self._resolve_amount(ontology.entities, context_json=context_json)
|
||||
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
|
||||
explicit_expense_type = self._resolve_explicit_review_expense_type(context_json)
|
||||
inferred_expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
|
||||
locked_expense_type = explicit_expense_type
|
||||
if not locked_expense_type and claim is not None and review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
|
||||
locked_expense_type = str(claim.expense_type or "").strip()
|
||||
expense_type = locked_expense_type or inferred_expense_type
|
||||
location = self._resolve_location(message=message, context_json=context_json)
|
||||
reason = self._resolve_reason(
|
||||
message=message,
|
||||
context_json=context_json,
|
||||
allow_message_fallback=is_new_claim,
|
||||
)
|
||||
attachment_count = len(attachment_names) or self._resolve_attachment_count(context_json)
|
||||
|
||||
final_amount = amount if amount is not None else (claim.amount if claim is not None else Decimal("0.00"))
|
||||
final_occurred_at = (
|
||||
occurred_at if occurred_at is not None else (claim.occurred_at if claim is not None else datetime.now(UTC))
|
||||
)
|
||||
final_expense_type = expense_type or (claim.expense_type if claim is not None else "other")
|
||||
final_location = location or (claim.location if claim is not None else "待补充")
|
||||
final_reason = reason or (claim.reason if claim is not None else "待补充")
|
||||
final_attachment_count = (
|
||||
attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0
|
||||
)
|
||||
final_risk_flags = self._merge_persistent_claim_risk_flags(
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
final_risk_flags = self._merge_application_link_flag(
|
||||
final_risk_flags,
|
||||
context_json=context_json,
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
attachment_names=attachment_names,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
context_json=context_json,
|
||||
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
document_specs = []
|
||||
|
||||
if claim is not None and review_action == "link_to_existing_draft" and document_specs:
|
||||
duplicate_result = self._build_duplicate_attachment_block_result(
|
||||
claim=claim,
|
||||
document_specs=document_specs,
|
||||
context_documents=context_documents,
|
||||
)
|
||||
if duplicate_result is not None:
|
||||
return duplicate_result
|
||||
|
||||
try:
|
||||
if claim is None:
|
||||
claim = ExpenseClaim(
|
||||
claim_no=self._generate_claim_no(final_occurred_at),
|
||||
employee_id=employee.id if employee is not None else None,
|
||||
employee_name=draft_owner_name,
|
||||
department_id=employee.organization_unit_id if employee is not None else None,
|
||||
department_name=self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
),
|
||||
project_code=self._resolve_project_code(ontology.entities),
|
||||
expense_type=final_expense_type,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
amount=final_amount,
|
||||
currency="CNY",
|
||||
invoice_count=final_attachment_count,
|
||||
occurred_at=final_occurred_at,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=final_risk_flags,
|
||||
)
|
||||
self.db.add(claim)
|
||||
else:
|
||||
claim.employee_id = employee.id if employee is not None else claim.employee_id
|
||||
claim.employee_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
fallback=claim.employee_name,
|
||||
)
|
||||
)
|
||||
claim.department_id = employee.organization_unit_id if employee is not None else claim.department_id
|
||||
claim.department_name = self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
fallback=claim.department_name,
|
||||
)
|
||||
claim.project_code = self._resolve_project_code(ontology.entities) or claim.project_code
|
||||
claim.expense_type = final_expense_type
|
||||
claim.reason = final_reason
|
||||
claim.location = final_location
|
||||
claim.amount = final_amount
|
||||
claim.invoice_count = final_attachment_count
|
||||
claim.occurred_at = final_occurred_at
|
||||
claim.status = "draft"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = final_risk_flags
|
||||
|
||||
self.db.flush()
|
||||
skip_primary_item = self._should_skip_application_link_placeholder_item(
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
document_specs=document_specs,
|
||||
attachment_count=attachment_count,
|
||||
amount=amount,
|
||||
)
|
||||
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||
if review_action == "link_to_existing_draft" and claim.items:
|
||||
self._append_document_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
else:
|
||||
self._replace_claim_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
elif skip_primary_item:
|
||||
self._clear_application_link_placeholder_items(claim, context_json=context_json)
|
||||
if claim.items:
|
||||
self._sync_claim_from_items(claim)
|
||||
else:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
attachment_names=attachment_names,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
if locked_expense_type:
|
||||
claim.expense_type = locked_expense_type
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
except IntegrityError as exc:
|
||||
self.db.rollback()
|
||||
if (
|
||||
is_new_claim
|
||||
and retry_count < MAX_CLAIM_NO_RETRY_ATTEMPTS
|
||||
and self._is_claim_no_conflict_error(exc)
|
||||
):
|
||||
retry_context = dict(context_json)
|
||||
retry_context["_claim_no_retry_count"] = retry_count + 1
|
||||
return self.upsert_draft_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=user_id or claim.employee_name or "anonymous",
|
||||
action="expense_claim.draft_upsert",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。"
|
||||
"请核对识别结果,确认无误后继续提交。"
|
||||
),
|
||||
"draft_only": True,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"status": claim.status,
|
||||
"amount": float(claim.amount),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -140,183 +140,7 @@ from app.services.ocr import OcrService
|
||||
|
||||
|
||||
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimPaginationMixin,
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApprovalRoutingMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimPreReviewMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
ExpenseClaimDraftFlowMixin,
|
||||
ExpenseClaimDraftPersistenceMixin,
|
||||
ExpenseClaimDocumentItemBuilderMixin,
|
||||
ExpenseClaimDocumentParsingMixin,
|
||||
ExpenseClaimOntologyResolverMixin,
|
||||
ExpenseClaimAttachmentDocumentMixin,
|
||||
ExpenseClaimAttachmentAnalysisMixin,
|
||||
ExpenseClaimReadModelMixin,
|
||||
ExpenseClaimRiskReviewMixin,
|
||||
ExpenseClaimWorkflowRepairMixin,
|
||||
):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.audit_service = AuditLogService(db)
|
||||
self._access_policy = ExpenseClaimAccessPolicy(db)
|
||||
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
||||
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
||||
|
||||
@staticmethod
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
return (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
or document_type in {"application", "expense_application"}
|
||||
)
|
||||
|
||||
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if self._is_missing_value(claim.employee_name):
|
||||
issues.append("申请人未完善")
|
||||
if self._is_missing_value(claim.department_name):
|
||||
issues.append("所属部门未完善")
|
||||
if self._is_missing_value(claim.expense_type):
|
||||
issues.append("申请类型未完善")
|
||||
if self._is_missing_value(claim.reason):
|
||||
issues.append("申请事由未完善")
|
||||
if self._is_missing_value(claim.location):
|
||||
issues.append("业务地点未完善")
|
||||
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||||
issues.append("预计总费用未完善")
|
||||
if claim.occurred_at is None:
|
||||
issues.append("申请时间未完善")
|
||||
return issues
|
||||
|
||||
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
self._repair_duplicate_budget_approval_stages([claim])
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if (
|
||||
self._access_policy.has_privileged_claim_access(current_user)
|
||||
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
||||
):
|
||||
return True
|
||||
if self._access_policy.can_approve_claim(current_user, claim):
|
||||
return True
|
||||
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
*,
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimUpdate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_pending_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.update",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
class ExpenseClaimStandardAdjustmentMixin:
|
||||
@staticmethod
|
||||
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
|
||||
try:
|
||||
@@ -579,6 +403,8 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
class ExpenseClaimItemActionMixin:
|
||||
def update_claim_item(
|
||||
self,
|
||||
*,
|
||||
@@ -736,11 +562,6 @@ class ExpenseClaimService(
|
||||
"item_id": item.id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def submit_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
@@ -840,11 +661,6 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None and current_user.is_admin:
|
||||
@@ -1035,4 +851,161 @@ class ExpenseClaimService(
|
||||
return claim
|
||||
|
||||
|
||||
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.audit_service = AuditLogService(db)
|
||||
self._access_policy = ExpenseClaimAccessPolicy(db)
|
||||
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
||||
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
||||
|
||||
@staticmethod
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
return (
|
||||
is_application_claim_no(claim_no)
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
or document_type in {"application", "expense_application"}
|
||||
)
|
||||
|
||||
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if self._is_missing_value(claim.employee_name):
|
||||
issues.append("申请人未完善")
|
||||
if self._is_missing_value(claim.department_name):
|
||||
issues.append("所属部门未完善")
|
||||
if self._is_missing_value(claim.expense_type):
|
||||
issues.append("申请类型未完善")
|
||||
if self._is_missing_value(claim.reason):
|
||||
issues.append("申请事由未完善")
|
||||
if self._is_missing_value(claim.location):
|
||||
issues.append("业务地点未完善")
|
||||
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||||
issues.append("预计总费用未完善")
|
||||
if claim.occurred_at is None:
|
||||
issues.append("申请时间未完善")
|
||||
return issues
|
||||
|
||||
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
claims = list(self.db.scalars(stmt).all())
|
||||
self._repair_duplicate_budget_approval_stages(claims)
|
||||
return self._access_policy.attach_budget_approval_snapshots(claims)
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(
|
||||
selectinload(ExpenseClaim.items),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
|
||||
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
||||
)
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
claim = self.db.scalar(stmt)
|
||||
if claim is not None:
|
||||
self._repair_duplicate_budget_approval_stages([claim])
|
||||
return self._access_policy.attach_approval_snapshot(claim)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if (
|
||||
self._access_policy.has_privileged_claim_access(current_user)
|
||||
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
||||
):
|
||||
return True
|
||||
if self._access_policy.can_approve_claim(current_user, claim):
|
||||
return True
|
||||
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
*,
|
||||
claim_id: str,
|
||||
payload: ExpenseClaimUpdate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_pending_claim(claim)
|
||||
before_json = self._serialize_claim(claim)
|
||||
|
||||
if payload.reason is not None:
|
||||
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
||||
|
||||
if not self._is_expense_application_claim(claim):
|
||||
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.update",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
@@ -28,71 +28,7 @@ from app.services.finance_dashboard_constants import (
|
||||
)
|
||||
|
||||
|
||||
class FinanceDashboardService(BudgetSupportMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_dashboard(
|
||||
self,
|
||||
*,
|
||||
range_key: str = "近10日",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
trend_range: str = "近12天",
|
||||
department_range: str = "本月",
|
||||
) -> FinanceDashboardRead:
|
||||
now = datetime.now(UTC)
|
||||
start, end, resolved_key = self._resolve_scope(
|
||||
range_key=range_key,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
now=now,
|
||||
)
|
||||
previous_start = start - (end - start)
|
||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(
|
||||
trend_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
ranking_start, ranking_end = self._resolve_ranking_scope(
|
||||
department_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
|
||||
claims = [
|
||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||
]
|
||||
scope_claims = self._claims_between(claims, start, end)
|
||||
previous_claims = self._claims_between(claims, previous_start, start)
|
||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
|
||||
|
||||
totals = self._totals(scope_claims)
|
||||
previous_totals = self._totals(previous_claims)
|
||||
|
||||
return FinanceDashboardRead(
|
||||
range_key=resolved_key,
|
||||
start_date=start.date().isoformat(),
|
||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||
totals=totals,
|
||||
metric_meta=self._metric_meta(totals, previous_totals),
|
||||
trend=self._trend(trend_labels, trend_claims, now),
|
||||
spend_by_category=self._spend_by_category(scope_claims),
|
||||
exception_mix=self._payment_status_mix(scope_claims),
|
||||
department_ranking=self._department_ranking(ranking_claims),
|
||||
department_employee_mix=self._department_employee_mix(ranking_claims),
|
||||
employee_ranking=self._employee_ranking(ranking_claims),
|
||||
top_claims=self._top_claims(ranking_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims),
|
||||
budget_summary=self._budget_summary(now.year),
|
||||
budget_metrics=self._budget_metrics(now.year),
|
||||
)
|
||||
|
||||
class FinanceDashboardMetricMixin:
|
||||
def _fetch_claims(self) -> list[ExpenseClaim]:
|
||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
@@ -456,6 +392,8 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class FinanceDashboardBudgetAndLabelMixin:
|
||||
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
spend_claims = [
|
||||
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
@@ -882,3 +820,70 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
prefix = "-¥" if value < Decimal("0") else "¥"
|
||||
amount = abs(value)
|
||||
return f"{prefix}{amount:,.0f}"
|
||||
|
||||
|
||||
class FinanceDashboardService(FinanceDashboardMetricMixin, FinanceDashboardBudgetAndLabelMixin, BudgetSupportMixin):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_dashboard(
|
||||
self,
|
||||
*,
|
||||
range_key: str = "近10日",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
trend_range: str = "近12天",
|
||||
department_range: str = "本月",
|
||||
) -> FinanceDashboardRead:
|
||||
now = datetime.now(UTC)
|
||||
start, end, resolved_key = self._resolve_scope(
|
||||
range_key=range_key,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
now=now,
|
||||
)
|
||||
previous_start = start - (end - start)
|
||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(
|
||||
trend_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
ranking_start, ranking_end = self._resolve_ranking_scope(
|
||||
department_range,
|
||||
now,
|
||||
fallback_start=start,
|
||||
fallback_end=end,
|
||||
)
|
||||
|
||||
claims = [
|
||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||
]
|
||||
scope_claims = self._claims_between(claims, start, end)
|
||||
previous_claims = self._claims_between(claims, previous_start, start)
|
||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
|
||||
|
||||
totals = self._totals(scope_claims)
|
||||
previous_totals = self._totals(previous_claims)
|
||||
|
||||
return FinanceDashboardRead(
|
||||
range_key=resolved_key,
|
||||
start_date=start.date().isoformat(),
|
||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||
totals=totals,
|
||||
metric_meta=self._metric_meta(totals, previous_totals),
|
||||
trend=self._trend(trend_labels, trend_claims, now),
|
||||
spend_by_category=self._spend_by_category(scope_claims),
|
||||
exception_mix=self._payment_status_mix(scope_claims),
|
||||
department_ranking=self._department_ranking(ranking_claims),
|
||||
department_employee_mix=self._department_employee_mix(ranking_claims),
|
||||
employee_ranking=self._employee_ranking(ranking_claims),
|
||||
top_claims=self._top_claims(ranking_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims),
|
||||
budget_summary=self._budget_summary(now.year),
|
||||
budget_metrics=self._budget_metrics(now.year),
|
||||
)
|
||||
|
||||
|
||||
@@ -30,265 +30,7 @@ class ExecutionOutcome:
|
||||
failed_tool_count: int
|
||||
|
||||
|
||||
class OrchestratorExecutionEngine:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
db: Session,
|
||||
run_service,
|
||||
expense_claim_service,
|
||||
knowledge_service,
|
||||
user_agent_service,
|
||||
database_query_builder,
|
||||
trace_service=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.run_service = run_service
|
||||
self.expense_claim_service = expense_claim_service
|
||||
self.knowledge_service = knowledge_service
|
||||
self.user_agent_service = user_agent_service
|
||||
self.database_query_builder = database_query_builder
|
||||
self.trace_service = trace_service
|
||||
|
||||
def _execute_user_agent(
|
||||
self,
|
||||
*,
|
||||
payload: OrchestratorRequest,
|
||||
run_id: str,
|
||||
ontology: OntologyParseResult,
|
||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||
requires_confirmation: bool,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||
if requires_confirmation:
|
||||
response, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="user_agent.confirmation_placeholder",
|
||||
request_json={
|
||||
"message": payload.message,
|
||||
"permission_level": ontology.permission.level,
|
||||
},
|
||||
context_json=context_json,
|
||||
executor=lambda: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"{ontology.permission.reason} 当前仅返回确认摘要,不直接执行动作。",
|
||||
},
|
||||
fallback_factory=lambda exc: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"确认摘要生成失败,已阻断自动执行:{exc}",
|
||||
},
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
result={**response, "degraded": degraded},
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
next_step = self._resolve_next_step(
|
||||
ontology,
|
||||
payload.source,
|
||||
context_json=context_json,
|
||||
)
|
||||
if next_step == "query_database":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name=self._database_tool_name(ontology.scenario),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self.database_query_builder.build_database_answer(
|
||||
ontology,
|
||||
user_id=payload.user_id,
|
||||
context_json=context_json,
|
||||
message=payload.message or "",
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"数据库查询暂时不可用,已返回降级说明:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "search_knowledge":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name="knowledge.search",
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_knowledge_answer(
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
capabilities=capabilities,
|
||||
context_json=context_json,
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"知识检索暂时不可用,建议稍后重试:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "run_rule":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.RULE_ENGINE.value,
|
||||
tool_name=self._rule_tool_name(capabilities),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_rule_answer(ontology),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"规则检查暂时不可用,已返回人工复核建议:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
tool_type = AgentToolType.LLM.value
|
||||
tool_name = "user_agent.draft_placeholder"
|
||||
executor = lambda: {
|
||||
"message": (
|
||||
f"已生成 {ontology.scenario} 场景草稿,"
|
||||
"占位能力后续由 Day 5 User Agent 接管。"
|
||||
),
|
||||
"draft_only": True,
|
||||
}
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||
tool_type = (
|
||||
AgentToolType.DATABASE.value
|
||||
if is_persistence_action
|
||||
else AgentToolType.LLM.value
|
||||
)
|
||||
tool_name = (
|
||||
"database.expense_claims.save_or_submit"
|
||||
if is_persistence_action
|
||||
else "user_agent.expense_review_preview"
|
||||
)
|
||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": (
|
||||
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||
if is_persistence_action
|
||||
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||
),
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=executor,
|
||||
fallback_factory=fallback_factory,
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
class OrchestratorExecutionTaskMixin:
|
||||
def _execute_hermes(
|
||||
self,
|
||||
*,
|
||||
@@ -600,6 +342,8 @@ class OrchestratorExecutionEngine:
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorExecutionHelperMixin:
|
||||
@staticmethod
|
||||
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
|
||||
if task_asset is None:
|
||||
@@ -898,3 +642,263 @@ class OrchestratorExecutionEngine:
|
||||
"permission": ontology.permission.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
class OrchestratorExecutionEngine(OrchestratorExecutionTaskMixin, OrchestratorExecutionHelperMixin):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
db: Session,
|
||||
run_service,
|
||||
expense_claim_service,
|
||||
knowledge_service,
|
||||
user_agent_service,
|
||||
database_query_builder,
|
||||
trace_service=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.run_service = run_service
|
||||
self.expense_claim_service = expense_claim_service
|
||||
self.knowledge_service = knowledge_service
|
||||
self.user_agent_service = user_agent_service
|
||||
self.database_query_builder = database_query_builder
|
||||
self.trace_service = trace_service
|
||||
|
||||
def _execute_user_agent(
|
||||
self,
|
||||
*,
|
||||
payload: OrchestratorRequest,
|
||||
run_id: str,
|
||||
ontology: OntologyParseResult,
|
||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||
requires_confirmation: bool,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||
if requires_confirmation:
|
||||
response, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="user_agent.confirmation_placeholder",
|
||||
request_json={
|
||||
"message": payload.message,
|
||||
"permission_level": ontology.permission.level,
|
||||
},
|
||||
context_json=context_json,
|
||||
executor=lambda: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"{ontology.permission.reason} 当前仅返回确认摘要,不直接执行动作。",
|
||||
},
|
||||
fallback_factory=lambda exc: {
|
||||
"confirmation_title": "操作需要确认",
|
||||
"message": f"确认摘要生成失败,已阻断自动执行:{exc}",
|
||||
},
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
result={**response, "degraded": degraded},
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
next_step = self._resolve_next_step(
|
||||
ontology,
|
||||
payload.source,
|
||||
context_json=context_json,
|
||||
)
|
||||
if next_step == "query_database":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name=self._database_tool_name(ontology.scenario),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self.database_query_builder.build_database_answer(
|
||||
ontology,
|
||||
user_id=payload.user_id,
|
||||
context_json=context_json,
|
||||
message=payload.message or "",
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"数据库查询暂时不可用,已返回降级说明:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "search_knowledge":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name="knowledge.search",
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_knowledge_answer(
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
capabilities=capabilities,
|
||||
context_json=context_json,
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"知识检索暂时不可用,建议稍后重试:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
if next_step == "run_rule":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.RULE_ENGINE.value,
|
||||
tool_name=self._rule_tool_name(capabilities),
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=lambda: self._build_rule_answer(ontology),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"规则检查暂时不可用,已返回人工复核建议:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
tool_type = AgentToolType.LLM.value
|
||||
tool_name = "user_agent.draft_placeholder"
|
||||
executor = lambda: {
|
||||
"message": (
|
||||
f"已生成 {ontology.scenario} 场景草稿,"
|
||||
"占位能力后续由 Day 5 User Agent 接管。"
|
||||
),
|
||||
"draft_only": True,
|
||||
}
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||
tool_type = (
|
||||
AgentToolType.DATABASE.value
|
||||
if is_persistence_action
|
||||
else AgentToolType.LLM.value
|
||||
)
|
||||
tool_name = (
|
||||
"database.expense_claims.save_or_submit"
|
||||
if is_persistence_action
|
||||
else "user_agent.expense_review_preview"
|
||||
)
|
||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": (
|
||||
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||
if is_persistence_action
|
||||
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||
),
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
request_json=self._build_ontology_json(ontology),
|
||||
context_json=context_json,
|
||||
executor=executor,
|
||||
fallback_factory=fallback_factory,
|
||||
)
|
||||
result = self._build_user_agent_result(
|
||||
self.user_agent_service.respond(
|
||||
UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload=tool_payload,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=degraded,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
),
|
||||
degraded=degraded,
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,47 +17,7 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
||||
ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->|-|-|—|~|~|/|、|,|,|;|;)\s*")
|
||||
|
||||
|
||||
class RiskRuleTemplateExecutor:
|
||||
def evaluate_with_trace(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
result = self.evaluate(manifest, claim=claim, contexts=contexts)
|
||||
return {
|
||||
"hit": result is not None,
|
||||
"result": result,
|
||||
"trace": build_risk_rule_execution_trace(manifest, result=result),
|
||||
}
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
|
||||
|
||||
if template_key == "field_required_v1":
|
||||
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
|
||||
if template_key == "field_compare_v1":
|
||||
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return self._evaluate_city_consistency_rule(
|
||||
params,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
|
||||
if template_key == "keyword_match_v1":
|
||||
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
|
||||
return None
|
||||
|
||||
class RiskRuleTemplateConditionMixin:
|
||||
def _evaluate_required_fields(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
@@ -488,6 +448,8 @@ class RiskRuleTemplateExecutor:
|
||||
"right_values": right_numbers[:8],
|
||||
}
|
||||
|
||||
|
||||
class RiskRuleTemplateValueResolverMixin:
|
||||
def _resolve_group_values(
|
||||
self,
|
||||
field_keys: list[str],
|
||||
@@ -1162,3 +1124,46 @@ class RiskRuleTemplateExecutor:
|
||||
def _resolve_message(params: dict[str, Any], *, fallback: str) -> str:
|
||||
template = str(params.get("message_template") or "").strip()
|
||||
return template or fallback
|
||||
|
||||
|
||||
class RiskRuleTemplateExecutor(RiskRuleTemplateConditionMixin, RiskRuleTemplateValueResolverMixin):
|
||||
def evaluate_with_trace(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
result = self.evaluate(manifest, claim=claim, contexts=contexts)
|
||||
return {
|
||||
"hit": result is not None,
|
||||
"result": result,
|
||||
"trace": build_risk_rule_execution_trace(manifest, result=result),
|
||||
}
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
contexts: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
template_key = str(manifest.get("template_key") or params.get("template_key") or "").strip()
|
||||
|
||||
if template_key == "field_required_v1":
|
||||
return self._evaluate_required_fields(params, claim=claim, contexts=contexts)
|
||||
if template_key == "field_compare_v1":
|
||||
if str(params.get("semantic_type") or "").strip() in CITY_CONSISTENCY_SEMANTIC_TYPES:
|
||||
return self._evaluate_city_consistency_rule(
|
||||
params,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
return self._evaluate_compare_conditions(params, claim=claim, contexts=contexts)
|
||||
if template_key == "keyword_match_v1":
|
||||
return self._evaluate_keyword_match(params, claim=claim, contexts=contexts)
|
||||
if template_key == COMPOSITE_RULE_TEMPLATE_KEY:
|
||||
return self._evaluate_composite_rule(params, claim=claim, contexts=contexts)
|
||||
return None
|
||||
|
||||
|
||||
@@ -131,69 +131,7 @@ class PlannedTaskDraft:
|
||||
index: int
|
||||
|
||||
|
||||
class StewardPlannerService:
|
||||
"""小财管家第一版规划服务:只生成计划,不执行入库类动作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
intent_agent: StewardIntentAgent | None = None,
|
||||
off_topic_agent: StewardOffTopicAgent | None = None,
|
||||
) -> None:
|
||||
self.intent_agent = intent_agent
|
||||
self.off_topic_agent = off_topic_agent
|
||||
|
||||
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
|
||||
message = self._clean_text(request.message)
|
||||
if not message:
|
||||
raise ValueError("小财管家需要一段任务描述。")
|
||||
|
||||
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
|
||||
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
|
||||
scenario = self._classify_irrelevant_input(message, request)
|
||||
if scenario is not None:
|
||||
return self._build_off_topic_plan(request, scenario=scenario)
|
||||
model_call_traces: list[dict[str, Any]] = []
|
||||
fallback_reason = ""
|
||||
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
|
||||
try:
|
||||
intent_result = self.intent_agent.detect(
|
||||
request,
|
||||
base_date=base_date,
|
||||
canonical_fields=list(BUSINESS_CANONICAL_FIELD_ORDER),
|
||||
)
|
||||
if intent_result is not None:
|
||||
model_call_traces = intent_result.model_call_traces
|
||||
llm_plan = StewardModelPlanBuilder(self).build(
|
||||
intent_result,
|
||||
request=request,
|
||||
base_date=base_date,
|
||||
)
|
||||
if llm_plan is not None:
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return self._build_pending_flow_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=(
|
||||
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
|
||||
"服务端已改为候选流程确认,避免误入申请流程。"
|
||||
),
|
||||
planning_source="llm_function_call",
|
||||
)
|
||||
return llm_plan
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
|
||||
except Exception as exc:
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = f"主模型 function calling 调用失败,已切换到规则兜底:{exc}"
|
||||
|
||||
return self._build_rule_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
|
||||
class StewardPlannerFallbackMixin:
|
||||
def _should_use_model_intent_recognition(
|
||||
self,
|
||||
message: str,
|
||||
@@ -602,6 +540,8 @@ class StewardPlannerService:
|
||||
|
||||
return drafts
|
||||
|
||||
|
||||
class StewardPlannerExtractionMixin:
|
||||
def _has_multiple_financial_demands(self, message: str) -> bool:
|
||||
task_drafts = self._extract_task_drafts(message)
|
||||
if len(task_drafts) > 1:
|
||||
@@ -1219,3 +1159,68 @@ class StewardPlannerService:
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
class StewardPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractionMixin):
|
||||
"""小财管家第一版规划服务:只生成计划,不执行入库类动作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
intent_agent: StewardIntentAgent | None = None,
|
||||
off_topic_agent: StewardOffTopicAgent | None = None,
|
||||
) -> None:
|
||||
self.intent_agent = intent_agent
|
||||
self.off_topic_agent = off_topic_agent
|
||||
|
||||
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
|
||||
message = self._clean_text(request.message)
|
||||
if not message:
|
||||
raise ValueError("小财管家需要一段任务描述。")
|
||||
|
||||
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
|
||||
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
|
||||
scenario = self._classify_irrelevant_input(message, request)
|
||||
if scenario is not None:
|
||||
return self._build_off_topic_plan(request, scenario=scenario)
|
||||
model_call_traces: list[dict[str, Any]] = []
|
||||
fallback_reason = ""
|
||||
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
|
||||
try:
|
||||
intent_result = self.intent_agent.detect(
|
||||
request,
|
||||
base_date=base_date,
|
||||
canonical_fields=list(BUSINESS_CANONICAL_FIELD_ORDER),
|
||||
)
|
||||
if intent_result is not None:
|
||||
model_call_traces = intent_result.model_call_traces
|
||||
llm_plan = StewardModelPlanBuilder(self).build(
|
||||
intent_result,
|
||||
request=request,
|
||||
base_date=base_date,
|
||||
)
|
||||
if llm_plan is not None:
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return self._build_pending_flow_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=(
|
||||
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
|
||||
"服务端已改为候选流程确认,避免误入申请流程。"
|
||||
),
|
||||
planning_source="llm_function_call",
|
||||
)
|
||||
return llm_plan
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
|
||||
except Exception as exc:
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = f"主模型 function calling 调用失败,已切换到规则兜底:{exc}"
|
||||
|
||||
return self._build_rule_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
|
||||
|
||||
@@ -151,432 +151,7 @@ APPLICATION_DUPLICATE_IGNORED_STATUSES = {
|
||||
}
|
||||
|
||||
|
||||
class UserAgentApplicationMixin:
|
||||
@staticmethod
|
||||
def _is_expense_application_request(payload: UserAgentRequest) -> bool:
|
||||
context_json = payload.context_json or {}
|
||||
context_values = {
|
||||
str(context_json.get("session_type") or "").strip(),
|
||||
str(context_json.get("entry_source") or "").strip(),
|
||||
str(context_json.get("document_type") or "").strip(),
|
||||
str(context_json.get("application_stage") or "").strip(),
|
||||
}
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
context_values.update(
|
||||
{
|
||||
str(conversation_state.get("session_type") or "").strip(),
|
||||
str(conversation_state.get("entry_source") or "").strip(),
|
||||
str(conversation_state.get("document_type") or "").strip(),
|
||||
str(conversation_state.get("application_stage") or "").strip(),
|
||||
}
|
||||
)
|
||||
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||
return True
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||
looks_like_submit = (
|
||||
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
)
|
||||
if not looks_like_submit:
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "assistant"
|
||||
and (
|
||||
"#application-submit" in str(item.get("content") or "")
|
||||
or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
|
||||
)
|
||||
for item in history[-6:]
|
||||
)
|
||||
|
||||
def _build_expense_application_response(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
risk_flags: list[str],
|
||||
) -> UserAgentResponse:
|
||||
facts = self._resolve_expense_application_facts(payload)
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
application_claim = None
|
||||
if step in {"draft", "submitted"}:
|
||||
editable_claim = self._find_editable_expense_application_record(payload)
|
||||
if editable_claim is not None:
|
||||
application_claim = self._update_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
editable_claim,
|
||||
submit=step == "submitted",
|
||||
)
|
||||
facts["application_edit_mode"] = "true"
|
||||
elif step == "submitted":
|
||||
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||
if application_claim is not None:
|
||||
step = "duplicate"
|
||||
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=True,
|
||||
)
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=False,
|
||||
)
|
||||
if application_claim is not None:
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
facts["application_claim_id"] = application_claim.id
|
||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||
return UserAgentResponse(
|
||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||
citations=[],
|
||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||
query_payload=None,
|
||||
draft_payload=(
|
||||
self._build_persisted_application_payload(application_claim, facts)
|
||||
if step in {"draft", "submitted"}
|
||||
else None
|
||||
),
|
||||
review_payload=None,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=step == "preview",
|
||||
)
|
||||
|
||||
def _build_expense_application_answer(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
step: str,
|
||||
) -> str:
|
||||
recognized_table = build_application_summary_table(facts, include_empty=False)
|
||||
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
missing_text = "、".join(
|
||||
self._display_application_slot_label(item)
|
||||
for item in missing_fields
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||
"已识别信息:\n" + recognized_table,
|
||||
f"当前还需要补充:{missing_text}。",
|
||||
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "draft":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
return "\n\n".join(
|
||||
[
|
||||
"申请草稿已保存。",
|
||||
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
|
||||
"当前节点:待提交。",
|
||||
"后续可进入单据详情继续核对、补充或提交审批。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "submitted":
|
||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||
submitted_title = (
|
||||
"申请单据已修改并重新提交,已进入审批流程。"
|
||||
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
|
||||
else "申请单据已生成,并已进入审批流程。"
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
submitted_title,
|
||||
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
||||
f"申请单号:{application_no}",
|
||||
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "duplicate":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||
time_label = resolve_application_time_label(facts)
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||
f"已有申请单号:{application_no}",
|
||||
f"当前节点:{stage}",
|
||||
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是费用申请核对结果,请核对:",
|
||||
build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
|
||||
facts = {
|
||||
"time": "",
|
||||
"location": "",
|
||||
"reason": "",
|
||||
"days": "",
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
"applicant": "",
|
||||
"grade": "",
|
||||
"department": "",
|
||||
"position": "",
|
||||
"manager_name": "",
|
||||
"lodging_daily_cap": "",
|
||||
"subsidy_daily_cap": "",
|
||||
"transport_policy": "",
|
||||
"policy_estimate": "",
|
||||
"matched_city": "",
|
||||
"rule_name": "",
|
||||
"rule_version": "",
|
||||
"hotel_amount": "",
|
||||
"allowance_amount": "",
|
||||
"transport_estimated_amount": "",
|
||||
"transport_estimate_source": "",
|
||||
"transport_estimate_confidence": "",
|
||||
"policy_total_amount": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
"time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
|
||||
"location": self._resolve_application_location(payload, message=message, use_entities=is_current),
|
||||
"reason": self._resolve_application_reason(message),
|
||||
"days": self._resolve_application_days(message),
|
||||
"transport_mode": self._resolve_application_transport_mode(message),
|
||||
"amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
|
||||
"application_type": self._resolve_application_type_from_text(message),
|
||||
}
|
||||
for key, value in partial.items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
|
||||
context_json = payload.context_json or {}
|
||||
context_time = self._resolve_application_time_from_context(context_json)
|
||||
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
|
||||
facts["time"] = context_time
|
||||
current_user = self._build_application_current_user(payload)
|
||||
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
|
||||
if not facts["applicant"]:
|
||||
facts["applicant"] = str(
|
||||
context_json.get("name")
|
||||
or context_json.get("user_name")
|
||||
or context_json.get("applicant")
|
||||
or (employee.name if employee is not None else "")
|
||||
or current_user.name
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["grade"]:
|
||||
facts["grade"] = str(
|
||||
context_json.get("grade")
|
||||
or context_json.get("employee_grade")
|
||||
or context_json.get("employeeGrade")
|
||||
or current_user.grade
|
||||
or (employee.grade if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["department"]:
|
||||
facts["department"] = str(
|
||||
context_json.get("department")
|
||||
or context_json.get("department_name")
|
||||
or context_json.get("departmentName")
|
||||
or current_user.department_name
|
||||
or (
|
||||
employee.organization_unit.name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["position"]:
|
||||
facts["position"] = str(
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or current_user.position
|
||||
or (employee.position if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["manager_name"]:
|
||||
facts["manager_name"] = str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or current_user.manager_name
|
||||
or (
|
||||
employee.manager.name
|
||||
if employee is not None and employee.manager is not None
|
||||
else ""
|
||||
)
|
||||
or (
|
||||
employee.organization_unit.manager_name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
facts.get("time", ""),
|
||||
facts.get("days", ""),
|
||||
payload.context_json or {},
|
||||
)
|
||||
if self._is_application_missing_value(facts.get("days", "")):
|
||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||
if range_days:
|
||||
facts["days"] = f"{range_days}天"
|
||||
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
def _apply_rule_center_travel_policy_to_application_facts(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> None:
|
||||
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
|
||||
return
|
||||
|
||||
location = str(facts.get("location") or "").strip()
|
||||
grade = str(facts.get("grade") or "").strip()
|
||||
if not location or not grade:
|
||||
return
|
||||
|
||||
days = self._parse_application_days_count(facts.get("days", "")) or 1
|
||||
try:
|
||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
|
||||
self._build_application_current_user(payload),
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
hotel_rate = self._format_application_policy_money(result.hotel_rate)
|
||||
hotel_amount = self._format_application_policy_money(result.hotel_amount)
|
||||
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
|
||||
allowance_amount = self._format_application_policy_money(result.allowance_amount)
|
||||
if hotel_rate:
|
||||
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
|
||||
if hotel_amount:
|
||||
facts["hotel_amount"] = f"{hotel_amount}元"
|
||||
if allowance_rate:
|
||||
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
|
||||
if allowance_amount:
|
||||
facts["allowance_amount"] = f"{allowance_amount}元"
|
||||
if str(result.matched_city or "").strip():
|
||||
facts["matched_city"] = str(result.matched_city).strip()
|
||||
if str(result.rule_name or "").strip():
|
||||
facts["rule_name"] = str(result.rule_name).strip()
|
||||
if str(result.rule_version or "").strip():
|
||||
facts["rule_version"] = str(result.rule_version).strip()
|
||||
|
||||
@staticmethod
|
||||
def _format_application_policy_money(value: object) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
if amount == amount.to_integral():
|
||||
return f"{int(amount):,}"
|
||||
return f"{amount:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_days_count(value: object) -> int:
|
||||
match = re.search(r"\d+", str(value or ""))
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(match.group(0)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||
preview = context_json.get("application_preview")
|
||||
if not isinstance(preview, dict):
|
||||
return {}
|
||||
fields = preview.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for key in keys:
|
||||
value = str(fields.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
|
||||
return {
|
||||
"application_type": UserAgentApplicationMixin._normalize_application_type_label(
|
||||
pick("applicationType", "application_type")
|
||||
),
|
||||
"time": pick("time", "timeRange", "time_range"),
|
||||
"location": pick("location"),
|
||||
"reason": reason,
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
"applicant": pick("applicant", "name", "userName", "user_name"),
|
||||
"grade": pick("grade"),
|
||||
"department": pick("department", "departmentName", "department_name"),
|
||||
"position": pick("position", "employeePosition", "employee_position"),
|
||||
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
|
||||
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
|
||||
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
|
||||
"transport_policy": pick("transportPolicy", "transport_policy"),
|
||||
"policy_estimate": pick("policyEstimate", "policy_estimate"),
|
||||
"matched_city": pick("matchedCity", "matched_city"),
|
||||
"rule_name": pick("ruleName", "rule_name"),
|
||||
"rule_version": pick("ruleVersion", "rule_version"),
|
||||
"hotel_amount": pick("hotelAmount", "hotel_amount"),
|
||||
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
|
||||
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
|
||||
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
|
||||
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
|
||||
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_application_missing_value(value: object) -> bool:
|
||||
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
if self._is_application_save_draft_action(payload):
|
||||
return "draft"
|
||||
if self._resolve_application_missing_base_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._resolve_application_missing_followup_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._is_application_submit_confirmation(payload):
|
||||
return "submitted"
|
||||
return "preview"
|
||||
|
||||
class UserAgentApplicationSlotMixin:
|
||||
@staticmethod
|
||||
def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]:
|
||||
messages: list[tuple[str, bool]] = []
|
||||
@@ -1027,6 +602,8 @@ class UserAgentApplicationMixin:
|
||||
return "会务费用申请"
|
||||
return "差旅费用申请"
|
||||
|
||||
|
||||
class UserAgentApplicationPersistenceMixin:
|
||||
@staticmethod
|
||||
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
|
||||
if not isinstance(context_json, dict):
|
||||
@@ -1512,3 +1089,431 @@ class UserAgentApplicationMixin:
|
||||
"application",
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicationPersistenceMixin):
|
||||
@staticmethod
|
||||
def _is_expense_application_request(payload: UserAgentRequest) -> bool:
|
||||
context_json = payload.context_json or {}
|
||||
context_values = {
|
||||
str(context_json.get("session_type") or "").strip(),
|
||||
str(context_json.get("entry_source") or "").strip(),
|
||||
str(context_json.get("document_type") or "").strip(),
|
||||
str(context_json.get("application_stage") or "").strip(),
|
||||
}
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
context_values.update(
|
||||
{
|
||||
str(conversation_state.get("session_type") or "").strip(),
|
||||
str(conversation_state.get("entry_source") or "").strip(),
|
||||
str(conversation_state.get("document_type") or "").strip(),
|
||||
str(conversation_state.get("application_stage") or "").strip(),
|
||||
}
|
||||
)
|
||||
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||
return True
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||
looks_like_submit = (
|
||||
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
)
|
||||
if not looks_like_submit:
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "assistant"
|
||||
and (
|
||||
"#application-submit" in str(item.get("content") or "")
|
||||
or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
|
||||
)
|
||||
for item in history[-6:]
|
||||
)
|
||||
|
||||
def _build_expense_application_response(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
risk_flags: list[str],
|
||||
) -> UserAgentResponse:
|
||||
facts = self._resolve_expense_application_facts(payload)
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
application_claim = None
|
||||
if step in {"draft", "submitted"}:
|
||||
editable_claim = self._find_editable_expense_application_record(payload)
|
||||
if editable_claim is not None:
|
||||
application_claim = self._update_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
editable_claim,
|
||||
submit=step == "submitted",
|
||||
)
|
||||
facts["application_edit_mode"] = "true"
|
||||
elif step == "submitted":
|
||||
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||
if application_claim is not None:
|
||||
step = "duplicate"
|
||||
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=True,
|
||||
)
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=False,
|
||||
)
|
||||
if application_claim is not None:
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
facts["application_claim_id"] = application_claim.id
|
||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||
return UserAgentResponse(
|
||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||
citations=[],
|
||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||
query_payload=None,
|
||||
draft_payload=(
|
||||
self._build_persisted_application_payload(application_claim, facts)
|
||||
if step in {"draft", "submitted"}
|
||||
else None
|
||||
),
|
||||
review_payload=None,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=step == "preview",
|
||||
)
|
||||
|
||||
def _build_expense_application_answer(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
step: str,
|
||||
) -> str:
|
||||
recognized_table = build_application_summary_table(facts, include_empty=False)
|
||||
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
missing_text = "、".join(
|
||||
self._display_application_slot_label(item)
|
||||
for item in missing_fields
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||
"已识别信息:\n" + recognized_table,
|
||||
f"当前还需要补充:{missing_text}。",
|
||||
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "draft":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
return "\n\n".join(
|
||||
[
|
||||
"申请草稿已保存。",
|
||||
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
|
||||
"当前节点:待提交。",
|
||||
"后续可进入单据详情继续核对、补充或提交审批。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "submitted":
|
||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||
submitted_title = (
|
||||
"申请单据已修改并重新提交,已进入审批流程。"
|
||||
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
|
||||
else "申请单据已生成,并已进入审批流程。"
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
submitted_title,
|
||||
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
||||
f"申请单号:{application_no}",
|
||||
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "duplicate":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||
time_label = resolve_application_time_label(facts)
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||
f"已有申请单号:{application_no}",
|
||||
f"当前节点:{stage}",
|
||||
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是费用申请核对结果,请核对:",
|
||||
build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
|
||||
facts = {
|
||||
"time": "",
|
||||
"location": "",
|
||||
"reason": "",
|
||||
"days": "",
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
"applicant": "",
|
||||
"grade": "",
|
||||
"department": "",
|
||||
"position": "",
|
||||
"manager_name": "",
|
||||
"lodging_daily_cap": "",
|
||||
"subsidy_daily_cap": "",
|
||||
"transport_policy": "",
|
||||
"policy_estimate": "",
|
||||
"matched_city": "",
|
||||
"rule_name": "",
|
||||
"rule_version": "",
|
||||
"hotel_amount": "",
|
||||
"allowance_amount": "",
|
||||
"transport_estimated_amount": "",
|
||||
"transport_estimate_source": "",
|
||||
"transport_estimate_confidence": "",
|
||||
"policy_total_amount": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
"time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
|
||||
"location": self._resolve_application_location(payload, message=message, use_entities=is_current),
|
||||
"reason": self._resolve_application_reason(message),
|
||||
"days": self._resolve_application_days(message),
|
||||
"transport_mode": self._resolve_application_transport_mode(message),
|
||||
"amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
|
||||
"application_type": self._resolve_application_type_from_text(message),
|
||||
}
|
||||
for key, value in partial.items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
|
||||
context_json = payload.context_json or {}
|
||||
context_time = self._resolve_application_time_from_context(context_json)
|
||||
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
|
||||
facts["time"] = context_time
|
||||
current_user = self._build_application_current_user(payload)
|
||||
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
|
||||
if not facts["applicant"]:
|
||||
facts["applicant"] = str(
|
||||
context_json.get("name")
|
||||
or context_json.get("user_name")
|
||||
or context_json.get("applicant")
|
||||
or (employee.name if employee is not None else "")
|
||||
or current_user.name
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["grade"]:
|
||||
facts["grade"] = str(
|
||||
context_json.get("grade")
|
||||
or context_json.get("employee_grade")
|
||||
or context_json.get("employeeGrade")
|
||||
or current_user.grade
|
||||
or (employee.grade if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["department"]:
|
||||
facts["department"] = str(
|
||||
context_json.get("department")
|
||||
or context_json.get("department_name")
|
||||
or context_json.get("departmentName")
|
||||
or current_user.department_name
|
||||
or (
|
||||
employee.organization_unit.name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["position"]:
|
||||
facts["position"] = str(
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or current_user.position
|
||||
or (employee.position if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["manager_name"]:
|
||||
facts["manager_name"] = str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or current_user.manager_name
|
||||
or (
|
||||
employee.manager.name
|
||||
if employee is not None and employee.manager is not None
|
||||
else ""
|
||||
)
|
||||
or (
|
||||
employee.organization_unit.manager_name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
facts.get("time", ""),
|
||||
facts.get("days", ""),
|
||||
payload.context_json or {},
|
||||
)
|
||||
if self._is_application_missing_value(facts.get("days", "")):
|
||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||
if range_days:
|
||||
facts["days"] = f"{range_days}天"
|
||||
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
def _apply_rule_center_travel_policy_to_application_facts(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> None:
|
||||
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
|
||||
return
|
||||
|
||||
location = str(facts.get("location") or "").strip()
|
||||
grade = str(facts.get("grade") or "").strip()
|
||||
if not location or not grade:
|
||||
return
|
||||
|
||||
days = self._parse_application_days_count(facts.get("days", "")) or 1
|
||||
try:
|
||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
|
||||
self._build_application_current_user(payload),
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
hotel_rate = self._format_application_policy_money(result.hotel_rate)
|
||||
hotel_amount = self._format_application_policy_money(result.hotel_amount)
|
||||
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
|
||||
allowance_amount = self._format_application_policy_money(result.allowance_amount)
|
||||
if hotel_rate:
|
||||
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
|
||||
if hotel_amount:
|
||||
facts["hotel_amount"] = f"{hotel_amount}元"
|
||||
if allowance_rate:
|
||||
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
|
||||
if allowance_amount:
|
||||
facts["allowance_amount"] = f"{allowance_amount}元"
|
||||
if str(result.matched_city or "").strip():
|
||||
facts["matched_city"] = str(result.matched_city).strip()
|
||||
if str(result.rule_name or "").strip():
|
||||
facts["rule_name"] = str(result.rule_name).strip()
|
||||
if str(result.rule_version or "").strip():
|
||||
facts["rule_version"] = str(result.rule_version).strip()
|
||||
|
||||
@staticmethod
|
||||
def _format_application_policy_money(value: object) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
if amount == amount.to_integral():
|
||||
return f"{int(amount):,}"
|
||||
return f"{amount:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_days_count(value: object) -> int:
|
||||
match = re.search(r"\d+", str(value or ""))
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(match.group(0)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||
preview = context_json.get("application_preview")
|
||||
if not isinstance(preview, dict):
|
||||
return {}
|
||||
fields = preview.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for key in keys:
|
||||
value = str(fields.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
|
||||
return {
|
||||
"application_type": UserAgentApplicationMixin._normalize_application_type_label(
|
||||
pick("applicationType", "application_type")
|
||||
),
|
||||
"time": pick("time", "timeRange", "time_range"),
|
||||
"location": pick("location"),
|
||||
"reason": reason,
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
"applicant": pick("applicant", "name", "userName", "user_name"),
|
||||
"grade": pick("grade"),
|
||||
"department": pick("department", "departmentName", "department_name"),
|
||||
"position": pick("position", "employeePosition", "employee_position"),
|
||||
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
|
||||
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
|
||||
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
|
||||
"transport_policy": pick("transportPolicy", "transport_policy"),
|
||||
"policy_estimate": pick("policyEstimate", "policy_estimate"),
|
||||
"matched_city": pick("matchedCity", "matched_city"),
|
||||
"rule_name": pick("ruleName", "rule_name"),
|
||||
"rule_version": pick("ruleVersion", "rule_version"),
|
||||
"hotel_amount": pick("hotelAmount", "hotel_amount"),
|
||||
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
|
||||
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
|
||||
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
|
||||
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
|
||||
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_application_missing_value(value: object) -> bool:
|
||||
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
if self._is_application_save_draft_action(payload):
|
||||
return "draft"
|
||||
if self._resolve_application_missing_base_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._resolve_application_missing_followup_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._is_application_submit_confirmation(payload):
|
||||
return "submitted"
|
||||
return "preview"
|
||||
|
||||
|
||||
34
server/tests/test_code_size_limits.py
Normal file
34
server/tests/test_code_size_limits.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAX_CLASS_LINES = 800
|
||||
SERVER_SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "app"
|
||||
|
||||
|
||||
def iter_python_source_files(root: Path) -> list[Path]:
|
||||
ignored_parts = {"__pycache__", "x_financial_server.egg-info"}
|
||||
return sorted(
|
||||
path
|
||||
for path in root.rglob("*.py")
|
||||
if not ignored_parts.intersection(path.parts)
|
||||
)
|
||||
|
||||
|
||||
def test_python_classes_do_not_exceed_800_lines() -> None:
|
||||
oversized_classes: list[str] = []
|
||||
|
||||
for path in iter_python_source_files(SERVER_SOURCE_ROOT):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.end_lineno is not None:
|
||||
line_count = node.end_lineno - node.lineno + 1
|
||||
if line_count > MAX_CLASS_LINES:
|
||||
relative_path = path.relative_to(SERVER_SOURCE_ROOT.parents[1])
|
||||
oversized_classes.append(
|
||||
f"{relative_path}:{node.lineno} {node.name} ({line_count} lines)"
|
||||
)
|
||||
|
||||
assert oversized_classes == []
|
||||
@@ -2207,6 +2207,160 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
|
||||
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_auto_collected_attachment_uses_source_receipt_ocr_result(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
current_user = CurrentUserContext(
|
||||
username="auto-collect-travel@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
) -> OcrRecognizeBatchRead:
|
||||
return OcrRecognizeBatchRead(
|
||||
total_file_count=1,
|
||||
success_count=1,
|
||||
documents=[
|
||||
OcrRecognizeDocumentRead(
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
media_type="application/pdf",
|
||||
text="",
|
||||
summary="",
|
||||
avg_score=0.0,
|
||||
line_count=0,
|
||||
page_count=1,
|
||||
document_type="other",
|
||||
document_type_label="其他单据",
|
||||
scene_code="other",
|
||||
scene_label="其他票据",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(
|
||||
ExpenseClaimAttachmentStorage,
|
||||
"root",
|
||||
lambda self: tmp_path / "attachments",
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E-AUTO-COLLECT",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
|
||||
claim = build_claim(expense_type="travel", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = employee.name
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"severity": "high",
|
||||
"message": "票据类型:未识别到发票、票据、电子行程单等关键字。",
|
||||
}
|
||||
]
|
||||
claim.items[0].item_type = "travel"
|
||||
claim.items[0].item_reason = ""
|
||||
claim.items[0].item_location = "上海"
|
||||
claim.items[0].item_amount = Decimal("0.00")
|
||||
claim.items[0].invoice_id = None
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
receipt = ReceiptFolderService().save_receipt(
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
content=b"%PDF-1.4 fake-train-ticket",
|
||||
media_type="application/pdf",
|
||||
current_user=current_user,
|
||||
document=OcrRecognizeDocumentRead(
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
media_type="application/pdf",
|
||||
text="中国铁路电子客票 深圳北-上海虹桥 2026-02-22 票价:¥388.00",
|
||||
summary="铁路电子客票,深圳至上海,2026-02-22 出发,票价 388 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
document_fields=[
|
||||
{"key": "origin", "label": "出发城市", "value": "深圳"},
|
||||
{"key": "destination", "label": "到达城市", "value": "上海"},
|
||||
{"key": "trip_date", "label": "列车出发时间", "value": "2026-02-22"},
|
||||
{"key": "fare", "label": "票价", "value": "¥388.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
updated = service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
filename="2月22 深圳-上海.pdf",
|
||||
content=b"%PDF-1.4 fake-train-ticket",
|
||||
media_type="application/pdf",
|
||||
current_user=current_user,
|
||||
source_receipt_id=receipt.id,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["item_type"] == "train_ticket"
|
||||
assert updated["item_amount"] == Decimal("388.00")
|
||||
assert updated["item_date"] == "2026-02-22"
|
||||
assert updated["item_reason"] == "深圳北-上海虹桥"
|
||||
|
||||
uploaded_meta = service.get_claim_item_attachment_meta(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
assert uploaded_meta is not None
|
||||
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
|
||||
assert uploaded_meta["requirement_check"]["matches"] is True
|
||||
assert not any(
|
||||
"未识别到发票" in point or "当前识别为其他单据" in point
|
||||
for point in uploaded_meta["analysis"]["points"]
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
allowance_item = next(
|
||||
item for item in claim.items if item.item_type == "travel_allowance"
|
||||
)
|
||||
assert allowance_item.item_amount > Decimal("0.00")
|
||||
assert "1天" in allowance_item.item_reason
|
||||
assert claim.amount == Decimal("388.00") + allowance_item.item_amount
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
and "未识别到发票" in str(flag.get("message") or "")
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
linked_receipt = ReceiptFolderService().get_receipt(receipt.id, current_user)
|
||||
assert linked_receipt.status == "linked"
|
||||
assert linked_receipt.linked_claim_id == claim.id
|
||||
assert linked_receipt.linked_claim_no == claim.claim_no
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
|
||||
Reference in New Issue
Block a user