diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 42fc96a..f3a0205 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -1257,8 +1257,9 @@ class OrchestratorService: if employee is not None: add_condition("employee_id", employee.id) - add_condition("employee_name", employee.name) add_condition("employee_name", employee.email) + if self._employee_name_is_unique(employee): + add_condition("employee_name", employee.name) else: add_condition("employee_id", normalized_user_id) add_condition("employee_name", normalized_user_id) @@ -1268,6 +1269,19 @@ class OrchestratorService: return conditions, "你的报销单" return conditions, "当前用户的报销单" + def _employee_name_is_unique(self, employee: Employee) -> bool: + normalized_name = str(employee.name or "").strip() + if not normalized_name: + return False + + same_name_count = int( + self.db.scalar( + select(func.count()).select_from(Employee).where(Employee.name == normalized_name) + ) + or 0 + ) + return same_name_count == 1 + @staticmethod def _has_privileged_expense_query_access(context_json: dict[str, Any]) -> bool: role_codes = { diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 5b0e8d7..22da968 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -120,6 +120,100 @@ +
+ {{ message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细' }} + +

+ {{ buildExpenseQueryWindowLabel(message.queryPayload) }} +

+ +
+ + {{ item.label }} {{ item.count }} + +
+ +
+ + +
+ + +
+ +
+ + +
+
+ +
+ + 当前没有可直接展开的近期待办单据。 +
+ +

+ {{ buildExpenseQueryHint(message.queryPayload) }} +

+
+
diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index b84df8f..0142d21 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -1,4 +1,5 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useRouter } from 'vue-router' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' @@ -151,6 +152,7 @@ const MAX_ATTACHMENTS = 10 const MAX_OCR_DOCUMENTS = 10 const VISIBLE_ATTACHMENT_CHIPS = 2 const COMPOSER_MAX_ROWS = 5 +const EXPENSE_QUERY_PAGE_SIZE = 5 const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_KNOWLEDGE = 'knowledge' const HOT_KNOWLEDGE_QUESTIONS = [ @@ -199,6 +201,7 @@ function createMessage(role, text, attachments = [], extras = {}) { meta: [], citations: [], suggestedActions: [], + queryPayload: null, draftPayload: null, reviewPayload: null, riskFlags: [], @@ -625,8 +628,50 @@ function resolveKnowledgeRankTone(index) { return 'default' } +function parseConversationMessageSequence(message) { + const messageJson = message?.message_json || message?.messageJson || {} + const sequence = Number.parseInt(messageJson?.sequence, 10) + return Number.isFinite(sequence) && sequence > 0 ? sequence : null +} + +function parseConversationMessageTime(message) { + const rawValue = message?.created_at || message?.createdAt || '' + const timestamp = new Date(rawValue).getTime() + return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER +} + +function resolveConversationMessageRolePriority(message) { + return String(message?.role || '').trim() === 'user' ? 0 : 1 +} + +function sortConversationMessages(messages) { + return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { + const leftSequence = parseConversationMessageSequence(left) + const rightSequence = parseConversationMessageSequence(right) + if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { + return leftSequence - rightSequence + } + + const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) + if (timeDiff !== 0) { + return timeDiff + } + + const leftRunId = String(left?.run_id || left?.runId || '').trim() + const rightRunId = String(right?.run_id || right?.runId || '').trim() + if (leftRunId && rightRunId && leftRunId === rightRunId) { + const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) + if (roleDiff !== 0) { + return roleDiff + } + } + + return String(left?.id || '').localeCompare(String(right?.id || '')) + }) +} + function normalizeInitialConversationMessages(conversation) { - const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] + const rawMessages = sortConversationMessages(conversation?.messages) return rawMessages.map((item) => { const messageJson = item?.message_json || item?.messageJson || {} @@ -645,6 +690,7 @@ function normalizeInitialConversationMessages(conversation) { item.role === 'assistant' && Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], + queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] @@ -909,6 +955,159 @@ function formatAmountDisplay(value) { }).format(amount) } +function normalizeExpenseQueryStatusGroup(item) { + if (!item || typeof item !== 'object') { + return null + } + + const rawCount = Number(item.count || 0) + return { + key: String(item.key || 'other').trim() || 'other', + label: String(item.label || '其他状态').trim() || '其他状态', + count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0 + } +} + +function normalizeExpenseQueryRecord(item) { + if (!item || typeof item !== 'object') { + return null + } + + const amount = Number(item.amount || 0) + const amountValue = Number.isFinite(amount) ? amount : 0 + const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销' + const reason = String(item.reason || '').trim() + const documentDate = String(item.document_date || '').trim() + const occurredAt = String(item.occurred_at || '').trim() + + return { + claimId: String(item.claim_id || '').trim(), + claimNo: String(item.claim_no || '').trim() || '未编号', + employeeName: String(item.employee_name || '').trim(), + expenseType: String(item.expense_type || '').trim(), + expenseTypeLabel, + amount: amountValue, + amountDisplay: formatAmountDisplay(amountValue), + status: String(item.status || '').trim(), + statusLabel: String(item.status_label || '处理中').trim() || '处理中', + statusGroup: String(item.status_group || 'other').trim() || 'other', + statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态', + approvalStage: String(item.approval_stage || '').trim(), + documentDate, + occurredAt, + reason, + location: String(item.location || '').trim(), + summary: reason || `${expenseTypeLabel}报销`, + dateDisplay: documentDate || occurredAt || '待补充日期' + } +} + +function normalizeExpenseQueryPayload(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + + const resultType = String(payload.result_type || '').trim() + if (resultType && resultType !== 'expense_claim_list') { + return null + } + + const records = (Array.isArray(payload.records) ? payload.records : []) + .map(normalizeExpenseQueryRecord) + .filter(Boolean) + const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : []) + .map(normalizeExpenseQueryStatusGroup) + .filter(Boolean) + + const rawRecordCount = Number(payload.record_count || 0) + const rawPreviewCount = Number(payload.preview_count || records.length) + const rawOlderRecordCount = Number(payload.older_record_count || 0) + const totalAmount = Number(payload.total_amount || 0) + const rawWindowDays = Number(payload.window_days || 0) + const windowStartDate = String(payload.window_start_date || '').trim() + const windowEndDate = String(payload.window_end_date || '').trim() + + return { + resultType: 'expense_claim_list', + scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', + recentWindowApplied: Boolean(payload.recent_window_applied), + windowDays: + payload.window_days === null || payload.window_days === undefined || payload.window_days === '' + ? null + : (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null), + windowStartDate: windowStartDate || '', + windowEndDate: windowEndDate || '', + recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0, + previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length, + olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0, + hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more), + totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, + statusGroups, + records, + currentPage: 1 + } +} + +function buildExpenseQueryWindowLabel(queryPayload) { + if (!queryPayload) { + return '' + } + + if (queryPayload.windowStartDate && queryPayload.windowEndDate) { + return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}` + } + + if (queryPayload.recentWindowApplied && queryPayload.windowDays) { + return `近 ${queryPayload.windowDays} 日内` + } + + return '当前条件下' +} + +function getExpenseQueryTotalPages(queryPayload) { + const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0 + return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE)) +} + +function getExpenseQueryActivePage(queryPayload) { + const totalPages = getExpenseQueryTotalPages(queryPayload) + const rawPage = Number(queryPayload?.currentPage || 1) + if (!Number.isFinite(rawPage)) { + return 1 + } + return Math.min(Math.max(1, Math.round(rawPage)), totalPages) +} + +function getExpenseQueryVisibleRecords(queryPayload) { + const records = Array.isArray(queryPayload?.records) ? queryPayload.records : [] + const activePage = getExpenseQueryActivePage(queryPayload) + const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE + return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE) +} + +function buildExpenseQueryHint(queryPayload) { + if (!queryPayload) { + return '' + } + + const parts = [] + const windowText = buildExpenseQueryWindowLabel(queryPayload) + + if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) { + parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`) + } + + if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) { + parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`) + } + + if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) { + parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`) + } + + return parts.join('。') +} + function countReviewPendingItems(reviewPayload) { return resolveReviewMissingSlotCards(reviewPayload).length } @@ -1661,6 +1860,7 @@ function buildErrorInsight(error, fileNames = []) { fileNames, citations: [], suggestedActions: [], + queryPayload: null, draftPayload: null, reviewPayload: null, riskFlags: [], @@ -1699,6 +1899,7 @@ function buildAgentInsight(payload, fileNames = [], filePreviews = []) { fileNames, citations: Array.isArray(result?.citations) ? result.citations : [], suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], + queryPayload: normalizeExpenseQueryPayload(result?.query_payload), draftPayload: result?.draft_payload || null, reviewPayload: result?.review_payload || null, riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], @@ -1743,6 +1944,7 @@ export default { }, emits: ['close', 'draft-saved'], setup(props, { emit }) { + const router = useRouter() const { currentUser } = useSystemState() const { toast } = useToast() @@ -1816,7 +2018,7 @@ export default { if (props.entrySource === 'detail' && linkedRequest.value?.id) { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } - return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。' + return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件生成报销草稿。' }) const currentIntentLabel = computed(() => { if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { @@ -2395,6 +2597,37 @@ export default { emit('close') } + function openExpenseQueryRecord(record) { + const claimId = String(record?.claimId || '').trim() + if (!claimId) { + return + } + + router.push({ + name: 'app-request-detail', + params: { requestId: claimId } + }) + emit('close') + } + + function setExpenseQueryPage(message, page) { + if (!message?.queryPayload) { + return + } + + const totalPages = getExpenseQueryTotalPages(message.queryPayload) + const nextPage = Math.min(Math.max(1, Number(page || 1)), totalPages) + message.queryPayload.currentPage = nextPage + } + + function shiftExpenseQueryPage(message, delta) { + if (!message?.queryPayload) { + return + } + + setExpenseQueryPage(message, getExpenseQueryActivePage(message.queryPayload) + Number(delta || 0)) + } + function openDeleteSessionDialog() { if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) { return @@ -2637,6 +2870,7 @@ export default { suggestedActions: Array.isArray(payload?.result?.suggested_actions) ? payload.result.suggested_actions : [], + queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload), draftPayload: payload?.result?.draft_payload || null, reviewPayload: payload?.result?.review_payload || null, riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] @@ -2886,6 +3120,11 @@ export default { buildReviewRiskHint, buildReviewActionHint, buildReviewStatusTag, + buildExpenseQueryWindowLabel, + buildExpenseQueryHint, + getExpenseQueryActivePage, + getExpenseQueryTotalPages, + getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, handleFilesChange, @@ -2899,6 +3138,9 @@ export default { removeAttachedFile, clearAttachedFiles, requestCloseWorkbench, + openExpenseQueryRecord, + setExpenseQueryPage, + shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession,