From 6d33ba5742c898086b85aa66f8654811535553a9 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 22 Jun 2026 11:58:53 +0800 Subject: [PATCH] refactor: enforce 800 line source limits --- server/src/app/services/agent_assets.py | 605 ++- .../expense_claim_attachment_operations.py | 221 +- .../app/services/expense_claim_draft_flow.py | 581 +-- server/src/app/services/expense_claims.py | 347 +- server/src/app/services/finance_dashboard.py | 135 +- .../app/services/orchestrator_execution.py | 522 +-- server/src/app/services/receipt_folder.py | 1293 +++--- .../services/risk_rule_template_executor.py | 87 +- server/src/app/services/steward_planner.py | 131 +- .../app/services/user_agent_application.py | 857 ++-- server/tests/test_code_size_limits.py | 34 + server/tests/test_expense_claim_service.py | 154 + .../expense-profile-detail-modal.css | 619 +++ .../components/personal-workbench-ai-mode.css | 194 + .../travel-request-detail-date-popper.css | 218 + .../business/ExpenseProfileDetailModal.vue | 621 +-- .../PersonalWorkbenchAiMode.template.html | 756 ++++ .../business/PersonalWorkbenchAiMode.vue | 2951 +----------- .../workbench-ai/WorkbenchAiComposer.vue | 173 + .../workbench-ai/WorkbenchAiFileStrip.vue | 36 + .../business/workbench-ai/WorkbenchAiHome.vue | 56 + web/src/components/layout/TopBar.vue | 1431 +++--- web/src/components/layout/topBarKpis.js | 132 + .../layout/useTopBarOverviewRange.js | 114 + .../components/shared/RiskRuleTestDialog.vue | 71 +- .../shared/riskRuleTestDialogUtils.js | 64 + .../travel/TravelRequestDetailHero.vue | 57 + .../travel/TravelRequestProgressCard.vue | 43 + .../TravelRequestRelatedApplicationCard.vue | 32 + .../composables/overviewViewDisplayModel.js | 199 + web/src/composables/overviewViewRangeModel.js | 122 + .../requests/requestClaimMapper.js | 133 + .../requests/requestExpenseItems.js | 306 ++ .../requests/requestProgressSteps.js | 704 +++ .../requests/requestRelatedApplication.js | 227 + web/src/composables/requests/requestShared.js | 426 ++ web/src/composables/useAppShell.js | 35 +- web/src/composables/useOverviewView.js | 352 +- web/src/composables/useRequests.js | 1666 +------ .../usePersonalWorkbenchAiMode.js | 787 ++++ .../useWorkbenchAiActionRouter.js | 125 + .../useWorkbenchAiApplicationPreviewFlow.js | 511 +++ ...useWorkbenchAiAttachmentAssociationFlow.js | 357 ++ .../useWorkbenchAiComposerFiles.js | 55 + .../useWorkbenchAiDocumentQueryFlow.js | 204 + .../useWorkbenchAiExpenseFlow.js | 186 + .../useWorkbenchAiMessageActions.js | 33 + .../useWorkbenchAiSessionCommands.js | 97 + .../useWorkbenchAiStewardFlow.js | 371 ++ .../workbenchAiApplicationPreviewModel.js | 340 ++ .../workbenchAiComposerModel.js | 100 + .../workbenchAiMessageModel.js | 195 + web/src/utils/aiAttachmentAssociationModel.js | 521 +++ web/src/utils/aiConversationHtmlRenderer.js | 196 +- .../aiConversationLegacyAttachmentRenderer.js | 87 + web/src/utils/aiConversationTableRenderer.js | 192 + web/src/utils/aiDocumentDetailReference.js | 4 +- web/src/utils/aiDocumentQueryIntent.js | 240 + web/src/utils/aiDocumentQueryModel.js | 276 +- web/src/utils/aiDocumentQueryText.js | 33 + web/src/utils/documentCenterViewModel.js | 361 ++ web/src/utils/expenseApplicationPreview.js | 788 +--- .../utils/expenseApplicationPreviewParsing.js | 742 +++ web/src/utils/expenseClaimAttachmentSync.js | 124 + web/src/views/AppShellRouteView.vue | 47 +- web/src/views/DocumentsCenterView.vue | 392 +- web/src/views/TravelRequestDetailView.vue | 398 +- web/src/views/scripts/AuditView.js | 150 +- .../views/scripts/EmployeeManagementView.js | 1055 +---- .../scripts/TravelReimbursementCreateView.js | 4012 ++--------------- .../views/scripts/TravelRequestDetailView.js | 2854 +----------- .../views/scripts/employeeManagementModel.js | 586 +++ web/src/views/scripts/stewardPlanFields.js | 114 + web/src/views/scripts/stewardPlanModel.js | 122 +- web/src/views/scripts/stewardTypewriter.js | 34 + ...elReimbursementConversationMessageModel.js | 246 + .../travelReimbursementConversationModel.js | 1053 +---- ...elReimbursementConversationSessionModel.js | 294 ++ ...avelReimbursementConversationStateModel.js | 277 ++ .../travelReimbursementCreateReviewModel.js | 319 ++ .../scripts/travelReimbursementFlowTiming.js | 191 + .../travelReimbursementFlowToolModel.js | 180 + .../travelReimbursementReviewDisplayModel.js | 722 +++ .../travelReimbursementReviewFormModel.js | 585 +++ .../scripts/travelReimbursementReviewModel.js | 1638 +------ .../travelReimbursementReviewSyncModel.js | 352 ++ .../travelReimbursementStewardFollowupFlow.js | 230 + ...velReimbursementStewardRuntimeTextModel.js | 65 + ...ReimbursementSubmitApplicationConflicts.js | 190 + ...elReimbursementSubmitApplicationPreview.js | 178 + ...travelReimbursementSubmitAttachmentFlow.js | 271 ++ .../travelReimbursementSubmitConstants.js | 99 + ...travelReimbursementSubmitDraftPreflight.js | 132 + ...avelReimbursementSubmitLocalPreviewFlow.js | 241 + ...ravelReimbursementSubmitRecognitionFlow.js | 189 + .../travelReimbursementSubmitResponseModel.js | 29 + ...velReimbursementSubmitStewardDelegation.js | 505 +++ .../travelReimbursementWelcomeModel.js | 267 ++ .../travelRequestDetailAiAdviceModel.js | 94 + .../scripts/travelRequestDetailInsights.js | 241 +- .../travelRequestDetailRiskCardDedupe.js | 84 + .../scripts/travelRequestDetailRiskTags.js | 96 + .../views/scripts/travelRequestDetailSetup.js | 495 ++ ...ravelRequestDetailSmartEntryRecognition.js | 225 + .../views/scripts/useAuditViewPermissions.js | 158 + .../scripts/useEmployeeManagementFilters.js | 182 + .../scripts/useEmployeeManagementPickers.js | 217 + web/src/views/scripts/useStewardPlanFlow.js | 5 +- ...imbursementApplicationPreviewDateEditor.js | 69 + ...elReimbursementApplicationSubmitConfirm.js | 178 + .../useTravelReimbursementAttachments.js | 102 +- ...seTravelReimbursementCreateViewControls.js | 143 + ...elReimbursementCreateViewDrawerControls.js | 64 + ...eTravelReimbursementCreateViewLifecycle.js | 12 + ...lReimbursementCreateViewMessageHandlers.js | 159 + ...eimbursementCreateViewOperationFeedback.js | 40 + .../useTravelReimbursementCreateViewScroll.js | 75 + ...elReimbursementCreateViewSessionCleanup.js | 51 + .../useTravelReimbursementCreateViewState.js | 169 + ...mbursementCreateViewSuggestedActionLock.js | 47 + ...ReimbursementCreateViewTravelCalculator.js | 57 + .../scripts/useTravelReimbursementFlow.js | 388 +- .../useTravelReimbursementStewardRuntime.js | 1 + ...avelReimbursementStewardRuntimeDecision.js | 748 +++ .../useTravelReimbursementSubmitComposer.js | 1777 +------- .../useTravelReimbursementSuggestedActions.js | 1 + .../useTravelRequestDetailApprovalFlow.js | 386 ++ ...useTravelRequestDetailAttachmentPreview.js | 266 ++ .../useTravelRequestDetailExpenseEditor.js | 718 +++ .../useTravelRequestDetailRiskSubmit.js | 767 ++++ .../useTravelRequestEmployeeRiskProfile.js | 81 + .../ai-attachment-association-model.test.mjs | 281 ++ .../ai-conversation-html-renderer.test.mjs | 23 + .../ai-document-detail-reference.test.mjs | 4 + ...p-shell-financial-assistant-entry.test.mjs | 80 +- ...tachment-association-confirmation.test.mjs | 32 +- web/tests/code-size-limits.test.mjs | 79 + .../documents-center-status-filter.test.mjs | 81 +- .../employee-management-history.test.mjs | 30 +- .../expense-application-fast-preview.test.mjs | 25 +- .../expense-claim-attachment-sync.test.mjs | 54 + .../expense-profile-detail-modal.test.mjs | 15 +- web/tests/finance-dashboard-ranking.test.mjs | 6 +- web/tests/risk-observation-dashboard.test.mjs | 16 +- .../travel-reimbursement-guided-flow.test.mjs | 8 +- ...eimbursement-review-drawer-switch.test.mjs | 264 +- ...travel-request-detail-risk-advice.test.mjs | 88 +- ...vel-request-detail-submit-confirm.test.mjs | 40 +- web/tests/workbench-ai-mode-switch.test.mjs | 290 +- web/tests/workbench-detail-return.test.mjs | 45 +- 150 files changed, 27413 insertions(+), 23791 deletions(-) create mode 100644 server/tests/test_code_size_limits.py create mode 100644 web/src/assets/styles/components/expense-profile-detail-modal.css create mode 100644 web/src/assets/styles/views/travel-request-detail-date-popper.css create mode 100644 web/src/components/business/PersonalWorkbenchAiMode.template.html create mode 100644 web/src/components/business/workbench-ai/WorkbenchAiComposer.vue create mode 100644 web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue create mode 100644 web/src/components/business/workbench-ai/WorkbenchAiHome.vue create mode 100644 web/src/components/layout/topBarKpis.js create mode 100644 web/src/components/layout/useTopBarOverviewRange.js create mode 100644 web/src/components/travel/TravelRequestDetailHero.vue create mode 100644 web/src/components/travel/TravelRequestProgressCard.vue create mode 100644 web/src/components/travel/TravelRequestRelatedApplicationCard.vue create mode 100644 web/src/composables/overviewViewDisplayModel.js create mode 100644 web/src/composables/overviewViewRangeModel.js create mode 100644 web/src/composables/requests/requestClaimMapper.js create mode 100644 web/src/composables/requests/requestExpenseItems.js create mode 100644 web/src/composables/requests/requestProgressSteps.js create mode 100644 web/src/composables/requests/requestRelatedApplication.js create mode 100644 web/src/composables/requests/requestShared.js create mode 100644 web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiComposerFiles.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiMessageActions.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiSessionCommands.js create mode 100644 web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js create mode 100644 web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js create mode 100644 web/src/composables/workbenchAiMode/workbenchAiComposerModel.js create mode 100644 web/src/composables/workbenchAiMode/workbenchAiMessageModel.js create mode 100644 web/src/utils/aiAttachmentAssociationModel.js create mode 100644 web/src/utils/aiConversationLegacyAttachmentRenderer.js create mode 100644 web/src/utils/aiConversationTableRenderer.js create mode 100644 web/src/utils/aiDocumentQueryIntent.js create mode 100644 web/src/utils/aiDocumentQueryText.js create mode 100644 web/src/utils/documentCenterViewModel.js create mode 100644 web/src/utils/expenseApplicationPreviewParsing.js create mode 100644 web/src/utils/expenseClaimAttachmentSync.js create mode 100644 web/src/views/scripts/employeeManagementModel.js create mode 100644 web/src/views/scripts/stewardPlanFields.js create mode 100644 web/src/views/scripts/stewardTypewriter.js create mode 100644 web/src/views/scripts/travelReimbursementConversationMessageModel.js create mode 100644 web/src/views/scripts/travelReimbursementConversationSessionModel.js create mode 100644 web/src/views/scripts/travelReimbursementConversationStateModel.js create mode 100644 web/src/views/scripts/travelReimbursementCreateReviewModel.js create mode 100644 web/src/views/scripts/travelReimbursementFlowTiming.js create mode 100644 web/src/views/scripts/travelReimbursementFlowToolModel.js create mode 100644 web/src/views/scripts/travelReimbursementReviewDisplayModel.js create mode 100644 web/src/views/scripts/travelReimbursementReviewFormModel.js create mode 100644 web/src/views/scripts/travelReimbursementReviewSyncModel.js create mode 100644 web/src/views/scripts/travelReimbursementStewardFollowupFlow.js create mode 100644 web/src/views/scripts/travelReimbursementStewardRuntimeTextModel.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitApplicationConflicts.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitApplicationPreview.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitAttachmentFlow.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitConstants.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitDraftPreflight.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitRecognitionFlow.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitResponseModel.js create mode 100644 web/src/views/scripts/travelReimbursementSubmitStewardDelegation.js create mode 100644 web/src/views/scripts/travelReimbursementWelcomeModel.js create mode 100644 web/src/views/scripts/travelRequestDetailAiAdviceModel.js create mode 100644 web/src/views/scripts/travelRequestDetailRiskCardDedupe.js create mode 100644 web/src/views/scripts/travelRequestDetailRiskTags.js create mode 100644 web/src/views/scripts/travelRequestDetailSetup.js create mode 100644 web/src/views/scripts/travelRequestDetailSmartEntryRecognition.js create mode 100644 web/src/views/scripts/useAuditViewPermissions.js create mode 100644 web/src/views/scripts/useEmployeeManagementFilters.js create mode 100644 web/src/views/scripts/useEmployeeManagementPickers.js create mode 100644 web/src/views/scripts/useTravelReimbursementApplicationPreviewDateEditor.js create mode 100644 web/src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewControls.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewDrawerControls.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewOperationFeedback.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewScroll.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewSessionCleanup.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewState.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewSuggestedActionLock.js create mode 100644 web/src/views/scripts/useTravelReimbursementCreateViewTravelCalculator.js create mode 100644 web/src/views/scripts/useTravelReimbursementStewardRuntime.js create mode 100644 web/src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js create mode 100644 web/src/views/scripts/useTravelRequestDetailApprovalFlow.js create mode 100644 web/src/views/scripts/useTravelRequestDetailAttachmentPreview.js create mode 100644 web/src/views/scripts/useTravelRequestDetailExpenseEditor.js create mode 100644 web/src/views/scripts/useTravelRequestDetailRiskSubmit.js create mode 100644 web/src/views/scripts/useTravelRequestEmployeeRiskProfile.js create mode 100644 web/tests/ai-attachment-association-model.test.mjs create mode 100644 web/tests/code-size-limits.test.mjs create mode 100644 web/tests/expense-claim-attachment-sync.test.mjs diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index ab94a02..adb5ad2 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -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 diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index 3107d35..aae47e7 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -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, *, diff --git a/server/src/app/services/expense_claim_draft_flow.py b/server/src/app/services/expense_claim_draft_flow.py index 8f10169..f456860 100644 --- a/server/src/app/services/expense_claim_draft_flow.py +++ b/server/src/app/services/expense_claim_draft_flow.py @@ -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), + } + diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 75e0e49..e74ded3 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -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 diff --git a/server/src/app/services/finance_dashboard.py b/server/src/app/services/finance_dashboard.py index df0a6db..230bf79 100644 --- a/server/src/app/services/finance_dashboard.py +++ b/server/src/app/services/finance_dashboard.py @@ -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), + ) + diff --git a/server/src/app/services/orchestrator_execution.py b/server/src/app/services/orchestrator_execution.py index f30a920..3967107 100644 --- a/server/src/app/services/orchestrator_execution.py +++ b/server/src/app/services/orchestrator_execution.py @@ -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, + ) + diff --git a/server/src/app/services/receipt_folder.py b/server/src/app/services/receipt_folder.py index 32054e8..729b6ed 100644 --- a/server/src/app/services/receipt_folder.py +++ b/server/src/app/services/receipt_folder.py @@ -48,7 +48,656 @@ TRAIN_COMBINED_SEAT_PATTERN = re.compile(r"([0-9]{1,2})车\s*([0-9]{1,3}[A-F]) TRAIN_FARE_PATTERN = re.compile(r"(?:票价|金额)\s*[::¥¥\s]*([0-9]+(?:[.,][0-9]{1,2})?)") -class ReceiptFolderService: +class ReceiptFolderStorageMixin: + @staticmethod + def normalize_filename(filename: str | None) -> str: + normalized = Path(str(filename or "").strip()).name + normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", normalized).strip("._") + return normalized or "receipt.bin" + + @staticmethod + def resolve_media_type(filename: str, fallback: str | None = None) -> str: + return str(mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream") + + def _owner_root(self, owner_key: str) -> Path: + return self._assert_child(self.root / owner_key) + + def _receipt_dir(self, owner_key: str, receipt_id: str) -> Path: + normalized = str(receipt_id or "").strip() + if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", normalized): + raise FileNotFoundError("Receipt not found") + path = self._assert_child(self._owner_root(owner_key) / normalized) + if not path.exists() or not path.is_dir(): + raise FileNotFoundError("Receipt not found") + return path + + def _assert_child(self, path: Path) -> Path: + self.root.mkdir(parents=True, exist_ok=True) + resolved = path.resolve() + try: + resolved.relative_to(self.root) + except ValueError as exc: + raise FileNotFoundError("Receipt path is invalid") from exc + return resolved + + @staticmethod + def _owner_key(current_user: CurrentUserContext) -> str: + raw = str(current_user.username or current_user.name or "anonymous").strip().lower() + normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw).strip("._") + return normalized or "anonymous" + + @staticmethod + def _should_persist_source(filename: str, content: bytes) -> bool: + if not content: + return False + return Path(str(filename or "")).suffix.lower() in SUPPORTED_SUFFIXES + + def _write_preview_asset( + self, + *, + receipt_dir: Path, + source_path: Path, + media_type: str, + document: Any | None, + ) -> dict[str, Any]: + preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip() + decoded = ExpenseClaimAttachmentPresentation.decode_data_url(preview_data_url) + if decoded is not None: + preview_media_type, preview_content = decoded + suffix = mimetypes.guess_extension(preview_media_type) or ".bin" + preview_name = f"preview{suffix}" + preview_path = receipt_dir / preview_name + preview_path.write_bytes(preview_content) + return { + "previewable": True, + "preview_kind": "image", + "preview_file_name": preview_name, + "preview_media_type": preview_media_type, + } + if self._is_previewable(media_type): + return { + "previewable": True, + "preview_kind": "image" if media_type.startswith("image/") else "pdf", + "preview_file_name": source_path.name, + "preview_media_type": media_type, + } + return { + "previewable": False, + "preview_kind": "", + "preview_file_name": "", + "preview_media_type": "", + } + + @staticmethod + def _is_previewable(media_type: str) -> bool: + return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf" + + @classmethod + def _build_document_meta(cls, document: Any | None) -> dict[str, Any]: + fields = [] + for field in list(getattr(document, "document_fields", []) or []): + if isinstance(field, dict): + fields.append( + { + "key": str(field.get("key") or "").strip(), + "label": str(field.get("label") or "").strip(), + "value": str(field.get("value") or "").strip(), + } + ) + else: + fields.append( + { + "key": str(getattr(field, "key", "") or "").strip(), + "label": str(getattr(field, "label", "") or "").strip(), + "value": str(getattr(field, "value", "") or "").strip(), + } + ) + fields = [field for field in fields if field["label"] and field["value"]] + ocr_text = str(getattr(document, "text", "") or "") + summary = str(getattr(document, "summary", "") or "") + document_type = str(getattr(document, "document_type", "") or "other") + document_type_label = str(getattr(document, "document_type_label", "") or "其他单据") + scene_label = str(getattr(document, "scene_label", "") or "其他票据") + if cls._is_train_ticket_values( + document_type=document_type, + document_type_label=document_type_label, + scene_label=scene_label, + text=f"{summary}\n{ocr_text}", + ): + fields = cls._enrich_train_ticket_field_dicts( + fields, + text=f"{ocr_text}\n{summary}\n{str(getattr(document, 'filename', '') or '')}", + ) + return { + "engine": str(getattr(document, "engine", "") or ""), + "model": str(getattr(document, "model", "") or ""), + "ocr_text": ocr_text, + "summary": summary, + "ocr_avg_score": float(getattr(document, "avg_score", 0.0) or 0.0), + "ocr_line_count": int(getattr(document, "line_count", 0) or 0), + "page_count": int(getattr(document, "page_count", 1) or 1), + "document_type": document_type, + "document_type_label": document_type_label, + "scene_code": str(getattr(document, "scene_code", "") or "other"), + "scene_label": scene_label, + "ocr_classification_source": str(getattr(document, "classification_source", "") or ""), + "ocr_classification_confidence": float(getattr(document, "classification_confidence", 0.0) or 0.0), + "ocr_classification_evidence": [ + str(value) for value in list(getattr(document, "classification_evidence", []) or []) if str(value).strip() + ], + "document_fields": fields, + "editable_fields": {}, + "ocr_warnings": [str(value) for value in list(getattr(document, "warnings", []) or []) if str(value).strip()], + } + + def _iter_owner_meta(self, owner_key: str) -> list[dict[str, Any]]: + owner_root = self._owner_root(owner_key) + if not owner_root.exists(): + return [] + metas = [] + for meta_path in owner_root.glob("*/meta.json"): + meta = self._read_meta(meta_path.parent) + if meta: + metas.append(meta) + return metas + + def _read_receipt_meta(self, receipt_id: str, current_user: CurrentUserContext) -> dict[str, Any]: + return self._read_meta(self._receipt_dir(self._owner_key(current_user), receipt_id)) + + def _resolve_existing_item( + self, + receipt_id: str | None, + current_user: CurrentUserContext, + ) -> ReceiptFolderItemRead | None: + normalized = str(receipt_id or "").strip() + if not normalized: + return None + try: + return self._build_item(self._read_receipt_meta(normalized, current_user)) + except FileNotFoundError: + return None + + @staticmethod + def _meta_path(receipt_dir: Path) -> Path: + return receipt_dir / "meta.json" + + def _read_meta(self, receipt_dir: Path) -> dict[str, Any]: + meta_path = self._meta_path(receipt_dir) + if not meta_path.exists(): + raise FileNotFoundError("Receipt not found") + try: + payload = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise FileNotFoundError("Receipt metadata not found") from exc + return payload if isinstance(payload, dict) else {} + + def _write_meta(self, receipt_dir: Path, payload: dict[str, Any]) -> None: + self._meta_path(receipt_dir).write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + @staticmethod + def _content_hash(content: bytes) -> str: + return hashlib.sha256(content or b"").hexdigest() if content else "" + + @staticmethod + def _operator_label(current_user: CurrentUserContext) -> str: + return str(current_user.name or current_user.username or "当前用户").strip() or "当前用户" + + +class ReceiptFolderItemMixin: + @staticmethod + def _matches_status(meta: dict[str, Any], status_filter: str) -> bool: + if status_filter in {"", "all"}: + return True + return str(meta.get("status") or "unlinked").strip().lower() == status_filter + + def _build_item(self, meta: dict[str, Any]) -> ReceiptFolderItemRead: + receipt_id = str(meta.get("id") or "").strip() + status_value = str(meta.get("status") or "unlinked").strip() or "unlinked" + return ReceiptFolderItemRead( + id=receipt_id, + file_name=str(meta.get("file_name") or ""), + media_type=str(meta.get("media_type") or "application/octet-stream"), + size_bytes=int(meta.get("size_bytes") or 0), + status=status_value, + status_label="已关联" if status_value == "linked" else "未关联", + document_type=str(meta.get("document_type") or "other"), + document_type_label=str(meta.get("document_type_label") or "其他单据"), + scene_code=str(meta.get("scene_code") or "other"), + scene_label=str(meta.get("scene_label") or "其他票据"), + summary=str(meta.get("summary") or ""), + amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")), + document_date=self._resolve_receipt_document_date(meta), + merchant_name=self._resolve_receipt_merchant_name(meta), + avg_score=float(meta.get("ocr_avg_score") or 0.0), + uploaded_at=self._parse_datetime(meta.get("uploaded_at")), + linked_at=self._parse_datetime(meta.get("linked_at")), + linked_claim_id=str(meta.get("linked_claim_id") or ""), + linked_claim_no=str(meta.get("linked_claim_no") or ""), + previewable=bool(meta.get("previewable")), + preview_kind=str(meta.get("preview_kind") or ""), + preview_url=f"/receipt-folder/{receipt_id}/preview" if bool(meta.get("previewable")) and receipt_id else "", + source_url=f"/receipt-folder/{receipt_id}/source" if receipt_id else "", + warnings=[str(value) for value in list(meta.get("ocr_warnings") or []) if str(value).strip()], + ) + + def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]: + fields = [ + ReceiptFolderFieldRead( + key=str(field.get("key") or ""), + label=str(field.get("label") or ""), + value=str(field.get("value") or ""), + ) + for field in list(meta.get("document_fields") or []) + if isinstance(field, dict) and str(field.get("label") or "").strip() + ] + if self._is_train_ticket_meta(meta): + return [ + ReceiptFolderFieldRead(**field) + for field in self._enrich_train_ticket_field_dicts( + [field.model_dump() for field in fields], + text=self._receipt_text(meta), + ) + ] + return fields + + def _resolve_edit_logs(self, meta: dict[str, Any]) -> list[dict[str, Any]]: + logs = [] + for log in list(meta.get("edit_logs") or []): + if not isinstance(log, dict): + continue + changes = [ + { + "key": str(change.get("key") or ""), + "label": str(change.get("label") or ""), + "before": str(change.get("before") or ""), + "after": str(change.get("after") or ""), + } + for change in list(log.get("changes") or []) + if isinstance(change, dict) + and str(change.get("label") or change.get("key") or "").strip() + ] + if not changes: + continue + logs.append( + { + "operated_at": self._parse_datetime(log.get("operated_at")), + "operator": str(log.get("operator") or "当前用户").strip() or "当前用户", + "changes": changes, + } + ) + return logs + + def _build_edit_changes(self, before_meta: dict[str, Any], after_meta: dict[str, Any]) -> list[dict[str, str]]: + before_values = self._flatten_editable_receipt_values(before_meta) + after_values = self._flatten_editable_receipt_values(after_meta) + changes = [] + for key in sorted(set(before_values) | set(after_values)): + before = before_values.get(key, {}) + after = after_values.get(key, {}) + before_value = str(before.get("value") or "").strip() + after_value = str(after.get("value") or "").strip() + if before_value == after_value: + continue + label = str(after.get("label") or before.get("label") or key).strip() + changes.append( + { + "key": key, + "label": label, + "before": before_value, + "after": after_value, + } + ) + return changes + + def _flatten_editable_receipt_values(self, meta: dict[str, Any]) -> dict[str, dict[str, str]]: + values = { + "document_type_label": { + "label": "票据类型", + "value": str(meta.get("document_type_label") or "").strip(), + }, + "scene_label": { + "label": "费用场景", + "value": str(meta.get("scene_label") or "").strip(), + }, + "summary": { + "label": "摘要", + "value": str(meta.get("summary") or "").strip(), + }, + "amount": { + "label": "金额", + "value": self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")), + }, + "document_date": { + "label": "票据日期", + "value": self._resolve_receipt_document_date(meta), + }, + "merchant_name": { + "label": "商户", + "value": self._resolve_receipt_merchant_name(meta), + }, + } + for index, field in enumerate(list(meta.get("document_fields") or [])): + if not isinstance(field, dict): + continue + key = str(field.get("key") or "").strip() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + stable_key = key or f"field_{index}_{label}" + if not stable_key and not label: + continue + values[stable_key] = { + "label": label or stable_key, + "value": value, + } + return values + + def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str: + editable = meta.get("editable_fields") + if isinstance(editable, dict): + value = str(editable.get("document_date") or "").strip() + if value: + return value + + fields = self._resolve_fields(meta) + for field in fields: + if field.key in {"invoice_date", "issue_date"} or field.label in {"开票日期", "发票日期"}: + return self._normalize_receipt_date_value(field.value) + + if self._is_train_ticket_meta(meta): + invoice_date = self._extract_train_invoice_date(self._receipt_text(meta)) + if invoice_date: + return invoice_date + + for field in fields: + if field.key == "document_date" or field.label in {"日期", "乘车日期", "列车出发时间", "行程日期"}: + return self._normalize_receipt_date_value(field.value) + return "" + + def _resolve_receipt_merchant_name(self, meta: dict[str, Any]) -> str: + value = self._resolve_editable_or_field(meta, "merchant_name", labels=("商户", "销售方", "收款方", "开票方")) + if value: + return value + if self._is_train_ticket_meta(meta): + return "中国铁路" + return "" + + def _resolve_editable_or_field(self, meta: dict[str, Any], key: str, *, labels: tuple[str, ...]) -> str: + editable = meta.get("editable_fields") + if isinstance(editable, dict): + value = str(editable.get(key) or "").strip() + if value: + return value + label_set = set(labels) + for field in self._resolve_fields(meta): + if field.label in label_set or field.key == key: + return field.value + return "" + + +class ReceiptFolderTrainTicketMixin: + @classmethod + def _enrich_train_ticket_field_dicts( + cls, + fields: list[dict[str, Any]], + *, + text: str, + ) -> list[dict[str, str]]: + normalized: list[dict[str, str]] = [] + for field in fields: + key = str(field.get("key") or "").strip() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + if not label or not value: + continue + if key == "trip_no" and label == "车次/航班": + label = "车次" + if key == "route" and label == "行程": + label = "行程" + normalized.append({"key": key, "label": label, "value": value}) + + def add_field(key: str, label: str, value: str) -> None: + cleaned = str(value or "").strip() + if not cleaned: + return + if any(item["key"] == key for item in normalized if item["key"]): + return + if any(item["label"] == label for item in normalized): + return + normalized.append({"key": key, "label": label, "value": cleaned}) + + invoice_date = cls._extract_train_invoice_date(text) + add_field("invoice_date", "开票日期", invoice_date) + + trip_datetime = cls._extract_train_trip_datetime(text) + add_field("trip_date", "列车出发时间", trip_datetime) + + departure, arrival = cls._extract_train_route_points(text) + add_field("departure_station", "出发地点", departure) + add_field("arrival_station", "到达地点", arrival) + if departure and arrival: + add_field("route", "行程", f"{departure}-{arrival}") + + add_field("train_no", "车次", cls._extract_first(TRAIN_NO_PATTERN, text) or cls._extract_first(TRAIN_STANDALONE_NO_PATTERN, text)) + id_number = cls._extract_train_id_number(text) + add_field("passenger_name", "乘车人", cls._extract_train_passenger_name(text, id_number=id_number)) + add_field("id_number", "身份证号", id_number) + add_field("electronic_ticket_no", "电子客票号", cls._extract_first(TRAIN_ETICKET_PATTERN, text)) + add_field("seat_class", "席别", cls._extract_first(TRAIN_SEAT_CLASS_PATTERN, text)) + carriage_no, seat_no = cls._extract_train_carriage_and_seat(text) + add_field("carriage_no", "车厢", carriage_no) + add_field("seat_no", "座位号", seat_no) + add_field("fare", "票价", cls._extract_train_fare(text)) + return normalized + + @staticmethod + def _is_train_ticket_values( + *, + document_type: str, + document_type_label: str, + scene_label: str, + text: str, + ) -> bool: + if str(document_type or "").strip().lower() == "train_ticket": + return True + compact = "".join([document_type_label, scene_label, text]).replace(" ", "") + return any(token in compact for token in ("火车", "高铁", "动车", "铁路", "电子客票", "车次")) + + @classmethod + def _is_train_ticket_meta(cls, meta: dict[str, Any]) -> bool: + return cls._is_train_ticket_values( + document_type=str(meta.get("document_type") or ""), + document_type_label=str(meta.get("document_type_label") or ""), + scene_label=str(meta.get("scene_label") or ""), + text=cls._receipt_text(meta), + ) + + @staticmethod + def _receipt_text(meta: dict[str, Any]) -> str: + field_text = "\n".join( + f"{field.get('label', '')} {field.get('value', '')}" + for field in list(meta.get("document_fields") or []) + if isinstance(field, dict) + ) + return "\n".join( + value + for value in ( + str(meta.get("ocr_text") or ""), + str(meta.get("summary") or ""), + str(meta.get("file_name") or ""), + field_text, + ) + if value + ) + + @classmethod + def _extract_train_invoice_date(cls, text: str) -> str: + match = TRAIN_INVOICE_DATE_PATTERN.search(str(text or "")) + if not match: + return "" + return cls._normalize_receipt_date_value(match.group(1)) + + @classmethod + def _extract_train_trip_datetime(cls, text: str) -> str: + raw_text = str(text or "") + candidates: list[tuple[int, int, str]] = [] + for index, match in enumerate(RECEIPT_DATE_PATTERN.finditer(raw_text)): + window = raw_text[max(0, match.start() - 14): match.end() + 8].replace(" ", "") + if any(token in window for token in ("开票日期", "发票日期", "开票时间")): + continue + value = cls._format_date_match_with_time(raw_text, match) + score = 0 + nearby = raw_text[max(0, match.start() - 32): match.end() + 32] + compact = nearby.replace(" ", "") + if ":" in value or ":" in value: + score += 8 + if any(token in compact for token in ("开车时间", "发车时间", "乘车日期", "乘车时间", "检票", "车次")): + score += 6 + if any(token in compact for token in ("二等座", "一等座", "商务座", "硬座", "软卧", "硬卧")): + score += 3 + candidates.append((score, -index, value)) + if not candidates: + return "" + return max(candidates, key=lambda item: (item[0], item[1]))[2] + + @classmethod + def _format_date_match_with_time(cls, text: str, match: re.Match[str]) -> str: + date_value = cls._normalize_receipt_date_value(match.group(1)) + if not date_value: + return "" + surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24] + time_match = RECEIPT_TIME_PATTERN.search(surrounding) + if not time_match: + return date_value + return f"{date_value} {str(time_match.group(1)).zfill(2)}:{str(time_match.group(2)).zfill(2)}" + + @staticmethod + def _normalize_receipt_date_value(value: str) -> str: + raw = str(value or "").strip() + match = RECEIPT_DATE_PATTERN.search(raw) + if not match: + return raw + normalized = match.group(1).replace("年", "-").replace("月", "-").replace("日", "") + normalized = normalized.replace("/", "-").replace(".", "-") + parts = [part for part in normalized.split("-") if part] + if len(parts) != 3: + return match.group(1) + year, month, day = parts + return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}" + + @classmethod + def _extract_train_route_points(cls, text: str) -> tuple[str, str]: + raw_text = str(text or "") + station_candidates: list[str] = [] + for line in raw_text.replace("\r", "\n").splitlines(): + candidate = cls._clean_train_station(line) + if not candidate or candidate in station_candidates: + continue + if not str(line or "").strip().endswith("站"): + continue + if any(token in candidate for token in ("发票", "客票", "铁路", "票价", "日期")): + continue + station_candidates.append(candidate) + if len(station_candidates) >= 2: + return station_candidates[0], station_candidates[1] + + match = TRAIN_ROUTE_PATTERN.search(raw_text) + if match: + departure = cls._clean_train_station(match.group(1)) + arrival = cls._clean_train_station(match.group(2)) + if departure and arrival and departure != arrival: + return departure, arrival + return "", "" + + @staticmethod + def _clean_train_station(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9\u4e00-\u9fa5()()·]", "", str(value or "")) + cleaned = re.sub(r"(?:火车站|高铁站|站)$", "", cleaned) + return cleaned.strip() + + @staticmethod + def _extract_first(pattern: re.Pattern[str], text: str) -> str: + match = pattern.search(str(text or "")) + return str(match.group(1) or "").strip() if match else "" + + @classmethod + def _extract_train_passenger_name(cls, text: str, *, id_number: str = "") -> str: + labeled = cls._extract_first(TRAIN_PASSENGER_PATTERN, text) + if labeled: + return labeled + + lines = [line.strip() for line in str(text or "").replace("\r", "\n").splitlines() if line.strip()] + for index, line in enumerate(lines): + if id_number and id_number not in line: + continue + for offset in (1, -1, 2): + target_index = index + offset + if target_index < 0 or target_index >= len(lines): + continue + candidate = cls._clean_train_passenger_candidate(lines[target_index]) + if candidate: + return candidate + for line in lines: + if "购买方名称" in line: + candidate = cls._clean_train_passenger_candidate(line.split(":", 1)[-1].split(":", 1)[-1]) + if candidate: + return candidate + return "" + + @staticmethod + def _clean_train_passenger_candidate(value: str) -> str: + cleaned = re.sub(r"[^·\u4e00-\u9fa5]", "", str(value or "")).strip() + if not 2 <= len(cleaned) <= 8: + return "" + if any(token in cleaned for token in ("电子", "客票", "铁路", "发票", "税务", "湖北省", "中国铁路", "开票", "日期")): + return "" + return cleaned + + @classmethod + def _extract_train_id_number(cls, text: str) -> str: + labeled = cls._extract_first(TRAIN_ID_PATTERN, text) + if labeled: + return labeled + for line in str(text or "").replace("\r", "\n").splitlines(): + compact_line = line.replace(" ", "") + if any(token in compact_line for token in ("发票号码", "电子客票号", "客票号", "订单号")): + continue + match = TRAIN_ID_FALLBACK_PATTERN.search(compact_line) + if match: + return str(match.group(1) or "").strip() + return "" + + @staticmethod + def _extract_train_carriage_and_seat(text: str) -> tuple[str, str]: + combined_match = TRAIN_COMBINED_SEAT_PATTERN.search(str(text or "")) + if combined_match: + return f"{combined_match.group(1)}车", combined_match.group(2) + carriage_no = ReceiptFolderService._extract_first(TRAIN_CARRIAGE_PATTERN, text).replace(" ", "") + seat_no = ReceiptFolderService._extract_first(TRAIN_SEAT_NO_PATTERN, text) + return carriage_no, seat_no + + @staticmethod + def _extract_train_fare(text: str) -> str: + match = TRAIN_FARE_PATTERN.search(str(text or "")) + if not match: + return "" + value = str(match.group(1) or "").replace(",", ".").strip() + return f"{value}元" if value else "" + + @staticmethod + def _parse_datetime(value: Any) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + +class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, ReceiptFolderTrainTicketMixin): def __init__(self) -> None: self.root = (get_settings().resolved_storage_root_dir / "receipt_folder").resolve() @@ -390,645 +1039,3 @@ class ReceiptFolderService: return source_path, source_media_type, source_name raise FileNotFoundError("Receipt preview not found") - @staticmethod - def normalize_filename(filename: str | None) -> str: - normalized = Path(str(filename or "").strip()).name - normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", normalized).strip("._") - return normalized or "receipt.bin" - - @staticmethod - def resolve_media_type(filename: str, fallback: str | None = None) -> str: - return str(mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream") - - def _owner_root(self, owner_key: str) -> Path: - return self._assert_child(self.root / owner_key) - - def _receipt_dir(self, owner_key: str, receipt_id: str) -> Path: - normalized = str(receipt_id or "").strip() - if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", normalized): - raise FileNotFoundError("Receipt not found") - path = self._assert_child(self._owner_root(owner_key) / normalized) - if not path.exists() or not path.is_dir(): - raise FileNotFoundError("Receipt not found") - return path - - def _assert_child(self, path: Path) -> Path: - self.root.mkdir(parents=True, exist_ok=True) - resolved = path.resolve() - try: - resolved.relative_to(self.root) - except ValueError as exc: - raise FileNotFoundError("Receipt path is invalid") from exc - return resolved - - @staticmethod - def _owner_key(current_user: CurrentUserContext) -> str: - raw = str(current_user.username or current_user.name or "anonymous").strip().lower() - normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw).strip("._") - return normalized or "anonymous" - - @staticmethod - def _should_persist_source(filename: str, content: bytes) -> bool: - if not content: - return False - return Path(str(filename or "")).suffix.lower() in SUPPORTED_SUFFIXES - - def _write_preview_asset( - self, - *, - receipt_dir: Path, - source_path: Path, - media_type: str, - document: Any | None, - ) -> dict[str, Any]: - preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip() - decoded = ExpenseClaimAttachmentPresentation.decode_data_url(preview_data_url) - if decoded is not None: - preview_media_type, preview_content = decoded - suffix = mimetypes.guess_extension(preview_media_type) or ".bin" - preview_name = f"preview{suffix}" - preview_path = receipt_dir / preview_name - preview_path.write_bytes(preview_content) - return { - "previewable": True, - "preview_kind": "image", - "preview_file_name": preview_name, - "preview_media_type": preview_media_type, - } - if self._is_previewable(media_type): - return { - "previewable": True, - "preview_kind": "image" if media_type.startswith("image/") else "pdf", - "preview_file_name": source_path.name, - "preview_media_type": media_type, - } - return { - "previewable": False, - "preview_kind": "", - "preview_file_name": "", - "preview_media_type": "", - } - - @staticmethod - def _is_previewable(media_type: str) -> bool: - return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf" - - @classmethod - def _build_document_meta(cls, document: Any | None) -> dict[str, Any]: - fields = [] - for field in list(getattr(document, "document_fields", []) or []): - if isinstance(field, dict): - fields.append( - { - "key": str(field.get("key") or "").strip(), - "label": str(field.get("label") or "").strip(), - "value": str(field.get("value") or "").strip(), - } - ) - else: - fields.append( - { - "key": str(getattr(field, "key", "") or "").strip(), - "label": str(getattr(field, "label", "") or "").strip(), - "value": str(getattr(field, "value", "") or "").strip(), - } - ) - fields = [field for field in fields if field["label"] and field["value"]] - ocr_text = str(getattr(document, "text", "") or "") - summary = str(getattr(document, "summary", "") or "") - document_type = str(getattr(document, "document_type", "") or "other") - document_type_label = str(getattr(document, "document_type_label", "") or "其他单据") - scene_label = str(getattr(document, "scene_label", "") or "其他票据") - if cls._is_train_ticket_values( - document_type=document_type, - document_type_label=document_type_label, - scene_label=scene_label, - text=f"{summary}\n{ocr_text}", - ): - fields = cls._enrich_train_ticket_field_dicts( - fields, - text=f"{ocr_text}\n{summary}\n{str(getattr(document, 'filename', '') or '')}", - ) - return { - "engine": str(getattr(document, "engine", "") or ""), - "model": str(getattr(document, "model", "") or ""), - "ocr_text": ocr_text, - "summary": summary, - "ocr_avg_score": float(getattr(document, "avg_score", 0.0) or 0.0), - "ocr_line_count": int(getattr(document, "line_count", 0) or 0), - "page_count": int(getattr(document, "page_count", 1) or 1), - "document_type": document_type, - "document_type_label": document_type_label, - "scene_code": str(getattr(document, "scene_code", "") or "other"), - "scene_label": scene_label, - "ocr_classification_source": str(getattr(document, "classification_source", "") or ""), - "ocr_classification_confidence": float(getattr(document, "classification_confidence", 0.0) or 0.0), - "ocr_classification_evidence": [ - str(value) for value in list(getattr(document, "classification_evidence", []) or []) if str(value).strip() - ], - "document_fields": fields, - "editable_fields": {}, - "ocr_warnings": [str(value) for value in list(getattr(document, "warnings", []) or []) if str(value).strip()], - } - - def _iter_owner_meta(self, owner_key: str) -> list[dict[str, Any]]: - owner_root = self._owner_root(owner_key) - if not owner_root.exists(): - return [] - metas = [] - for meta_path in owner_root.glob("*/meta.json"): - meta = self._read_meta(meta_path.parent) - if meta: - metas.append(meta) - return metas - - def _read_receipt_meta(self, receipt_id: str, current_user: CurrentUserContext) -> dict[str, Any]: - return self._read_meta(self._receipt_dir(self._owner_key(current_user), receipt_id)) - - def _resolve_existing_item( - self, - receipt_id: str | None, - current_user: CurrentUserContext, - ) -> ReceiptFolderItemRead | None: - normalized = str(receipt_id or "").strip() - if not normalized: - return None - try: - return self._build_item(self._read_receipt_meta(normalized, current_user)) - except FileNotFoundError: - return None - - @staticmethod - def _meta_path(receipt_dir: Path) -> Path: - return receipt_dir / "meta.json" - - def _read_meta(self, receipt_dir: Path) -> dict[str, Any]: - meta_path = self._meta_path(receipt_dir) - if not meta_path.exists(): - raise FileNotFoundError("Receipt not found") - try: - payload = json.loads(meta_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise FileNotFoundError("Receipt metadata not found") from exc - return payload if isinstance(payload, dict) else {} - - def _write_meta(self, receipt_dir: Path, payload: dict[str, Any]) -> None: - self._meta_path(receipt_dir).write_text( - json.dumps(payload, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - - @staticmethod - def _content_hash(content: bytes) -> str: - return hashlib.sha256(content or b"").hexdigest() if content else "" - - @staticmethod - def _operator_label(current_user: CurrentUserContext) -> str: - return str(current_user.name or current_user.username or "当前用户").strip() or "当前用户" - - @staticmethod - def _matches_status(meta: dict[str, Any], status_filter: str) -> bool: - if status_filter in {"", "all"}: - return True - return str(meta.get("status") or "unlinked").strip().lower() == status_filter - - def _build_item(self, meta: dict[str, Any]) -> ReceiptFolderItemRead: - receipt_id = str(meta.get("id") or "").strip() - status_value = str(meta.get("status") or "unlinked").strip() or "unlinked" - return ReceiptFolderItemRead( - id=receipt_id, - file_name=str(meta.get("file_name") or ""), - media_type=str(meta.get("media_type") or "application/octet-stream"), - size_bytes=int(meta.get("size_bytes") or 0), - status=status_value, - status_label="已关联" if status_value == "linked" else "未关联", - document_type=str(meta.get("document_type") or "other"), - document_type_label=str(meta.get("document_type_label") or "其他单据"), - scene_code=str(meta.get("scene_code") or "other"), - scene_label=str(meta.get("scene_label") or "其他票据"), - summary=str(meta.get("summary") or ""), - amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")), - document_date=self._resolve_receipt_document_date(meta), - merchant_name=self._resolve_receipt_merchant_name(meta), - avg_score=float(meta.get("ocr_avg_score") or 0.0), - uploaded_at=self._parse_datetime(meta.get("uploaded_at")), - linked_at=self._parse_datetime(meta.get("linked_at")), - linked_claim_id=str(meta.get("linked_claim_id") or ""), - linked_claim_no=str(meta.get("linked_claim_no") or ""), - previewable=bool(meta.get("previewable")), - preview_kind=str(meta.get("preview_kind") or ""), - preview_url=f"/receipt-folder/{receipt_id}/preview" if bool(meta.get("previewable")) and receipt_id else "", - source_url=f"/receipt-folder/{receipt_id}/source" if receipt_id else "", - warnings=[str(value) for value in list(meta.get("ocr_warnings") or []) if str(value).strip()], - ) - - def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]: - fields = [ - ReceiptFolderFieldRead( - key=str(field.get("key") or ""), - label=str(field.get("label") or ""), - value=str(field.get("value") or ""), - ) - for field in list(meta.get("document_fields") or []) - if isinstance(field, dict) and str(field.get("label") or "").strip() - ] - if self._is_train_ticket_meta(meta): - return [ - ReceiptFolderFieldRead(**field) - for field in self._enrich_train_ticket_field_dicts( - [field.model_dump() for field in fields], - text=self._receipt_text(meta), - ) - ] - return fields - - def _resolve_edit_logs(self, meta: dict[str, Any]) -> list[dict[str, Any]]: - logs = [] - for log in list(meta.get("edit_logs") or []): - if not isinstance(log, dict): - continue - changes = [ - { - "key": str(change.get("key") or ""), - "label": str(change.get("label") or ""), - "before": str(change.get("before") or ""), - "after": str(change.get("after") or ""), - } - for change in list(log.get("changes") or []) - if isinstance(change, dict) - and str(change.get("label") or change.get("key") or "").strip() - ] - if not changes: - continue - logs.append( - { - "operated_at": self._parse_datetime(log.get("operated_at")), - "operator": str(log.get("operator") or "当前用户").strip() or "当前用户", - "changes": changes, - } - ) - return logs - - def _build_edit_changes(self, before_meta: dict[str, Any], after_meta: dict[str, Any]) -> list[dict[str, str]]: - before_values = self._flatten_editable_receipt_values(before_meta) - after_values = self._flatten_editable_receipt_values(after_meta) - changes = [] - for key in sorted(set(before_values) | set(after_values)): - before = before_values.get(key, {}) - after = after_values.get(key, {}) - before_value = str(before.get("value") or "").strip() - after_value = str(after.get("value") or "").strip() - if before_value == after_value: - continue - label = str(after.get("label") or before.get("label") or key).strip() - changes.append( - { - "key": key, - "label": label, - "before": before_value, - "after": after_value, - } - ) - return changes - - def _flatten_editable_receipt_values(self, meta: dict[str, Any]) -> dict[str, dict[str, str]]: - values = { - "document_type_label": { - "label": "票据类型", - "value": str(meta.get("document_type_label") or "").strip(), - }, - "scene_label": { - "label": "费用场景", - "value": str(meta.get("scene_label") or "").strip(), - }, - "summary": { - "label": "摘要", - "value": str(meta.get("summary") or "").strip(), - }, - "amount": { - "label": "金额", - "value": self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")), - }, - "document_date": { - "label": "票据日期", - "value": self._resolve_receipt_document_date(meta), - }, - "merchant_name": { - "label": "商户", - "value": self._resolve_receipt_merchant_name(meta), - }, - } - for index, field in enumerate(list(meta.get("document_fields") or [])): - if not isinstance(field, dict): - continue - key = str(field.get("key") or "").strip() - label = str(field.get("label") or "").strip() - value = str(field.get("value") or "").strip() - stable_key = key or f"field_{index}_{label}" - if not stable_key and not label: - continue - values[stable_key] = { - "label": label or stable_key, - "value": value, - } - return values - - def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str: - editable = meta.get("editable_fields") - if isinstance(editable, dict): - value = str(editable.get("document_date") or "").strip() - if value: - return value - - fields = self._resolve_fields(meta) - for field in fields: - if field.key in {"invoice_date", "issue_date"} or field.label in {"开票日期", "发票日期"}: - return self._normalize_receipt_date_value(field.value) - - if self._is_train_ticket_meta(meta): - invoice_date = self._extract_train_invoice_date(self._receipt_text(meta)) - if invoice_date: - return invoice_date - - for field in fields: - if field.key == "document_date" or field.label in {"日期", "乘车日期", "列车出发时间", "行程日期"}: - return self._normalize_receipt_date_value(field.value) - return "" - - def _resolve_receipt_merchant_name(self, meta: dict[str, Any]) -> str: - value = self._resolve_editable_or_field(meta, "merchant_name", labels=("商户", "销售方", "收款方", "开票方")) - if value: - return value - if self._is_train_ticket_meta(meta): - return "中国铁路" - return "" - - def _resolve_editable_or_field(self, meta: dict[str, Any], key: str, *, labels: tuple[str, ...]) -> str: - editable = meta.get("editable_fields") - if isinstance(editable, dict): - value = str(editable.get(key) or "").strip() - if value: - return value - label_set = set(labels) - for field in self._resolve_fields(meta): - if field.label in label_set or field.key == key: - return field.value - return "" - - @classmethod - def _enrich_train_ticket_field_dicts( - cls, - fields: list[dict[str, Any]], - *, - text: str, - ) -> list[dict[str, str]]: - normalized: list[dict[str, str]] = [] - for field in fields: - key = str(field.get("key") or "").strip() - label = str(field.get("label") or "").strip() - value = str(field.get("value") or "").strip() - if not label or not value: - continue - if key == "trip_no" and label == "车次/航班": - label = "车次" - if key == "route" and label == "行程": - label = "行程" - normalized.append({"key": key, "label": label, "value": value}) - - def add_field(key: str, label: str, value: str) -> None: - cleaned = str(value or "").strip() - if not cleaned: - return - if any(item["key"] == key for item in normalized if item["key"]): - return - if any(item["label"] == label for item in normalized): - return - normalized.append({"key": key, "label": label, "value": cleaned}) - - invoice_date = cls._extract_train_invoice_date(text) - add_field("invoice_date", "开票日期", invoice_date) - - trip_datetime = cls._extract_train_trip_datetime(text) - add_field("trip_date", "列车出发时间", trip_datetime) - - departure, arrival = cls._extract_train_route_points(text) - add_field("departure_station", "出发地点", departure) - add_field("arrival_station", "到达地点", arrival) - if departure and arrival: - add_field("route", "行程", f"{departure}-{arrival}") - - add_field("train_no", "车次", cls._extract_first(TRAIN_NO_PATTERN, text) or cls._extract_first(TRAIN_STANDALONE_NO_PATTERN, text)) - id_number = cls._extract_train_id_number(text) - add_field("passenger_name", "乘车人", cls._extract_train_passenger_name(text, id_number=id_number)) - add_field("id_number", "身份证号", id_number) - add_field("electronic_ticket_no", "电子客票号", cls._extract_first(TRAIN_ETICKET_PATTERN, text)) - add_field("seat_class", "席别", cls._extract_first(TRAIN_SEAT_CLASS_PATTERN, text)) - carriage_no, seat_no = cls._extract_train_carriage_and_seat(text) - add_field("carriage_no", "车厢", carriage_no) - add_field("seat_no", "座位号", seat_no) - add_field("fare", "票价", cls._extract_train_fare(text)) - return normalized - - @staticmethod - def _is_train_ticket_values( - *, - document_type: str, - document_type_label: str, - scene_label: str, - text: str, - ) -> bool: - if str(document_type or "").strip().lower() == "train_ticket": - return True - compact = "".join([document_type_label, scene_label, text]).replace(" ", "") - return any(token in compact for token in ("火车", "高铁", "动车", "铁路", "电子客票", "车次")) - - @classmethod - def _is_train_ticket_meta(cls, meta: dict[str, Any]) -> bool: - return cls._is_train_ticket_values( - document_type=str(meta.get("document_type") or ""), - document_type_label=str(meta.get("document_type_label") or ""), - scene_label=str(meta.get("scene_label") or ""), - text=cls._receipt_text(meta), - ) - - @staticmethod - def _receipt_text(meta: dict[str, Any]) -> str: - field_text = "\n".join( - f"{field.get('label', '')} {field.get('value', '')}" - for field in list(meta.get("document_fields") or []) - if isinstance(field, dict) - ) - return "\n".join( - value - for value in ( - str(meta.get("ocr_text") or ""), - str(meta.get("summary") or ""), - str(meta.get("file_name") or ""), - field_text, - ) - if value - ) - - @classmethod - def _extract_train_invoice_date(cls, text: str) -> str: - match = TRAIN_INVOICE_DATE_PATTERN.search(str(text or "")) - if not match: - return "" - return cls._normalize_receipt_date_value(match.group(1)) - - @classmethod - def _extract_train_trip_datetime(cls, text: str) -> str: - raw_text = str(text or "") - candidates: list[tuple[int, int, str]] = [] - for index, match in enumerate(RECEIPT_DATE_PATTERN.finditer(raw_text)): - window = raw_text[max(0, match.start() - 14): match.end() + 8].replace(" ", "") - if any(token in window for token in ("开票日期", "发票日期", "开票时间")): - continue - value = cls._format_date_match_with_time(raw_text, match) - score = 0 - nearby = raw_text[max(0, match.start() - 32): match.end() + 32] - compact = nearby.replace(" ", "") - if ":" in value or ":" in value: - score += 8 - if any(token in compact for token in ("开车时间", "发车时间", "乘车日期", "乘车时间", "检票", "车次")): - score += 6 - if any(token in compact for token in ("二等座", "一等座", "商务座", "硬座", "软卧", "硬卧")): - score += 3 - candidates.append((score, -index, value)) - if not candidates: - return "" - return max(candidates, key=lambda item: (item[0], item[1]))[2] - - @classmethod - def _format_date_match_with_time(cls, text: str, match: re.Match[str]) -> str: - date_value = cls._normalize_receipt_date_value(match.group(1)) - if not date_value: - return "" - surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24] - time_match = RECEIPT_TIME_PATTERN.search(surrounding) - if not time_match: - return date_value - return f"{date_value} {str(time_match.group(1)).zfill(2)}:{str(time_match.group(2)).zfill(2)}" - - @staticmethod - def _normalize_receipt_date_value(value: str) -> str: - raw = str(value or "").strip() - match = RECEIPT_DATE_PATTERN.search(raw) - if not match: - return raw - normalized = match.group(1).replace("年", "-").replace("月", "-").replace("日", "") - normalized = normalized.replace("/", "-").replace(".", "-") - parts = [part for part in normalized.split("-") if part] - if len(parts) != 3: - return match.group(1) - year, month, day = parts - return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}" - - @classmethod - def _extract_train_route_points(cls, text: str) -> tuple[str, str]: - raw_text = str(text or "") - station_candidates: list[str] = [] - for line in raw_text.replace("\r", "\n").splitlines(): - candidate = cls._clean_train_station(line) - if not candidate or candidate in station_candidates: - continue - if not str(line or "").strip().endswith("站"): - continue - if any(token in candidate for token in ("发票", "客票", "铁路", "票价", "日期")): - continue - station_candidates.append(candidate) - if len(station_candidates) >= 2: - return station_candidates[0], station_candidates[1] - - match = TRAIN_ROUTE_PATTERN.search(raw_text) - if match: - departure = cls._clean_train_station(match.group(1)) - arrival = cls._clean_train_station(match.group(2)) - if departure and arrival and departure != arrival: - return departure, arrival - return "", "" - - @staticmethod - def _clean_train_station(value: str) -> str: - cleaned = re.sub(r"[^A-Za-z0-9\u4e00-\u9fa5()()·]", "", str(value or "")) - cleaned = re.sub(r"(?:火车站|高铁站|站)$", "", cleaned) - return cleaned.strip() - - @staticmethod - def _extract_first(pattern: re.Pattern[str], text: str) -> str: - match = pattern.search(str(text or "")) - return str(match.group(1) or "").strip() if match else "" - - @classmethod - def _extract_train_passenger_name(cls, text: str, *, id_number: str = "") -> str: - labeled = cls._extract_first(TRAIN_PASSENGER_PATTERN, text) - if labeled: - return labeled - - lines = [line.strip() for line in str(text or "").replace("\r", "\n").splitlines() if line.strip()] - for index, line in enumerate(lines): - if id_number and id_number not in line: - continue - for offset in (1, -1, 2): - target_index = index + offset - if target_index < 0 or target_index >= len(lines): - continue - candidate = cls._clean_train_passenger_candidate(lines[target_index]) - if candidate: - return candidate - for line in lines: - if "购买方名称" in line: - candidate = cls._clean_train_passenger_candidate(line.split(":", 1)[-1].split(":", 1)[-1]) - if candidate: - return candidate - return "" - - @staticmethod - def _clean_train_passenger_candidate(value: str) -> str: - cleaned = re.sub(r"[^·\u4e00-\u9fa5]", "", str(value or "")).strip() - if not 2 <= len(cleaned) <= 8: - return "" - if any(token in cleaned for token in ("电子", "客票", "铁路", "发票", "税务", "湖北省", "中国铁路", "开票", "日期")): - return "" - return cleaned - - @classmethod - def _extract_train_id_number(cls, text: str) -> str: - labeled = cls._extract_first(TRAIN_ID_PATTERN, text) - if labeled: - return labeled - for line in str(text or "").replace("\r", "\n").splitlines(): - compact_line = line.replace(" ", "") - if any(token in compact_line for token in ("发票号码", "电子客票号", "客票号", "订单号")): - continue - match = TRAIN_ID_FALLBACK_PATTERN.search(compact_line) - if match: - return str(match.group(1) or "").strip() - return "" - - @staticmethod - def _extract_train_carriage_and_seat(text: str) -> tuple[str, str]: - combined_match = TRAIN_COMBINED_SEAT_PATTERN.search(str(text or "")) - if combined_match: - return f"{combined_match.group(1)}车", combined_match.group(2) - carriage_no = ReceiptFolderService._extract_first(TRAIN_CARRIAGE_PATTERN, text).replace(" ", "") - seat_no = ReceiptFolderService._extract_first(TRAIN_SEAT_NO_PATTERN, text) - return carriage_no, seat_no - - @staticmethod - def _extract_train_fare(text: str) -> str: - match = TRAIN_FARE_PATTERN.search(str(text or "")) - if not match: - return "" - value = str(match.group(1) or "").replace(",", ".").strip() - return f"{value}元" if value else "" - - @staticmethod - def _parse_datetime(value: Any) -> datetime | None: - raw = str(value or "").strip() - if not raw: - return None - try: - return datetime.fromisoformat(raw) - except ValueError: - return None diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index ad69b1a..14b7d06 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -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 + diff --git a/server/src/app/services/steward_planner.py b/server/src/app/services/steward_planner.py index 70e75a7..cea7345 100644 --- a/server/src/app/services/steward_planner.py +++ b/server/src/app/services/steward_planner.py @@ -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, + ) + diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index f708386..84a48ed 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -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" + diff --git a/server/tests/test_code_size_limits.py b/server/tests/test_code_size_limits.py new file mode 100644 index 0000000..e1849ba --- /dev/null +++ b/server/tests/test_code_size_limits.py @@ -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 == [] diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index d0712ba..80d2a51 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -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, diff --git a/web/src/assets/styles/components/expense-profile-detail-modal.css b/web/src/assets/styles/components/expense-profile-detail-modal.css new file mode 100644 index 0000000..b2eceec --- /dev/null +++ b/web/src/assets/styles/components/expense-profile-detail-modal.css @@ -0,0 +1,619 @@ +:global(.expense-profile-dialog-overlay) { + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)), + rgba(15, 23, 42, 0.36); +} + +:global(.expense-profile-dialog.el-dialog) { + max-height: calc(100vh - 56px); + max-height: calc(100dvh - 56px); + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 4px; + background: #ffffff; + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2); +} + +:global(.expense-profile-dialog .el-dialog__header), +:global(.expense-profile-dialog .expense-profile-dialog-body), +:global(.expense-profile-dialog .el-dialog__footer) { + padding: 0; + margin: 0; +} + +:global(.expense-profile-dialog-zoom-enter-active), +:global(.expense-profile-dialog-zoom-leave-active) { + transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1); +} + +:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog), +:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) { + transform-origin: center center; + will-change: transform, opacity; +} + +:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) { + animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both; +} + +:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) { + animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +:global(.expense-profile-dialog-zoom-enter-from), +:global(.expense-profile-dialog-zoom-leave-to) { + opacity: 0; +} + +.profile-dialog-header, +.profile-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + background: #ffffff; +} + +.profile-dialog-header { + border-bottom: 1px solid #e2e8f0; +} + +.profile-dialog-footer { + justify-content: flex-start; + border-top: 1px solid #e2e8f0; +} + +.profile-dialog-title-block { + min-width: 0; +} + +.profile-dialog-eyebrow, +.profile-section-title small { + color: #64748b; + font-size: 10px; + font-weight: 850; + letter-spacing: 0; + text-transform: uppercase; +} + +.profile-dialog-header h2 { + margin: 3px 0 4px; + color: #0f172a; + font-size: 19px; + line-height: 1.25; + font-weight: 850; +} + +.profile-dialog-header p, +.profile-dialog-footer span { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; + font-weight: 650; +} + +.profile-dialog-close { + width: 32px; + height: 32px; + min-height: 32px; + padding: 0; + border-radius: 4px; + color: #334155; + font-size: 18px; +} + +.profile-dialog-close:hover { + background: #eef4fb; + color: var(--theme-primary-active); +} + +.profile-dialog-content { + max-height: min(580px, calc(100vh - 176px)); + max-height: min(580px, calc(100dvh - 176px)); + min-height: 0; + display: grid; + gap: 12px; + padding: 14px; + overflow: auto; + background: #f8fafc; +} + +.profile-dialog-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 4px; + background: #ffffff; + color: #475569; + font-size: 12px; + font-weight: 750; +} + +.profile-dialog-alert.is-error { + border-color: rgba(220, 38, 38, 0.24); + background: #fff7f7; + color: #b91c1c; +} + +.profile-dialog-alert.is-empty { + border-color: rgba(245, 158, 11, 0.28); + background: #fffaf0; + color: #92400e; +} + +.profile-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.profile-summary-item, +.profile-panel { + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #ffffff; +} + +.profile-summary-item { + min-width: 0; + display: grid; + gap: 4px; + padding: 10px 12px; +} + +.profile-summary-item span, +.profile-operation-copy span, +.profile-operation-row time { + color: #64748b; + font-size: 11.5px; + font-weight: 650; +} + +.profile-summary-item strong { + color: #0f172a; + font-size: 18px; + line-height: 1.15; + font-weight: 850; + font-variant-numeric: tabular-nums; +} + +.profile-summary-item small { + margin-left: 2px; + color: #64748b; + font-size: 11px; + font-weight: 650; +} + +.profile-summary-item em { + overflow: hidden; + color: #94a3b8; + font-size: 11px; + font-style: normal; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-analysis-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr); + gap: 12px; +} + +.profile-panel { + min-width: 0; + display: grid; + gap: 10px; + padding: 12px; +} + +.profile-tags-panel { + grid-template-rows: auto minmax(0, 1fr); + align-content: stretch; + min-height: 312px; +} + +.profile-radar-panel { + grid-template-rows: auto minmax(0, 1fr) auto; + align-content: stretch; + min-height: 312px; +} + +.profile-section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.profile-section-title > div { + min-width: 0; + display: grid; + gap: 2px; +} + +.profile-section-title span { + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.profile-radar-title { align-items: flex-start; } + +.profile-radar-view-select { + width: 118px; + flex: 0 0 118px; +} +.profile-radar-view-select :deep(.el-select__wrapper) { + min-height: 28px; + border-radius: 4px; + box-shadow: 0 0 0 1px #cbd5e1 inset; + color: #334155; + font-size: 12px; + font-weight: 750; +} + +.profile-operation-list { + display: grid; + gap: 8px; +} + +.profile-panel-empty { + margin: 0; + padding: 18px 12px; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + justify-self: stretch; + box-sizing: border-box; + min-height: 100%; + border: 1px dashed #cbd5e1; + border-radius: 4px; + background: #f8fafc; + color: #64748b; + font-size: 12px; + line-height: 1.5; + font-weight: 700; + text-align: center; +} + +.profile-tags-panel > .profile-panel-empty { + min-height: 244px; +} + +.profile-radar-empty { + min-height: 268px; +} + +.profile-operation-copy strong { + overflow: hidden; + color: #0f172a; + font-size: 13px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-operation-status { + border-radius: 4px; + font-weight: 800; +} + +.profile-radar-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + justify-items: stretch; + min-height: 300px; + animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.profile-radar-chart { + width: 100%; + height: 300px; +} + +.profile-behavior-tags { + display: grid; + gap: 8px; + padding-top: 10px; + min-height: 59px; + border-top: 1px solid #e8eef5; +} + +.profile-behavior-tags.is-empty { visibility: hidden; } + +.profile-behavior-tags-title { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.profile-behavior-tag-list { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.profile-behavior-tag { + --behavior-tag-rgb: 58, 124, 165; + --behavior-tag-text: #235d7e; + max-width: 132px; + overflow: hidden; + padding: 4px 9px; + border: 1px solid rgba(var(--behavior-tag-rgb), 0.24); + border-radius: 999px; + background: rgba(var(--behavior-tag-rgb), 0.08); + color: var(--behavior-tag-text); + font-size: 11.5px; + line-height: 1.25; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; + animation: profileBehaviorTagIn 260ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.profile-behavior-tag--risk { + --behavior-tag-rgb: 245, 158, 11; + --behavior-tag-text: #92400e; +} + +.profile-behavior-tag--positive { + --behavior-tag-rgb: 16, 185, 129; + --behavior-tag-text: #047857; +} + +.profile-behavior-tag--accent-0 { + --behavior-tag-rgb: 58, 124, 165; + --behavior-tag-text: #235d7e; +} + +.profile-behavior-tag--accent-1 { + --behavior-tag-rgb: 15, 159, 143; + --behavior-tag-text: #0f766e; +} + +.profile-behavior-tag--accent-2 { + --behavior-tag-rgb: 245, 158, 11; + --behavior-tag-text: #92400e; +} + +.profile-behavior-tag--accent-3 { + --behavior-tag-rgb: 124, 58, 237; + --behavior-tag-text: #5b21b6; +} + +.profile-behavior-tag--accent-4 { + --behavior-tag-rgb: 220, 38, 38; + --behavior-tag-text: #991b1b; +} + +.profile-behavior-tag--accent-5 { + --behavior-tag-rgb: 37, 99, 235; + --behavior-tag-text: #1d4ed8; +} + +.profile-behavior-tag--accent-6 { + --behavior-tag-rgb: 22, 163, 74; + --behavior-tag-text: #15803d; +} + +.profile-behavior-tag--accent-7 { + --behavior-tag-rgb: 219, 39, 119; + --behavior-tag-text: #be185d; +} + +.profile-operation-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 9px 0; + border-top: 1px solid #e8eef5; +} + +.profile-operation-row:first-child { + border-top: 0; + padding-top: 0; +} + +.profile-operation-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.profile-operation-status { + justify-self: end; +} + +@media (min-width: 861px) and (max-width: 1440px), + (min-width: 861px) and (max-height: 820px) { + :global(.expense-profile-dialog.el-dialog) { + width: min(900px, calc(100vw - 96px)) !important; + max-height: calc(100vh - 64px); + max-height: calc(100dvh - 64px); + } + + .profile-dialog-header, + .profile-dialog-footer { + gap: 12px; + padding: 12px 16px; + } + + .profile-dialog-header h2 { + margin: 2px 0 3px; + font-size: 17px; + } + + .profile-dialog-header p, + .profile-dialog-footer span { + font-size: 11.5px; + } + + .profile-dialog-content { + max-height: min(520px, calc(100vh - 152px)); + max-height: min(520px, calc(100dvh - 152px)); + gap: 10px; + padding: 12px; + } + + .profile-summary-grid, + .profile-analysis-grid { + gap: 8px; + } + + .profile-summary-item { + gap: 3px; + padding: 8px 10px; + } + + .profile-summary-item strong { + font-size: 16px; + } + + .profile-panel { + gap: 8px; + padding: 10px; + } + + .profile-analysis-grid { + grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr); + } + + .profile-tags-panel, + .profile-radar-panel { + min-height: 272px; + } + + .profile-tags-panel > .profile-panel-empty { + min-height: 210px; + } + + .profile-radar-empty { + min-height: 220px; + } + + .profile-radar-layout { + min-height: 248px; + } + + .profile-radar-chart { + height: 248px; + } + + .profile-behavior-tags { + gap: 6px; + min-height: 50px; + padding-top: 8px; + } + + .profile-operation-list { + gap: 6px; + } + + .profile-operation-row { + gap: 8px; + padding: 7px 0; + } +} + +@keyframes expenseProfileDialogIn { + 0% { + opacity: 0; + transform: scale3d(0.94, 0.94, 1); + } + + 100% { + opacity: 1; + transform: scale3d(1, 1, 1); + } +} + +@keyframes profileRadarEnter { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes profileBehaviorTagIn { + 0% { + opacity: 0; + transform: translateY(4px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes expenseProfileDialogOut { + 0% { + opacity: 1; + transform: scale3d(1, 1, 1); + } + + 100% { + opacity: 0; + transform: scale3d(0.96, 0.96, 1); + } +} + +@media (max-width: 860px) { + :global(.expense-profile-dialog.el-dialog) { + width: calc(100vw - 24px) !important; + } + + .profile-summary-grid, + .profile-analysis-grid, + .profile-radar-layout { + grid-template-columns: 1fr; + } + + .profile-dialog-content { + max-height: calc(100vh - 170px); + } +} + +@media (max-width: 560px) { + .profile-dialog-header, + .profile-dialog-footer { + align-items: flex-start; + } + + .profile-dialog-footer { + flex-direction: column; + } + + .profile-operation-row { + grid-template-columns: 1fr; + align-items: start; + } + + .profile-operation-status { + justify-self: start; + } +} + +@media (prefers-reduced-motion: reduce) { + :global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog), + :global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog), + .profile-radar-layout, + .profile-behavior-tag { + animation-duration: 1ms !important; + } +} diff --git a/web/src/assets/styles/components/personal-workbench-ai-mode.css b/web/src/assets/styles/components/personal-workbench-ai-mode.css index 45ffdc1..b5942ab 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -995,6 +995,148 @@ line-height: 1.55; } +.workbench-ai-ocr-detail-panel { + display: grid; + gap: 12px; + margin: -8px 0 22px; + border: 1px solid rgba(191, 219, 254, 0.52); + border-radius: 14px; + background: rgba(248, 250, 252, 0.72); + overflow: hidden; +} + +.workbench-ai-ocr-detail-toggle { + width: 100%; + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 10px 12px 10px 14px; + border: 0; + background: transparent; + color: #1e3a8a; + cursor: pointer; +} + +.workbench-ai-ocr-detail-toggle:hover { + background: rgba(239, 246, 255, 0.78); +} + +.workbench-ai-ocr-detail-toggle-left { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 10px; +} + +.workbench-ai-ocr-detail-toggle strong { + color: #1e3a8a; + font-size: 13px; + font-weight: 860; + line-height: 1.35; +} + +.workbench-ai-ocr-detail-toggle small { + color: #64748b; + font-size: 12px; + font-weight: 720; + line-height: 1.35; +} + +.workbench-ai-ocr-detail-toggle > i { + color: #64748b; + font-size: 18px; + line-height: 1; +} + +.workbench-ai-ocr-detail-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #38bdf8; + box-shadow: 0 0 0 5px rgba(56, 189, 248, 0.13); +} + +.workbench-ai-ocr-detail-body { + display: grid; + gap: 10px; + padding: 0 14px 14px; +} + +.workbench-ai-ocr-document { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(226, 232, 240, 0.84); + border-radius: 12px; + background: rgba(255, 255, 255, 0.78); +} + +.workbench-ai-ocr-document__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.workbench-ai-ocr-document__head strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 850; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workbench-ai-ocr-document__head span { + flex: 0 0 auto; + color: #64748b; + font-size: 12px; + font-weight: 720; +} + +.workbench-ai-ocr-document__summary { + margin: 0; + color: #64748b; + font-size: 13px; + font-weight: 560; + line-height: 1.55; +} + +.workbench-ai-ocr-document__fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 14px; +} + +.workbench-ai-ocr-document__field { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 10px; + align-items: start; + min-width: 0; +} + +.workbench-ai-ocr-document__field span { + color: #94a3b8; + font-size: 12px; + font-weight: 720; + line-height: 1.45; +} + +.workbench-ai-ocr-document__field strong { + min-width: 0; + color: #334155; + font-size: 13px; + font-weight: 760; + line-height: 1.45; + overflow-wrap: anywhere; +} + .workbench-ai-pending-line { color: #64748b; font-size: 15px; @@ -1476,6 +1618,58 @@ letter-spacing: 0; } +.workbench-ai-answer-markdown :deep(.ai-attachment-association-card) { + background-image: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.94)), + url("../../ai-document-card-bg.png"); +} + +.workbench-ai-answer-markdown :deep(.ai-attachment-association-card .ai-document-card__head) { + background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(240, 253, 250, 0.82)); +} + +.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card) { + box-shadow: + inset 0 0 0 1px rgba(147, 197, 253, 0.42), + 0 1px 2px rgba(15, 23, 42, 0.03), + 0 12px 28px rgba(37, 99, 235, 0.045); +} + +.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__head) { + background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(239, 246, 255, 0.74)); +} + +.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__reason) { + color: #1d4ed8; +} + +.workbench-ai-answer-markdown :deep(.ai-ocr-recognition-card .ai-document-card__status) { + color: #2563eb; +} + +.workbench-ai-answer-markdown :deep(.ai-attachment-association__details) { + gap: 14px 26px; +} + +.workbench-ai-answer-markdown :deep(.ai-attachment-association__details .ai-document-card__field--wide) { + grid-column: 1 / -1; +} + +.workbench-ai-answer-markdown :deep(.ai-attachment-association__muted) { + color: #64748b; + font-weight: 680; +} + +.workbench-ai-answer-markdown :deep(.ai-attachment-association__note) { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(203, 213, 225, 0.68); + color: #64748b; + font-size: 13px; + font-weight: 650; + line-height: 1.6; +} + .workbench-ai-answer-markdown :deep(.ai-document-card__action) { display: inline-flex; align-items: center; diff --git a/web/src/assets/styles/views/travel-request-detail-date-popper.css b/web/src/assets/styles/views/travel-request-detail-date-popper.css new file mode 100644 index 0000000..6e941d8 --- /dev/null +++ b/web/src/assets/styles/views/travel-request-detail-date-popper.css @@ -0,0 +1,218 @@ +/* 强力锁定表格中输入框的高度,解决 scoped 模式下有前缀的 Element Plus 子组件无法被 :deep 成功匹配的局限性 */ +.detail-expense-table .editor-control .el-input__wrapper, +.detail-expense-table .editor-control .el-select__wrapper, +.detail-expense-table .editor-select .el-select__wrapper, +.detail-expense-table .editor-date-picker .el-input__wrapper { + box-sizing: border-box !important; + min-height: var(--expense-editor-control-height, 34px) !important; + height: var(--expense-editor-control-height, 34px) !important; + line-height: var(--expense-editor-control-line-height, 16px) !important; +} + +.detail-expense-table .editor-control:not(.risk-note-editor-input), +.detail-expense-table .editor-date-picker.editor-control, +.detail-expense-table .editor-select { + min-height: var(--expense-editor-control-height, 34px) !important; + height: var(--expense-editor-control-height, 34px) !important; +} + +.detail-expense-table .editor-date-picker.editor-control { + display: flex !important; + align-items: center !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__wrapper { + gap: 4px !important; + padding-right: 7px !important; + padding-left: 7px !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__inner, +.detail-expense-table .editor-input-control.editor-control .el-input__inner, +.detail-expense-table .editor-select .el-select__selected-item, +.detail-expense-table .editor-select .el-select__placeholder { + height: var(--expense-editor-control-line-height, 16px) !important; + line-height: var(--expense-editor-control-line-height, 16px) !important; + font-size: 12px !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__prefix, +.detail-expense-table .editor-date-picker.editor-control .el-input__suffix, +.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner, +.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner { + display: inline-flex !important; + align-items: center !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__prefix, +.detail-expense-table .editor-date-picker.editor-control .el-input__suffix { + min-height: var(--expense-editor-control-height, 34px) !important; + height: var(--expense-editor-control-height, 34px) !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__prefix { + flex: 0 0 14px !important; + width: 14px !important; + min-width: 14px !important; + margin: 0 !important; + color: #94a3b8 !important; + font-size: 13px !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__suffix { + display: none !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner, +.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner { + height: var(--expense-editor-control-line-height, 16px) !important; + line-height: var(--expense-editor-control-line-height, 16px) !important; +} + +.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner { + width: 14px !important; + font-size: 13px !important; +} + +.detail-expense-table .editor-amount-input.editor-control { + display: flex !important; + align-items: center !important; +} + +.detail-expense-table .editor-amount-input.editor-control .el-input__wrapper { + display: flex !important; + align-items: center !important; + min-height: var(--expense-editor-control-height, 34px) !important; + height: var(--expense-editor-control-height, 34px) !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.detail-expense-table .editor-amount-input.editor-control .el-input__inner { + height: var(--expense-editor-control-line-height, 16px) !important; + line-height: var(--expense-editor-control-line-height, 16px) !important; +} + +.detail-expense-table .editor-amount-input.editor-control .el-input__prefix, +.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner { + display: inline-flex !important; + align-items: center !important; +} + +.detail-expense-table .editor-amount-input.editor-control .el-input__prefix { + min-height: var(--expense-editor-control-height, 34px) !important; + height: var(--expense-editor-control-height, 34px) !important; +} + +.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner { + height: var(--expense-editor-control-line-height, 16px) !important; + line-height: var(--expense-editor-control-line-height, 16px) !important; +} + +.detail-editor-date-popper.el-popper { + border: 1px solid rgba(148, 163, 184, .32) !important; + border-radius: 4px !important; + background: #ffffff !important; + box-shadow: 0 18px 42px rgba(15, 23, 42, .14) !important; +} + +.detail-editor-date-popper .el-picker-panel { + border: 0 !important; + border-radius: 4px !important; + background: #ffffff !important; + color: #334155 !important; +} + +.detail-editor-date-popper .el-date-picker__header { + height: 38px !important; + margin: 0 !important; + padding: 0 10px !important; + border-bottom: 1px solid #e2e8f0 !important; + display: flex !important; + align-items: center !important; +} + +.detail-editor-date-popper .el-picker-panel__icon-btn { + appearance: none !important; + width: 24px !important; + height: 24px !important; + margin: 0 1px !important; + padding: 0 !important; + border: 0 !important; + border-radius: 4px !important; + background: transparent !important; + color: #64748b !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + transition: background-color 160ms var(--ease), color 160ms var(--ease) !important; +} + +.detail-editor-date-popper .el-picker-panel__icon-btn:hover { + background: var(--theme-primary-soft) !important; + color: var(--theme-primary-active) !important; +} + +.detail-editor-date-popper .el-date-picker__header-label { + color: #0f172a !important; + font-size: 13px !important; + font-weight: 800 !important; +} + +.detail-editor-date-popper .el-picker-panel__content { + margin: 8px 10px 10px !important; +} + +.detail-editor-date-popper .el-date-table th { + border-bottom: 1px solid #edf2f7 !important; + color: #64748b !important; + font-size: 11px !important; + font-weight: 800 !important; +} + +.detail-editor-date-popper .el-date-table td { + width: 32px !important; + height: 30px !important; + padding: 2px !important; +} + +.detail-editor-date-popper .el-date-table td .el-date-table-cell { + height: 28px !important; + padding: 0 !important; +} + +.detail-editor-date-popper .el-date-table td .el-date-table-cell__text { + width: 26px !important; + height: 26px !important; + border-radius: 4px !important; + color: #334155 !important; + font-size: 12px !important; + line-height: 26px !important; +} + +.detail-editor-date-popper .el-date-table td.available:hover .el-date-table-cell__text { + background: var(--theme-primary-soft) !important; + color: var(--theme-primary-active) !important; +} + +.detail-editor-date-popper .el-date-table td.today .el-date-table-cell__text { + color: var(--theme-primary-active) !important; + font-weight: 850 !important; +} + +.detail-editor-date-popper .el-date-table td.current .el-date-table-cell__text, +.detail-editor-date-popper .el-date-table td.selected .el-date-table-cell__text { + background: var(--theme-primary) !important; + color: #ffffff !important; + font-weight: 850 !important; +} + +.detail-editor-date-popper .el-date-table td.prev-month .el-date-table-cell__text, +.detail-editor-date-popper .el-date-table td.next-month .el-date-table-cell__text { + color: #cbd5e1 !important; +} + +.detail-editor-date-popper .el-date-table td.disabled .el-date-table-cell__text { + background: #f8fafc !important; + color: #cbd5e1 !important; +} diff --git a/web/src/components/business/ExpenseProfileDetailModal.vue b/web/src/components/business/ExpenseProfileDetailModal.vue index 8bc742c..e01db41 100644 --- a/web/src/components/business/ExpenseProfileDetailModal.vue +++ b/web/src/components/business/ExpenseProfileDetailModal.vue @@ -269,624 +269,5 @@ watch( ) - -:global(.expense-profile-dialog.el-dialog) { - max-height: calc(100vh - 56px); - max-height: calc(100dvh - 56px); - overflow: hidden; - border: 1px solid rgba(148, 163, 184, 0.34); - border-radius: 4px; - background: #ffffff; - box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2); -} - -:global(.expense-profile-dialog .el-dialog__header), -:global(.expense-profile-dialog .expense-profile-dialog-body), -:global(.expense-profile-dialog .el-dialog__footer) { - padding: 0; - margin: 0; -} - -:global(.expense-profile-dialog-zoom-enter-active), -:global(.expense-profile-dialog-zoom-leave-active) { - transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1); -} - -:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog), -:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) { - transform-origin: center center; - will-change: transform, opacity; -} - -:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) { - animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both; -} - -:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) { - animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -:global(.expense-profile-dialog-zoom-enter-from), -:global(.expense-profile-dialog-zoom-leave-to) { - opacity: 0; -} - -.profile-dialog-header, -.profile-dialog-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 16px 18px; - background: #ffffff; -} - -.profile-dialog-header { - border-bottom: 1px solid #e2e8f0; -} - -.profile-dialog-footer { - justify-content: flex-start; - border-top: 1px solid #e2e8f0; -} - -.profile-dialog-title-block { - min-width: 0; -} - -.profile-dialog-eyebrow, -.profile-section-title small { - color: #64748b; - font-size: 10px; - font-weight: 850; - letter-spacing: 0; - text-transform: uppercase; -} - -.profile-dialog-header h2 { - margin: 3px 0 4px; - color: #0f172a; - font-size: 19px; - line-height: 1.25; - font-weight: 850; -} - -.profile-dialog-header p, -.profile-dialog-footer span { - margin: 0; - color: #64748b; - font-size: 12px; - line-height: 1.5; - font-weight: 650; -} - -.profile-dialog-close { - width: 32px; - height: 32px; - min-height: 32px; - padding: 0; - border-radius: 4px; - color: #334155; - font-size: 18px; -} - -.profile-dialog-close:hover { - background: #eef4fb; - color: var(--theme-primary-active); -} - -.profile-dialog-content { - max-height: min(580px, calc(100vh - 176px)); - max-height: min(580px, calc(100dvh - 176px)); - min-height: 0; - display: grid; - gap: 12px; - padding: 14px; - overflow: auto; - background: #f8fafc; -} - -.profile-dialog-alert { - display: flex; - align-items: center; - gap: 8px; - padding: 9px 11px; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 4px; - background: #ffffff; - color: #475569; - font-size: 12px; - font-weight: 750; -} - -.profile-dialog-alert.is-error { - border-color: rgba(220, 38, 38, 0.24); - background: #fff7f7; - color: #b91c1c; -} - -.profile-dialog-alert.is-empty { - border-color: rgba(245, 158, 11, 0.28); - background: #fffaf0; - color: #92400e; -} - -.profile-summary-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.profile-summary-item, -.profile-panel { - border: 1px solid #e2e8f0; - border-radius: 4px; - background: #ffffff; -} - -.profile-summary-item { - min-width: 0; - display: grid; - gap: 4px; - padding: 10px 12px; -} - -.profile-summary-item span, -.profile-operation-copy span, -.profile-operation-row time { - color: #64748b; - font-size: 11.5px; - font-weight: 650; -} - -.profile-summary-item strong { - color: #0f172a; - font-size: 18px; - line-height: 1.15; - font-weight: 850; - font-variant-numeric: tabular-nums; -} - -.profile-summary-item small { - margin-left: 2px; - color: #64748b; - font-size: 11px; - font-weight: 650; -} - -.profile-summary-item em { - overflow: hidden; - color: #94a3b8; - font-size: 11px; - font-style: normal; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.profile-analysis-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr); - gap: 12px; -} - -.profile-panel { - min-width: 0; - display: grid; - gap: 10px; - padding: 12px; -} - -.profile-tags-panel { - grid-template-rows: auto minmax(0, 1fr); - align-content: stretch; - min-height: 312px; -} - -.profile-radar-panel { - grid-template-rows: auto minmax(0, 1fr) auto; - align-content: stretch; - min-height: 312px; -} - -.profile-section-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.profile-section-title > div { - min-width: 0; - display: grid; - gap: 2px; -} - -.profile-section-title span { - color: #0f172a; - font-size: 14px; - font-weight: 850; -} - -.profile-radar-title { align-items: flex-start; } - -.profile-radar-view-select { - width: 118px; - flex: 0 0 118px; -} -.profile-radar-view-select :deep(.el-select__wrapper) { - min-height: 28px; - border-radius: 4px; - box-shadow: 0 0 0 1px #cbd5e1 inset; - color: #334155; - font-size: 12px; - font-weight: 750; -} - -.profile-operation-list { - display: grid; - gap: 8px; -} - -.profile-panel-empty { - margin: 0; - padding: 18px 12px; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - align-self: stretch; - justify-self: stretch; - box-sizing: border-box; - min-height: 100%; - border: 1px dashed #cbd5e1; - border-radius: 4px; - background: #f8fafc; - color: #64748b; - font-size: 12px; - line-height: 1.5; - font-weight: 700; - text-align: center; -} - -.profile-tags-panel > .profile-panel-empty { - min-height: 244px; -} - -.profile-radar-empty { - min-height: 268px; -} - -.profile-operation-copy strong { - overflow: hidden; - color: #0f172a; - font-size: 13px; - font-weight: 850; - text-overflow: ellipsis; - white-space: nowrap; -} - -.profile-operation-status { - border-radius: 4px; - font-weight: 800; -} - -.profile-radar-layout { - display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: center; - justify-items: stretch; - min-height: 300px; - animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both; -} - -.profile-radar-chart { - width: 100%; - height: 300px; -} - -.profile-behavior-tags { - display: grid; - gap: 8px; - padding-top: 10px; - min-height: 59px; - border-top: 1px solid #e8eef5; -} - -.profile-behavior-tags.is-empty { visibility: hidden; } - -.profile-behavior-tags-title { - color: #0f172a; - font-size: 12px; - font-weight: 850; -} - -.profile-behavior-tag-list { - display: flex; - flex-wrap: wrap; - gap: 7px; -} - -.profile-behavior-tag { - --behavior-tag-rgb: 58, 124, 165; - --behavior-tag-text: #235d7e; - max-width: 132px; - overflow: hidden; - padding: 4px 9px; - border: 1px solid rgba(var(--behavior-tag-rgb), 0.24); - border-radius: 999px; - background: rgba(var(--behavior-tag-rgb), 0.08); - color: var(--behavior-tag-text); - font-size: 11.5px; - line-height: 1.25; - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; - animation: profileBehaviorTagIn 260ms cubic-bezier(0.2, 0, 0, 1) both; -} - -.profile-behavior-tag--risk { - --behavior-tag-rgb: 245, 158, 11; - --behavior-tag-text: #92400e; -} - -.profile-behavior-tag--positive { - --behavior-tag-rgb: 16, 185, 129; - --behavior-tag-text: #047857; -} - -.profile-behavior-tag--accent-0 { - --behavior-tag-rgb: 58, 124, 165; - --behavior-tag-text: #235d7e; -} - -.profile-behavior-tag--accent-1 { - --behavior-tag-rgb: 15, 159, 143; - --behavior-tag-text: #0f766e; -} - -.profile-behavior-tag--accent-2 { - --behavior-tag-rgb: 245, 158, 11; - --behavior-tag-text: #92400e; -} - -.profile-behavior-tag--accent-3 { - --behavior-tag-rgb: 124, 58, 237; - --behavior-tag-text: #5b21b6; -} - -.profile-behavior-tag--accent-4 { - --behavior-tag-rgb: 220, 38, 38; - --behavior-tag-text: #991b1b; -} - -.profile-behavior-tag--accent-5 { - --behavior-tag-rgb: 37, 99, 235; - --behavior-tag-text: #1d4ed8; -} - -.profile-behavior-tag--accent-6 { - --behavior-tag-rgb: 22, 163, 74; - --behavior-tag-text: #15803d; -} - -.profile-behavior-tag--accent-7 { - --behavior-tag-rgb: 219, 39, 119; - --behavior-tag-text: #be185d; -} - -.profile-operation-row { - display: grid; - grid-template-columns: 88px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - padding: 9px 0; - border-top: 1px solid #e8eef5; -} - -.profile-operation-row:first-child { - border-top: 0; - padding-top: 0; -} - -.profile-operation-copy { - min-width: 0; - display: grid; - gap: 3px; -} - -.profile-operation-status { - justify-self: end; -} - -@media (min-width: 861px) and (max-width: 1440px), - (min-width: 861px) and (max-height: 820px) { - :global(.expense-profile-dialog.el-dialog) { - width: min(900px, calc(100vw - 96px)) !important; - max-height: calc(100vh - 64px); - max-height: calc(100dvh - 64px); - } - - .profile-dialog-header, - .profile-dialog-footer { - gap: 12px; - padding: 12px 16px; - } - - .profile-dialog-header h2 { - margin: 2px 0 3px; - font-size: 17px; - } - - .profile-dialog-header p, - .profile-dialog-footer span { - font-size: 11.5px; - } - - .profile-dialog-content { - max-height: min(520px, calc(100vh - 152px)); - max-height: min(520px, calc(100dvh - 152px)); - gap: 10px; - padding: 12px; - } - - .profile-summary-grid, - .profile-analysis-grid { - gap: 8px; - } - - .profile-summary-item { - gap: 3px; - padding: 8px 10px; - } - - .profile-summary-item strong { - font-size: 16px; - } - - .profile-panel { - gap: 8px; - padding: 10px; - } - - .profile-analysis-grid { - grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr); - } - - .profile-tags-panel, - .profile-radar-panel { - min-height: 272px; - } - - .profile-tags-panel > .profile-panel-empty { - min-height: 210px; - } - - .profile-radar-empty { - min-height: 220px; - } - - .profile-radar-layout { - min-height: 248px; - } - - .profile-radar-chart { - height: 248px; - } - - .profile-behavior-tags { - gap: 6px; - min-height: 50px; - padding-top: 8px; - } - - .profile-operation-list { - gap: 6px; - } - - .profile-operation-row { - gap: 8px; - padding: 7px 0; - } -} - -@keyframes expenseProfileDialogIn { - 0% { - opacity: 0; - transform: scale3d(0.94, 0.94, 1); - } - - 100% { - opacity: 1; - transform: scale3d(1, 1, 1); - } -} - -@keyframes profileRadarEnter { - 0% { - opacity: 0; - transform: translateY(8px) scale(0.985); - } - - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes profileBehaviorTagIn { - 0% { - opacity: 0; - transform: translateY(4px); - } - - 100% { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes expenseProfileDialogOut { - 0% { - opacity: 1; - transform: scale3d(1, 1, 1); - } - - 100% { - opacity: 0; - transform: scale3d(0.96, 0.96, 1); - } -} - -@media (max-width: 860px) { - :global(.expense-profile-dialog.el-dialog) { - width: calc(100vw - 24px) !important; - } - - .profile-summary-grid, - .profile-analysis-grid, - .profile-radar-layout { - grid-template-columns: 1fr; - } - - .profile-dialog-content { - max-height: calc(100vh - 170px); - } -} - -@media (max-width: 560px) { - .profile-dialog-header, - .profile-dialog-footer { - align-items: flex-start; - } - - .profile-dialog-footer { - flex-direction: column; - } - - .profile-operation-row { - grid-template-columns: 1fr; - align-items: start; - } - - .profile-operation-status { - justify-self: start; - } -} - -@media (prefers-reduced-motion: reduce) { - :global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog), - :global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog), - .profile-radar-layout, - .profile-behavior-tag { - animation-duration: 1ms !important; - } -} - diff --git a/web/src/components/business/PersonalWorkbenchAiMode.template.html b/web/src/components/business/PersonalWorkbenchAiMode.template.html new file mode 100644 index 0000000..2cc99ea --- /dev/null +++ b/web/src/components/business/PersonalWorkbenchAiMode.template.html @@ -0,0 +1,756 @@ +
+ + + +
+ + +
+

嗨,{{ displayUserName }},我是您的小财管家

+

您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务

+
+ +
+
+
+ + {{ workbenchDateTagLabel }} + +
+ + +
+ +
+
+
+ + + +
+ + +
+ +
+
+ {{ displayModelName }} + +
+ +
+
+
+ +
+
+ + + {{ file.name }} + {{ file.typeLabel }} + + +
+
+ +
+

快速开始

+
+ +
+
+
+ +
+
+ + +
+ +
+
+ {{ activeConversationTitle || '新对话' }} +

直接输入问题,小财管家会在当前页面内持续回复。

+
+ +
+
+ {{ message.content }} +
+
+ + + +
+ + +
+
+ +
+
+
+ + + {{ file.name }} + {{ file.typeLabel }} + + +
+
+ +
+
+
+ + {{ workbenchDateTagLabel }} + +
+ + +
+ +
+
+
+ + + +
+ + +
+ +
+
+ {{ displayModelName }} + +
+ +
+
+
+ +

小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。

+
+
+
+ + + + + + + + +
diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index 4fae149..b17f194 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -1,2960 +1,17 @@ - + diff --git a/web/src/components/business/workbench-ai/WorkbenchAiComposer.vue b/web/src/components/business/workbench-ai/WorkbenchAiComposer.vue new file mode 100644 index 0000000..9f6c36c --- /dev/null +++ b/web/src/components/business/workbench-ai/WorkbenchAiComposer.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue b/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue new file mode 100644 index 0000000..d958d08 --- /dev/null +++ b/web/src/components/business/workbench-ai/WorkbenchAiFileStrip.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/web/src/components/business/workbench-ai/WorkbenchAiHome.vue b/web/src/components/business/workbench-ai/WorkbenchAiHome.vue new file mode 100644 index 0000000..5e1a958 --- /dev/null +++ b/web/src/components/business/workbench-ai/WorkbenchAiHome.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index 70e9e45..5ad4387 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -1,284 +1,284 @@ -