refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

@@ -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

View File

@@ -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,
*,

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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"

View 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 == []

View File

@@ -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,