feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -12,6 +12,16 @@ export function resolveDocumentNewKey(row) {
|
||||
return id ? `${source}:${id}` : ''
|
||||
}
|
||||
|
||||
export function resolveDocumentNotificationKey(row) {
|
||||
const documentKey = String(row?.documentKey || '').trim()
|
||||
return documentKey || resolveDocumentNewKey(row)
|
||||
}
|
||||
|
||||
export function resolveDocumentNotificationId(row) {
|
||||
const key = resolveDocumentNotificationKey(row)
|
||||
return key ? `document:${key}` : ''
|
||||
}
|
||||
|
||||
export function readViewedDocumentKeys(storage = getStorage()) {
|
||||
if (!storage) {
|
||||
return new Set()
|
||||
@@ -66,6 +76,80 @@ export function countNewDocuments(rows, viewedKeys) {
|
||||
return rows.filter((row) => isNewDocument(row, viewedKeys)).length
|
||||
}
|
||||
|
||||
function resolveRemoteStateId(item) {
|
||||
return String(item?.notification_id || item?.notificationId || '').trim()
|
||||
}
|
||||
|
||||
function isRemoteStateViewed(item) {
|
||||
return Boolean(item?.read_at || item?.readAt || item?.hidden_at || item?.hiddenAt)
|
||||
}
|
||||
|
||||
export function mergeNotificationStatesIntoViewedDocumentKeys(states, viewedKeys, storage = getStorage()) {
|
||||
const nextKeys = new Set(viewedKeys)
|
||||
let changed = false
|
||||
|
||||
;(Array.isArray(states) ? states : []).forEach((item) => {
|
||||
if (!isRemoteStateViewed(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = resolveRemoteStateId(item)
|
||||
if (!id.startsWith('document:')) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = id.slice('document:'.length).trim()
|
||||
if (key && !nextKeys.has(key)) {
|
||||
nextKeys.add(key)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
writeViewedDocumentKeys(nextKeys, storage)
|
||||
}
|
||||
|
||||
return nextKeys
|
||||
}
|
||||
|
||||
export function buildDocumentViewedStatePatch(row) {
|
||||
if (!isNewDocument(row, new Set())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const notificationId = resolveDocumentNotificationId(row)
|
||||
if (!notificationId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
notification_id: notificationId,
|
||||
read: true,
|
||||
hidden: false,
|
||||
context_json: {
|
||||
kind: 'document',
|
||||
source: String(row?.source || '').trim(),
|
||||
target_type: 'documents-center'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDocumentsViewedStatePatches(rows, viewedKeys) {
|
||||
const seenIds = new Set()
|
||||
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => isNewDocument(row, viewedKeys))
|
||||
.map(buildDocumentViewedStatePatch)
|
||||
.filter((patch) => {
|
||||
if (!patch?.notification_id || seenIds.has(patch.notification_id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seenIds.add(patch.notification_id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
|
||||
const key = resolveDocumentNewKey(row)
|
||||
if (!key) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
|
||||
const APPLICATION_ARCHIVE_STAGE = '申请归档'
|
||||
|
||||
function isArchivedRequestPayload(request) {
|
||||
if (!request) {
|
||||
@@ -9,6 +10,11 @@ function isArchivedRequestPayload(request) {
|
||||
|
||||
const normalizedStatus = String(request.status || '').trim().toLowerCase()
|
||||
const stage = String(request.approval_stage || request.approvalStage || '').trim()
|
||||
const isApplicationRequest = isApplicationRequestLike(request)
|
||||
|
||||
if (isApplicationRequest) {
|
||||
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus) && stage === APPLICATION_ARCHIVE_STAGE
|
||||
}
|
||||
|
||||
if (stage === '归档入账' || stage === '已付款' || stage === 'completed') {
|
||||
return true
|
||||
@@ -18,14 +24,6 @@ function isArchivedRequestPayload(request) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& ['审批完成', '申请归档'].includes(stage)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
|
||||
&& (stage === '' || stage === '归档入账' || stage === '已付款' || stage === 'completed')
|
||||
}
|
||||
|
||||
@@ -125,6 +125,36 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, daysText)
|
||||
const endDate = inferredEndDate || explicitEndDate || startDate
|
||||
const start = parseIsoDate(startDate)
|
||||
const end = parseIsoDate(endDate)
|
||||
if (!start || !end) {
|
||||
return null
|
||||
}
|
||||
const orderedStart = start.getTime() <= end.getTime() ? start : end
|
||||
const orderedEnd = start.getTime() <= end.getTime() ? end : start
|
||||
return {
|
||||
startDate: formatIsoDate(orderedStart),
|
||||
endDate: formatIsoDate(orderedEnd),
|
||||
startTime: orderedStart.getTime(),
|
||||
endTime: orderedEnd.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
export function applicationDateRangesOverlap(leftRange, rightRange) {
|
||||
if (!leftRange || !rightRange) {
|
||||
return false
|
||||
}
|
||||
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
@@ -392,7 +422,7 @@ function normalizeApplicationTypeLabel(value, fallback = '') {
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
function normalizeTransportModeOption(value, fallback = '') {
|
||||
export function normalizeTransportModeOption(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
const APPLICATION_ARCHIVE_STAGE = '申请归档'
|
||||
|
||||
export function isArchivedExpenseClaim(claim) {
|
||||
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
|
||||
const status = String(claim?.status || '').trim().toLowerCase()
|
||||
|
||||
if (isApplicationRequestLike(claim)) {
|
||||
return stage === APPLICATION_ARCHIVE_STAGE
|
||||
&& ['approved', 'completed', 'paid'].includes(status)
|
||||
}
|
||||
|
||||
if (stage === '归档入账' || stage === '已付款' || stage === 'completed' || stage.includes('归档')) {
|
||||
return true
|
||||
}
|
||||
@@ -12,7 +19,7 @@ export function isArchivedExpenseClaim(claim) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isApplicationRequestLike(claim) && ['审批完成', '申请归档'].includes(stage)) {
|
||||
if (stage === APPLICATION_ARCHIVE_STAGE) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,22 @@ const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'补充信息'
|
||||
])
|
||||
|
||||
const BUSINESS_FIELD_LABELS = new Set([
|
||||
'时间',
|
||||
'地点',
|
||||
'事由',
|
||||
'金额',
|
||||
'费用类型',
|
||||
'报销类型',
|
||||
'商户',
|
||||
'商户/开票方',
|
||||
'客户',
|
||||
'客户/项目对象',
|
||||
'附件',
|
||||
'附件/凭证',
|
||||
'出行方式'
|
||||
])
|
||||
|
||||
function splitColonHeadingLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
@@ -142,7 +158,31 @@ function splitColonHeadingLine(line) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
return body ? [`### ${title}`, '', body] : [`### ${title}`]
|
||||
return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`]
|
||||
}
|
||||
|
||||
function normalizeBusinessFieldLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('|') ||
|
||||
/^[-*+]\s/.test(trimmed) ||
|
||||
/^#{1,6}\s/.test(trimmed)
|
||||
) {
|
||||
return rawLine
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
||||
if (!match) {
|
||||
return rawLine
|
||||
}
|
||||
const label = match[1].trim()
|
||||
const value = match[2].trim()
|
||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
||||
return rawLine
|
||||
}
|
||||
return `- **${label}**:${value}`
|
||||
}
|
||||
|
||||
function normalizeColonHeadings(text) {
|
||||
@@ -168,7 +208,7 @@ function normalizeColonHeadings(text) {
|
||||
normalizedLines.push('')
|
||||
}
|
||||
}
|
||||
normalizedLines.push(...nextLines)
|
||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
||||
})
|
||||
|
||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
|
||||
import approvalIcon from '../assets/workbench-icons/outline-approval.svg?raw'
|
||||
import budgetIcon from '../assets/workbench-icons/outline-budget.svg?raw'
|
||||
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
|
||||
import expenseApplicationIcon from '../assets/workbench-icons/outline-expense-application.svg?raw'
|
||||
import financeAnalysisIcon from '../assets/workbench-icons/outline-finance-analysis.svg?raw'
|
||||
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
|
||||
import policyIcon from '../assets/workbench-icons/outline-policy.svg?raw'
|
||||
import reimbursementIcon from '../assets/workbench-icons/outline-reimbursement.svg?raw'
|
||||
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
|
||||
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
|
||||
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
|
||||
|
||||
import capExpenseImg from '../assets/images/cap-expense.png'
|
||||
import capReimbImg from '../assets/images/cap-reimb.png'
|
||||
import capBudgetImg from '../assets/images/cap-budget.png'
|
||||
import capApprovalImg from '../assets/images/cap-approval.png'
|
||||
import capAnalysisImg from '../assets/images/cap-analysis.png'
|
||||
import capPolicyImg from '../assets/images/cap-policy.png'
|
||||
|
||||
function prepareHeroiconMarkup(svgRaw) {
|
||||
return String(svgRaw || '')
|
||||
.replace(/<svg\b([^>]*)>/i, '<svg class="workbench-heroicon"$1>')
|
||||
@@ -18,13 +19,17 @@ function prepareHeroiconMarkup(svgRaw) {
|
||||
.replace(/\saria-hidden="[^"]*"/g, '')
|
||||
}
|
||||
|
||||
function prepareImageMarkup(src) {
|
||||
return `<img src="${src}" class="workbench-image-icon" alt="" />`
|
||||
}
|
||||
|
||||
export const workbenchIconMap = {
|
||||
'expense-application': { markup: prepareHeroiconMarkup(expenseApplicationIcon), style: 'outline' },
|
||||
'quick-reimbursement': { markup: prepareHeroiconMarkup(reimbursementIcon), style: 'outline' },
|
||||
'budget-planning': { markup: prepareHeroiconMarkup(budgetIcon), style: 'outline' },
|
||||
'quick-approval': { markup: prepareHeroiconMarkup(approvalIcon), style: 'outline' },
|
||||
'finance-analysis': { markup: prepareHeroiconMarkup(financeAnalysisIcon), style: 'outline' },
|
||||
'company-policy': { markup: prepareHeroiconMarkup(policyIcon), style: 'outline' },
|
||||
'expense-application': { markup: prepareImageMarkup(capExpenseImg), style: 'image' },
|
||||
'quick-reimbursement': { markup: prepareImageMarkup(capReimbImg), style: 'image' },
|
||||
'budget-planning': { markup: prepareImageMarkup(capBudgetImg), style: 'image' },
|
||||
'quick-approval': { markup: prepareImageMarkup(capApprovalImg), style: 'image' },
|
||||
'finance-analysis': { markup: prepareImageMarkup(capAnalysisImg), style: 'image' },
|
||||
'company-policy': { markup: prepareImageMarkup(capPolicyImg), style: 'image' },
|
||||
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
|
||||
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
|
||||
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationRequestLike } from './documentClassification.js'
|
||||
|
||||
function parseNumber(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
@@ -139,6 +141,23 @@ function resolveStatusTone(approvalKey) {
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(request, requestId, title) {
|
||||
const explicitLabel = normalizeText(request?.documentTypeLabel || request?.document_type_label)
|
||||
const normalizedRequestId = normalizeText(requestId).toUpperCase()
|
||||
|
||||
if (
|
||||
isApplicationRequestLike(request)
|
||||
|| explicitLabel.includes('申请')
|
||||
|| title.includes('申请')
|
||||
|| normalizedRequestId.startsWith('SQ')
|
||||
|| normalizedRequestId.startsWith('CL')
|
||||
) {
|
||||
return '申请单'
|
||||
}
|
||||
|
||||
return explicitLabel || '报销单'
|
||||
}
|
||||
|
||||
function resolveTodoAction(request) {
|
||||
const approvalKey = normalizeText(request?.approvalKey)
|
||||
const status = normalizeText(request?.status || request?.approvalStatus)
|
||||
@@ -245,8 +264,7 @@ function buildProgressItems(ownedRequests) {
|
||||
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
|
||||
|
||||
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
|
||||
const isApplication = title.includes('申请') || (requestId || '').toUpperCase().startsWith('SQ') || (requestId || '').toUpperCase().startsWith('CL')
|
||||
const documentTypeLabel = isApplication ? '申请单' : '报销单'
|
||||
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
|
||||
|
||||
return {
|
||||
id: requestId,
|
||||
|
||||
Reference in New Issue
Block a user