feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -0,0 +1,204 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
labelAgentAssetReleaseObservation,
normalizeAgentAssetReleaseReviewLabel,
normalizeAgentAssetReleaseReviewQueue
} from '../src/services/agentAssets.js'
import {
buildReleaseMonitorMetricCards,
buildReleaseReviewDocumentRoute
} from '../src/views/scripts/useAuditReleaseMonitor.js'
function source(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const panel = source('../src/components/audit/AuditReleaseMonitorPanel.vue')
const detail = source('../src/components/audit/AuditJsonRiskRuleDetail.vue')
const view = source('../src/views/AuditView.vue')
const service = source('../src/services/agentAssets.js')
const composable = source('../src/views/scripts/useAuditReleaseMonitor.js')
test('盲审面板正负样本混排且不读取或暗示模型预测', () => {
assert.match(panel, /正负样本随机混排/u)
assert.match(panel, /候选与基线结论对复核人隐藏/u)
assert.match(panel, /模型结论已隐藏/u)
assert.match(panel, /存在真实风险/u)
assert.match(panel, /确认无该风险/u)
assert.doesNotMatch(panel, /candidate_hit|baseline_hit/u)
assert.doesNotMatch(service, /candidate_hit|baseline_hit/u)
assert.doesNotMatch(panel, /'confirmed'|'false_positive'/u)
assert.match(panel, /'risk_present'/u)
assert.match(panel, /'risk_absent'/u)
assert.doesNotMatch(panel, /确认命中|标记误报|待复核正例/u)
assert.match(panel, /class="review-decision"[\s\S]*class="review-decision"/u)
assert.match(panel, /:key="item\.sample_id \|\| item\.observation_id"/u)
assert.match(panel, /target="_blank"/u)
assert.match(panel, /rel="noopener noreferrer"/u)
})
test('复核队列只保留盲审字段并可从 source_document_id 打开单据', () => {
const queue = normalizeAgentAssetReleaseReviewQueue({
asset_id: 'asset-1',
release_id: 'release-1',
stage: 'shadow',
version: 'v2',
pending_total: 1,
telemetry_status: 'collecting',
items: [{
sample_id: 'sample-1',
observation_id: 'observation-1',
source_document_id: 'claim-1',
rule_code: 'RISK-001',
business_stage: 'reimbursement',
prediction_blinded: true,
reviewer_count: 1,
required_reviewers: 2,
conflicted: false,
created_at: '2026-07-17T00:00:00Z',
candidate_hit: true,
baseline_hit: false
}]
})
assert.deepEqual(Object.keys(queue.items[0]).sort(), [
'business_stage', 'conflicted', 'created_at', 'observation_id', 'prediction_blinded',
'required_reviewers', 'reviewer_count', 'rule_code', 'sample_id', 'source_document_id'
])
assert.equal(queue.items[0].prediction_blinded, true)
assert.deepEqual(buildReleaseReviewDocumentRoute(queue.items[0]), {
name: 'app-document-detail',
params: { requestId: 'claim-1' }
})
assert.equal(buildReleaseReviewDocumentRoute({ source_document_id: '' }), null)
})
test('负样本证据未就绪时召回和实际漏检不可用null 不伪装为 0', () => {
const queue = normalizeAgentAssetReleaseReviewQueue({
pending_total: 4,
metrics: {
observed_count: 20,
runtime_failure_count: 1,
runtime_failure_rate: 0.05,
negative_sample_count: 8,
negative_labeled_count: 3,
negative_pending_label_count: 5,
false_negative_count: null,
estimated_false_negative_count: null,
false_negative_upper_bound: null,
random_negative_population_count: 12,
random_negative_sample_count: 4,
random_negative_labeled_count: 1,
recall: null,
recall_lower_bound: null,
recall_confidence_level: 0.95,
recall_method: 'stratified_random_audit_wilson_upper_bound',
negative_ground_truth_status: 'insufficient_random_negative_reviews'
}
})
const cards = cardsByLabel(buildReleaseMonitorMetricCards(queue))
assert.equal(queue.metrics.false_negative_count, null)
assert.equal(cards['实际漏检'].value, '不可用')
assert.match(cards['实际漏检'].hint, /不按 0 展示/u)
assert.equal(cards['估计漏检'].value, '不可用')
assert.equal(cards['召回率'].value, '不可用')
assert.equal(cards['召回率'].hint, '随机负样本复核不足')
assert.equal(cards['负样本积压'].value, '5')
assert.equal(cards['负样本标注进度'].value, '37.5%')
assert.equal(cards['随机负样本抽检'].value, '25.0%')
})
test('真实负样本证据显示召回点估计、保守下界和漏检上界', () => {
const cards = cardsByLabel(buildReleaseMonitorMetricCards({
pending_total: 2,
metrics: {
observed_count: 100,
runtime_failure_count: 0,
runtime_failure_rate: 0,
negative_sample_count: 20,
negative_labeled_count: 18,
negative_pending_label_count: 2,
false_negative_count: 2,
estimated_false_negative_count: 2.5,
false_negative_upper_bound: 4.75,
random_negative_population_count: 40,
random_negative_sample_count: 10,
random_negative_labeled_count: 10,
recall: 0.8,
recall_lower_bound: 0.64,
recall_confidence_level: 0.95,
recall_method: 'stratified_random_audit_wilson_upper_bound',
negative_ground_truth_status: 'available_stratified_random_audit'
}
}))
assert.equal(cards['实际漏检'].value, '2')
assert.equal(cards['估计漏检'].value, '2.5')
assert.equal(cards['估计漏检'].hint, '保守上界 4.75')
assert.equal(cards['负样本标注进度'].value, '90.0%')
assert.equal(cards['负样本积压'].value, '2')
assert.equal(cards['召回率'].value, '80.0%')
assert.match(cards['召回率'].hint, /保守下界 64\.0% · 95% 置信度/u)
assert.match(cards['召回率'].hint, /分层随机盲审 Wilson 保守上界/u)
})
test('发布复核使用独立租户鉴权 API、幂等请求号和中性结果文案', () => {
assert.match(service, /\/release\/review-queue/u)
assert.match(service, /\/release\/review-queue\/\$\{observationId\}\/labels/u)
assert.match(service, /normalizeAgentAssetReleaseReviewQueue\(payload\)/u)
assert.match(composable, /requestId:\s*requestId\(assetId, observationId, label\)/u)
assert.match(composable, /Promise\.all\(\[/u)
assert.match(composable, /fetchAgentAssetReleaseState/u)
assert.match(composable, /fetchAgentAssetReleaseReviewQueue/u)
assert.match(composable, /已提交“存在真实风险”/u)
assert.match(composable, /已提交“确认无该风险”/u)
assert.equal(normalizeAgentAssetReleaseReviewLabel('risk_present'), 'risk_present')
assert.equal(normalizeAgentAssetReleaseReviewLabel('risk_absent'), 'risk_absent')
assert.throws(() => normalizeAgentAssetReleaseReviewLabel('confirmed'), /risk_present/u)
assert.throws(() => normalizeAgentAssetReleaseReviewLabel('false_positive'), /risk_absent/u)
})
test('复核服务只发送 risk_present 或 risk_absent 新语义', async () => {
const originalFetch = globalThis.fetch
const bodies = []
globalThis.fetch = async (_url, options) => {
bodies.push(JSON.parse(options.body))
return new Response(JSON.stringify({ label: options.body.includes('risk_present') ? 'risk_present' : 'risk_absent' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await labelAgentAssetReleaseObservation('asset-1', 'observation-1', 'risk_present', {
actor: 'auditor', requestId: 'blind-review-present'
})
await labelAgentAssetReleaseObservation('asset-1', 'observation-2', 'risk_absent', {
actor: 'auditor', requestId: 'blind-review-absent'
})
assert.throws(
() => labelAgentAssetReleaseObservation('asset-1', 'observation-3', 'confirmed'),
/risk_present/u
)
} finally {
globalThis.fetch = originalFetch
}
assert.deepEqual(bodies, [{ label: 'risk_present' }, { label: 'risk_absent' }])
})
test('规则详情和审计页复用独立发布监控组件', () => {
assert.match(detail, /<AuditReleaseMonitorPanel/u)
assert.match(detail, /@label="\(observationId, label\) => emit\('label-release'/u)
assert.match(view, /:release-queue="releaseQueue"/u)
assert.match(view, /@label-release="submitReleaseLabel"/u)
})
function cardsByLabel(cards) {
return Object.fromEntries(cards.map((card) => [card.label, card]))
}

View File

@@ -116,12 +116,16 @@ test('documents center uses the full request list instead of the global date-fil
})
test('workbench summary merges approval inbox requests without polluting document center rows', () => {
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(
appShellComposable,
/import \{\s*REIMBURSEMENT_LIST_PREVIEW_PARAMS,\s*extractExpenseClaimItems,\s*fetchApprovalExpenseClaims,\s*fetchExpenseClaimDetail\s*\} from '\.\.\/services\/reimbursements\.js'/
)
assert.match(appShellComposable, /const workbenchApprovalRequests = ref\(\[\]\)/)
assert.match(appShellComposable, /async function reloadWorkbenchApprovalRequests\(\)/)
assert.match(appShellComposable, /async function reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(appShellComposable, /payload\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
assert.match(appShellComposable, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(appShellComposable, /extractExpenseClaimItems\(payload\)\.map\(\(item\) => mapExpenseClaimToRequest\(item\)\)/)
assert.doesNotMatch(appShellComposable, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(appShellComposable, /Promise\.all\(\[[\s\S]*reloadRequests\(\{ silent: true \}\),[\s\S]*reloadWorkbenchApprovalRequests\(\)[\s\S]*\]\)/)
assert.match(appShellComposable, /if \(view === 'workbench'\) \{[\s\S]*void reloadWorkbenchRequests\(\)/)
assert.match(appShellComposable, /const workbenchRequests = computed\(\(\) =>[\s\S]*mergeWorkbenchRequests\(requests\.value, workbenchApprovalRequests\.value\)/)
@@ -161,7 +165,10 @@ test('document detail navigation preserves document center list query', () => {
})
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
assert.match(appShellComposable, /import \{ fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(
appShellComposable,
/import \{\s*REIMBURSEMENT_LIST_PREVIEW_PARAMS,\s*extractExpenseClaimItems,\s*fetchApprovalExpenseClaims,\s*fetchExpenseClaimDetail\s*\} from '\.\.\/services\/reimbursements\.js'/
)
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)

View File

@@ -71,10 +71,10 @@ test('claim delete flow invalidates the matching financial assistant session', (
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const createViewScript = [
'../src/views/scripts/TravelReimbursementCreateView.js',
'../src/views/scripts/useTravelReimbursementCreateViewSessionCleanup.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
assert.match(appShellScript, /clearAssistantSessionSnapshotForDraftClaim/)
assert.match(appShellScript, /async function handleRequestDeleted\(payload = \{\}\)/)
@@ -194,8 +194,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
const detailExpenseEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailExpenseEditor.js', import.meta.url)),
'utf8'
)
const smartEntryRecognitionScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSmartEntryRecognition.js', import.meta.url)),
'utf8'
)
const appShellScript = readFileSync(
@@ -206,13 +210,17 @@ test('detail smart entry is scoped to the current claim instead of the latest co
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const submitComposerScript = [
'../src/views/scripts/useTravelReimbursementSubmitComposer.js',
'../src/views/scripts/travelReimbursementSubmitApplicationPreview.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
assert.match(detailViewScript, /restoreLatestConversation:\s*false/)
assert.match(detailViewScript, /scope:\s*claimId[\s\S]*type:\s*'claim'[\s\S]*claimId/)
assert.match(detailExpenseEditorScript, /if \(!request\.value\.claimId\) \{[\s\S]*当前草稿缺少 claimId/)
assert.match(detailExpenseEditorScript, /startSmartEntryRecognitionTask\(\{[\s\S]*claimId:\s*request\.value\.claimId/)
assert.match(detailExpenseEditorScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
assert.match(smartEntryRecognitionScript, /const normalizedClaimId = normalizeSmartEntryClaimId\(claimId\)/)
assert.match(smartEntryRecognitionScript, /smartEntryRecognitionTasks\.set\(normalizedClaimId, task\)/)
assert.doesNotMatch(detailExpenseEditorScript, /restoreLatestConversation/)
assert.match(appShellScript, /function isDetailClaimScopedPayload\(payload = \{\}\)/)
assert.match(appShellScript, /if \(isDetailClaimScopedPayload\(payload\)\) \{[\s\S]*return null[\s\S]*\}/)
assert.match(sessionStateScript, /const shouldPersistLocalSnapshot = props\.entrySource !== 'detail'/)

View File

@@ -0,0 +1,426 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildCfoValueSearch,
buildSavingsOpportunitySearch,
normalizeAnalyticsValuePayload,
recordSavingsRealization
} from '../src/services/analyticsValue.js'
import {
buildBreakdownGroups,
buildDataQualityRows,
buildValueKpis,
classifyCfoDashboardState,
formatMoneyValues,
isDashboardStale,
readValueFiltersFromQuery,
resolveRangeWindow,
writeValueFiltersToQuery
} from '../src/views/scripts/cfoValueDashboardModel.js'
import {
BUDGET_CONFIGURATION_NOTICE,
buildCfoOpportunitySourceLinks,
hasValueOpportunityQuery,
normalizeValueOpportunityId,
opportunityMatchesValueContext,
readBudgetConfigurationFocus,
readValueOpportunityId,
shouldClearValueOpportunityError,
writeValueOpportunityToQuery
} from '../src/views/scripts/cfoValueSourceLinks.js'
const overviewView = readSource('../src/views/OverviewView.vue')
const topBarRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
const appShell = readSource('../src/views/AppShellRouteView.vue')
const appShellComposable = readSource('../src/composables/useAppShell.js')
const budgetCenter = readSource('../src/views/BudgetCenterView.vue')
const budgetCenterScript = readSource('../src/views/scripts/BudgetCenterView.js')
const dashboardComponent = readSource('../src/components/dashboard/CfoValueDashboard.vue')
const dashboardComposable = readSource('../src/composables/useCfoValueDashboard.js')
const dashboardModel = readSource('../src/views/scripts/cfoValueDashboardModel.js')
const valueService = readSource('../src/services/analyticsValue.js')
const actionDialog = readSource('../src/components/dashboard/CfoValueActionDialog.vue')
const opportunityDrawer = readSource('../src/components/dashboard/CfoValueOpportunityDrawer.vue')
const trendChart = readSource('../src/components/charts/CfoValueTrendChart.vue')
const dashboardStyles = readSource('../src/assets/styles/components/cfo-value-dashboard.css')
test('经营价值接口把后端 snake_case 契约规范为前端字段且保留币种分账', () => {
const normalized = normalizeAnalyticsValuePayload({
source: { data_status: 'complete', opportunity_count: 2 },
funnel: { realization_rate_by_currency: { CNY: '0.2500', USD: null } },
data_quality: { pending_confirmation_count: 1 }
})
assert.equal(normalized.source.dataStatus, 'complete')
assert.equal(normalized.source.opportunityCount, 2)
assert.equal(normalized.funnel.realizationRateByCurrency.CNY, '0.2500')
assert.equal(normalized.funnel.realizationRateByCurrency.USD, null)
assert.equal(normalized.dataQuality.pendingConfirmationCount, 1)
})
test('经营价值与机会台账查询只发送真实筛选字段', () => {
const dashboardSearch = buildCfoValueSearch({
start: '2026-07-01T00:00:00.000Z',
end: '2026-07-16T00:00:00.000Z',
departmentId: 'dept-1',
projectCode: '',
valueKind: 'cash'
})
const opportunitySearch = buildSavingsOpportunitySearch({
page: 2,
pageSize: 12,
status: 'in_progress',
ownerId: 'finance-1'
})
assert.equal(dashboardSearch.get('department_id'), 'dept-1')
assert.equal(dashboardSearch.get('value_kind'), 'cash')
assert.equal(dashboardSearch.has('project_code'), false)
assert.equal(opportunitySearch.get('page_size'), '12')
assert.equal(opportunitySearch.get('status'), 'in_progress')
assert.equal(opportunitySearch.get('owner_id'), 'finance-1')
})
test('看板明确区分 loading permission error empty partial stale 与 ready', () => {
const complete = dashboardFixture({ dataStatus: 'complete' })
const partial = dashboardFixture({ dataStatus: 'partial' })
const empty = dashboardFixture({ dataStatus: 'empty', opportunityCount: 0, realizationCount: 0 })
const stale = dashboardFixture({ dataStatus: 'complete', freshnessAt: '2026-07-01T00:00:00Z' })
assert.equal(classifyCfoDashboardState({ loading: true }), 'loading')
assert.equal(classifyCfoDashboardState({ error: { status: 403 } }), 'permission')
assert.equal(classifyCfoDashboardState({ error: new Error('offline') }), 'error')
assert.equal(classifyCfoDashboardState({ dashboard: empty }), 'empty')
assert.equal(classifyCfoDashboardState({ dashboard: partial }), 'partial')
assert.equal(classifyCfoDashboardState({ dashboard: dashboardFixture({ dataStatus: 'complete', freshnessAt: '' }) }), 'partial')
assert.equal(classifyCfoDashboardState({ dashboard: complete, now: new Date('2026-07-16T01:00:00Z') }), 'ready')
assert.equal(classifyCfoDashboardState({ dashboard: stale, now: new Date('2026-07-16T01:00:00Z') }), 'stale')
assert.equal(isDashboardStale(stale, new Date('2026-07-16T01:00:00Z')), true)
})
test('数据质量缺字段显示不可用而不是伪装成零缺口', () => {
const rows = buildDataQualityRows({ dataQuality: { pendingConfirmationCount: 0 } })
assert.deepEqual(rows[0], {
key: 'pendingConfirmationCount',
label: '待财务确认',
count: 0,
displayValue: '0',
tone: 'ok'
})
assert.equal(rows[1].count, null)
assert.equal(rows[1].displayValue, '—')
assert.equal(rows[1].tone, 'unavailable')
})
test('零节省、空台账与缺失工时基线不会混为默认数字', () => {
const dashboard = dashboardFixture({ dataStatus: 'complete', opportunityCount: 2, realizationCount: 1 })
dashboard.kpis = {
verifiedCash: { label: '财务确认净现金节省', status: 'empty', values: [], confirmedRealizationCount: 0 },
releasableLabor: { label: '财务确认可释放工时价值', status: 'collecting', reason: '缺少工时基线', requiredInputs: ['人工分钟'] },
safeStraightThrough: { label: '安全智能直通率', status: 'collecting', reason: '缺少审计样本', requiredInputs: ['审计结果'] }
}
dashboard.funnel = { stages: [{ key: 'estimated', values: [{ currency: 'USD', amount: '50.00' }] }] }
const [cash, labor, straightThrough] = buildValueKpis(dashboard)
assert.equal(cash.state, 'zero')
assert.match(cash.displayValue, /US\$0|\$0/u)
assert.equal(labor.state, 'baseline-missing')
assert.equal(labor.displayValue, '待采集')
assert.equal(straightThrough.displayValue, '待采集')
assert.match(
formatMoneyValues([{ currency: 'CNY', amount: '10' }, { currency: 'USD', amount: '20' }]),
/¥10 \/ (?:US)?\$20/u
)
dashboard.filters = { valueKind: 'labor' }
const [excludedCash] = buildValueKpis(dashboard)
assert.equal(excludedCash.state, 'unavailable')
assert.equal(excludedCash.displayValue, '未纳入筛选')
})
test('价值分解按币种拆行,绝不把不同币种相加后比较', () => {
const groups = buildBreakdownGroups({
breakdowns: [{
dimension: 'department',
items: [{
dimensionId: 'dept-1',
dimensionName: '财务部',
opportunityCount: 2,
verifiedValues: [
{ currency: 'CNY', amount: '100' },
{ currency: 'USD', amount: '20' }
],
estimatedValues: [
{ currency: 'CNY', amount: '180' },
{ currency: 'USD', amount: '30' }
]
}]
}]
})
assert.equal(groups[0].items.length, 2)
assert.deepEqual(groups[0].items.map((item) => item.currency), ['CNY', 'USD'])
assert.deepEqual(groups[0].items.map((item) => item.verifiedMagnitude), [100, 20])
assert.ok(groups[0].items.every((item) => item.width === '100%'))
})
test('筛选条件和时间窗口可以通过 URL 恢复', () => {
const filters = readValueFiltersFromQuery({
value_department_id: 'dept-7',
value_value_kind: 'cash',
value_status: 'verified'
})
assert.deepEqual(
{ departmentId: filters.departmentId, valueKind: filters.valueKind, status: filters.status },
{ departmentId: 'dept-7', valueKind: 'cash', status: 'verified' }
)
const query = writeValueFiltersToQuery({ dashboard: 'value', value_city: 'old' }, {
...filters,
city: ''
})
assert.equal(query.dashboard, 'value')
assert.equal(query.value_department_id, 'dept-7')
assert.equal(Object.hasOwn(query, 'value_city'), false)
const range = resolveRangeWindow('custom', { start: '2026-07-01', end: '2026-07-16' })
assert.match(range.start, /^2026-06-30T16:00:00\.000Z$|^2026-07-01T00:00:00\.000Z$/u)
assert.ok(new Date(range.end) > new Date(range.start))
})
test('机会抽屉 URL 可恢复、关闭可清理且非法 ID 不会残留', () => {
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
const opened = writeValueOpportunityToQuery({ dashboard: 'value', value_city: '上海' }, opportunityId)
assert.equal(readValueOpportunityId(opened), opportunityId)
assert.equal(hasValueOpportunityQuery(opened), true)
const closed = writeValueOpportunityToQuery(opened)
assert.equal(hasValueOpportunityQuery(closed), false)
assert.equal(closed.value_city, '上海')
const malformed = { ...opened, value_opportunity: '../tenant-b/opportunity' }
assert.equal(normalizeValueOpportunityId(malformed.value_opportunity), '')
assert.equal(readValueOpportunityId(malformed), '')
assert.equal(hasValueOpportunityQuery(writeValueOpportunityToQuery(malformed)), false)
assert.equal(shouldClearValueOpportunityError({ status: 403 }), true)
assert.equal(shouldClearValueOpportunityError({ status: 404 }), true)
assert.equal(shouldClearValueOpportunityError({ status: 500 }), false)
})
test('打开机会必须仍属于当前筛选和时间窗口', () => {
const opportunity = {
departmentId: 'ignored-top-level',
dimensionJson: {
department_id: 'dept-7',
project_code: 'PROJECT-1',
expense_type: 'hotel',
supplier_id: 'supplier-1',
city: '上海'
},
ownerId: 'finance-1',
sourceType: 'risk_observation',
valueKind: 'cash',
status: 'identified',
createdAt: '2026-07-15T08:00:00Z'
}
const filters = {
departmentId: 'dept-7', projectCode: 'PROJECT-1', expenseType: 'hotel',
supplierId: 'supplier-1', city: '上海', ownerId: 'finance-1',
sourceType: 'risk_observation', valueKind: 'cash', status: 'identified'
}
const window = { start: '2026-07-01T00:00:00Z', end: '2026-07-16T23:59:59Z' }
assert.equal(opportunityMatchesValueContext(opportunity, filters, window), true)
assert.equal(opportunityMatchesValueContext(opportunity, { ...filters, departmentId: 'dept-other' }, window), false)
assert.equal(opportunityMatchesValueContext(opportunity, filters, { ...window, end: '2026-07-14T00:00:00Z' }), false)
})
test('来源动作可定位风险单据、预算配置和 CFO 维度且不伪造预算事实', () => {
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
const links = buildCfoOpportunitySourceLinks({
id: opportunityId,
claimId: '8a25e85a-b7d8-41dc-8717-57bd1d458fd2',
claimNoSnapshot: 'BX-20260716-001',
expenseCaseId: '04452d7a-cdb8-4665-bb9d-99b7f8fc7f1b',
aiDecisionId: 'b100ca12-8ce3-47eb-a80d-35883410545a',
sourceType: 'risk_observation',
sourceId: 'risk-fallback',
category: 'risk_avoidance',
ownerId: 'finance-1',
dimensionJson: {
department_id: 'dept-7',
department_name: '财务部',
project_code: 'PROJECT-1',
expense_type: 'hotel',
city: '上海'
},
evidence: [{ resourceType: 'risk_observation', resourceId: 'risk-observation-7' }]
}, {
dashboardQuery: {
dashboard: 'value',
range: '本月',
value_city: '上海',
value_opportunity: opportunityId
}
})
const risk = links.find((item) => item.key === 'risk-claim')
assert.equal(risk.label, '查看风险来源单据')
assert.equal(risk.to.name, 'app-document-detail')
assert.equal(risk.to.params.requestId, '8a25e85a-b7d8-41dc-8717-57bd1d458fd2')
assert.equal(risk.to.query.returnTo, 'value')
assert.equal(risk.to.query.focus, 'risk')
assert.equal(risk.to.query.risk_observation_id, 'risk-observation-7')
assert.equal(risk.to.query.ai_decision_id, 'b100ca12-8ce3-47eb-a80d-35883410545a')
assert.equal(risk.to.hash, '#risk-observation-active-detail')
const budget = links.find((item) => item.key === 'budget-configuration')
assert.equal(budget.label, '查看预算配置视图')
assert.equal(budget.to.name, 'app-budget')
assert.equal(budget.to.query.budget_department_id, 'dept-7')
assert.equal(budget.to.query.budget_expense_type, 'hotel')
assert.equal(budget.description, BUDGET_CONFIGURATION_NOTICE)
assert.match(budget.description, /不代表当前节省机会的真实预算金额/u)
const dimension = links.find((item) => item.key === 'dimension-expenseType')
assert.equal(dimension.to.name, 'app-overview')
assert.equal(dimension.to.query.dashboard, 'value')
assert.equal(dimension.to.query.value_expense_type, 'hotel')
assert.equal(Object.hasOwn(dimension.to.query, 'value_opportunity'), false)
assert.deepEqual(readBudgetConfigurationFocus(budget.to.query), {
active: true,
departmentId: 'dept-7',
departmentName: '财务部',
expenseType: 'hotel'
})
})
test('带可追溯凭证的实际结果递归序列化为后端 snake_case', async () => {
const originalFetch = globalThis.fetch
let requestBody = null
globalThis.fetch = async (_url, options) => {
requestBody = JSON.parse(options.body)
return new Response(JSON.stringify({ realization: {}, opportunity: {}, event: {}, replayed: false }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await recordSavingsRealization('opp-1', {
requestId: 'request-12345678',
expectedVersion: 2,
comment: '平台付款完成',
actualGross: '100',
incrementalCost: '10',
realizedAt: '2026-07-16T00:00:00Z',
evidenceLevel: 'external_document',
evidence: [{
evidenceKey: 'payment-receipt-001',
evidenceRole: 'payment_receipt',
resourceType: 'payment_receipt',
resourceId: 'receipt-001',
sourceSystem: 'erp',
contentHash: '0123456789abcdef0123456789abcdef',
occurredAt: '2026-07-16T00:00:00Z',
verificationStatus: 'unverified',
metadataJson: { connector: 'erp' }
}]
})
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requestBody.request_id, 'request-12345678')
assert.equal(requestBody.expected_version, 2)
assert.equal(requestBody.incremental_cost, '10')
assert.equal(requestBody.evidence_level, 'external_document')
assert.equal(requestBody.evidence[0].evidence_key, 'payment-receipt-001')
assert.equal(requestBody.evidence[0].metadata_json.connector, 'erp')
})
test('经营价值入口、独立模块、URL 深链与完整状态 UI 已接入分析看板', () => {
assert.match(topBarRange, /label: '经营价值看板', value: 'value'/u)
assert.match(overviewView, /<CfoValueDashboard/u)
assert.match(overviewView, /props\.dashboard === 'value'/u)
assert.match(appShell, /OVERVIEW_DASHBOARDS = new Set\(\['finance', 'value'/u)
assert.match(appShell, /dashboard: resolveOverviewDashboard\(overviewDashboard\.value\)/u)
assert.match(dashboardComposable, /readValueFiltersFromQuery\(route\.query\)/u)
assert.match(dashboardComposable, /writeValueFiltersToQuery\(route\.query/u)
assert.match(dashboardComponent, /dashboardState === 'loading'/u)
assert.match(dashboardComponent, /dashboardState === 'permission'/u)
assert.match(dashboardComponent, /dashboardState === 'error'/u)
assert.match(dashboardComponent, /dashboardState === 'empty'/u)
assert.match(dashboardComponent, /dashboardState === 'partial'/u)
assert.match(dashboardComponent, /dashboardStale/u)
assert.match(dashboardComponent, /kpi\.state/u)
assert.match(dashboardComponent, /机会状态 <em>仅筛选下方台账<\/em>/u)
assert.match(dashboardModel, /baseline-missing/u)
assert.match(dashboardModel, /真实零值/u)
assert.match(dashboardComposable, /valueOpportunityRouteSignature/u)
assert.match(dashboardComposable, /clearUnavailableOpportunity/u)
assert.match(dashboardComposable, /router\.replace\(\{ query: nextQuery \}\)/u)
assert.match(appShellComposable, /DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/u)
assert.match(appShellComposable, /name: 'app-overview', query: buildValueDashboardReturnQuery\(\)/u)
assert.match(opportunityDrawer, /id="value-source-links-title">查看来源/u)
assert.match(opportunityDrawer, /\{\{ link\.label \}\}/u)
assert.match(budgetCenter, /预算配置视图 · \{\{ budgetConfigurationFocusSummary \}\}/u)
assert.match(budgetCenter, /isFocusedBudgetCategory\(item\)/u)
assert.match(budgetCenterScript, /readBudgetConfigurationFocus\(route\.query\)/u)
assert.match(budgetCenterScript, /\.filter\(\(row\) => matchesBudgetConfigurationExpense\(row\)\)/u)
assert.match(budgetCenterScript, /这不代表预算为零/u)
})
test('经营价值实现不包含 demo 或 fallback 指标数字', () => {
assert.doesNotMatch(valueService, /FALLBACK|DEMO|mock/iu)
assert.doesNotMatch(dashboardComposable, /fallback|demo|mock/iu)
assert.match(dashboardComponent, /不会展示旧的演示数字/u)
})
test('移动端、键盘与读屏交互具备可验证的生产语义', () => {
assert.match(dashboardStyles, /@media \(max-width: 760px\)/u)
assert.match(dashboardStyles, /min-height: 44px/u)
assert.match(dashboardComponent, /role="alert"/u)
assert.match(dashboardComponent, /aria-live="polite"/u)
assert.match(actionDialog, /<label/u)
assert.match(actionDialog, /role="alert"/u)
assert.match(actionDialog, /:disabled="submitting"/u)
assert.match(actionDialog, /requestId: form\.requestId/u)
assert.match(dashboardComposable, /command\.requestId \|\| createRequestId/u)
assert.doesNotMatch(dashboardComposable, /recordSavingsRealization/u)
assert.match(dashboardComposable, /实际结果必须由付款事件或具备可追溯凭证的连接器写入/u)
assert.match(opportunityDrawer, /action !== 'record_realization'/u)
assert.match(opportunityDrawer, /当前页面不会提交空证据/u)
assert.match(opportunityDrawer, /financeConfirmerName/u)
assert.match(opportunityDrawer, /确认说明:/u)
assert.doesNotMatch(actionDialog, /实际毛节省/u)
assert.match(opportunityDrawer, /aria-label="关闭节省机会详情"/u)
assert.match(trendChart, /role="img"/u)
assert.match(trendChart, /查看趋势明细表/u)
assert.match(trendChart, /<caption class="sr-only">/u)
})
function dashboardFixture({
dataStatus,
opportunityCount = 1,
realizationCount = 1,
freshnessAt = '2026-07-16T00:00:00Z'
}) {
return {
source: { dataStatus, opportunityCount, realizationCount, freshnessAt },
kpis: {},
funnel: { stages: [] },
trend: [],
breakdowns: [],
guardrails: [],
dataQuality: {}
}
}
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}

View File

@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
const componentFiles = [
'../src/components/commercial/CommercialWorkspace.vue',
'../src/components/commercial/CommercialAccountOverview.vue',
'../src/components/commercial/CommercialValueProofPanel.vue',
'../src/components/commercial/CommercialPricingScenarioPanel.vue',
'../src/components/commercial/CommercialMetricCard.vue',
'../src/components/commercial/CommercialAdminConsole.vue',
'../src/components/commercial/CommercialHistoryPanel.vue',
'../src/components/commercial/CommercialMutationDialog.vue'
]
for (const relativeFile of componentFiles) {
test(`${relativeFile} 可以独立编译`, () => {
const filename = fileURLToPath(new URL(relativeFile, import.meta.url))
const source = readFileSync(filename, 'utf8')
const parsed = parse(source, { filename })
assert.deepEqual(parsed.errors, [])
const id = `commercial-${relativeFile.replace(/\W+/gu, '-')}`
const script = compileScript(parsed.descriptor, { id })
const template = compileTemplate({
source: parsed.descriptor.template?.content || '',
filename,
id,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
})
}
const workspace = readSource('../src/components/commercial/CommercialWorkspace.vue')
const valuePanel = readSource('../src/components/commercial/CommercialValueProofPanel.vue')
const pricingPanel = readSource('../src/components/commercial/CommercialPricingScenarioPanel.vue')
const metricCard = readSource('../src/components/commercial/CommercialMetricCard.vue')
const adminConsole = readSource('../src/components/commercial/CommercialAdminConsole.vue')
const mutationDialog = readSource('../src/components/commercial/CommercialMutationDialog.vue')
const historyPanel = readSource('../src/components/commercial/CommercialHistoryPanel.vue')
const styles = readSource('../src/components/commercial/commercial-workspace.css')
const overviewView = readSource('../src/views/OverviewView.vue')
const appShell = readSource('../src/views/AppShellRouteView.vue')
const topBarOverviewRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
const overviewModel = readSource('../src/composables/useOverviewView.js')
test('商业工作台具有显式租户选择、双账分离、权限和完整空错状态', () => {
assert.match(workspace, /目标租户 ID/u)
assert.match(workspace, /平台管理员必须显式选择租户/u)
assert.match(workspace, /商业权益不能放宽安全规则/u)
assert.match(valuePanel, /客户价值账/u)
assert.match(valuePanel, /平台经营账/u)
assert.match(valuePanel, /不同币种不会混算/u)
assert.match(valuePanel, /!platformAdmin/u)
assert.match(valuePanel, /role="alert"/u)
assert.match(valuePanel, /这不是零成本或零价值/u)
})
test('无真实财务证据显示不可用和缺失输入,不含演示或回退指标', () => {
assert.match(metricCard, /commercial-unavailable-value">不可用/u)
assert.match(metricCard, /查看缺失证据/u)
assert.match(metricCard, /requiredInputs/u)
assert.doesNotMatch(workspace, /demo|mock|fallback/iu)
assert.doesNotMatch(valuePanel, /demo|mock|fallback/iu)
})
test('定价场景具有参数确认、分币种走廊、部分可用与不写套餐语义', () => {
assert.match(workspace, /CommercialPricingScenarioPanel/u)
for (const label of ['目标贡献毛利率', '最大价值分享比例', '证据截止时间as_of', '确认计算条件']) {
assert.match(pricingPanel, new RegExp(label, 'u'))
}
for (const label of ['可持续收费下限', '价值对齐收费上限', '成功费封顶', '推荐商业模式', '缺失输入']) {
assert.match(pricingPanel, new RegExp(label, 'u'))
}
assert.match(pricingPanel, /v-for="row in view\.rows"/u)
assert.match(pricingPanel, /row\.currency/u)
assert.match(pricingPanel, /evidenceStatus/u)
assert.match(pricingPanel, /不可用/u)
assert.match(pricingPanel, /不会自动写入套餐/u)
assert.match(pricingPanel, /aria-busy/u)
assert.match(pricingPanel, /role="alert"/u)
assert.match(pricingPanel, /inputmode="decimal"/u)
})
test('所有商业写操作都有操作确认、忙碌态、幂等和版本化提示', () => {
for (const label of ['创建套餐版本', '激活套餐版本', '创建订阅快照', '重新激活订阅', '变更订阅状态', '配置权益版本', '激活权益版本', '记录用量事件', '记录成本事件']) {
assert.match(readSource('../src/components/commercial/commercialWorkspaceModel.js'), new RegExp(label, 'u'))
}
assert.match(adminConsole, /二次确认/u)
assert.match(mutationDialog, /step === 'edit'/u)
assert.match(mutationDialog, /确认写入/u)
assert.match(mutationDialog, /幂等键/u)
assert.match(mutationDialog, /版本化快照/u)
assert.match(mutationDialog, /:disabled="busy"/u)
assert.match(mutationDialog, /role="alertdialog"/u)
})
test('五类历史有按需加载、刷新、空态与订阅状态操作入口', () => {
for (const label of ['套餐版本', '订阅历史', '权益版本', '用量事件', '成本事件']) {
assert.match(historyPanel, new RegExp(label, 'u'))
}
assert.match(historyPanel, /加载历史/u)
assert.match(historyPanel, /刷新历史/u)
assert.match(historyPanel, /这不是零用量或零成本/u)
assert.match(historyPanel, /manage-subscription/u)
assert.match(historyPanel, /变更状态/u)
assert.match(historyPanel, /恢复/u)
})
test('商业工作台满足键盘、触控、响应式和减少动画语义', () => {
assert.match(styles, /min-height: 44px/u)
assert.match(styles, /:focus-visible/u)
assert.match(styles, /@media \(max-width: 760px\)/u)
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/u)
assert.match(mutationDialog, /@keydown\.esc/u)
assert.match(mutationDialog, /aria-label="关闭对话框"/u)
assert.match(pricingPanel, /tabindex="-1"/u)
assert.match(pricingPanel, /:disabled="loading"/u)
})
test('商业工作台已接入分析看板且不会误加载财务看板', () => {
assert.match(overviewView, /CommercialWorkspace/u)
assert.match(overviewView, /activeDashboard === 'commercial'/u)
assert.match(overviewView, /currentUser: \{ type: Object/u)
assert.match(appShell, /:current-user="currentUser"/u)
assert.match(appShell, /'commercial'/u)
assert.match(topBarOverviewRange, /\{ label: '商业化管理', value: 'commercial' \}/u)
assert.match(overviewModel, /dashboard === 'commercial'/u)
assert.match(
overviewModel,
/activeDashboardKey\.value === 'commercial' \|\| activeDashboardKey\.value === 'value'/u
)
})
function readSource(relativeFile) {
return readFileSync(fileURLToPath(new URL(relativeFile, import.meta.url)), 'utf8')
}

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
activateCommercialEntitlement,
activateCommercialPlan,
activateCommercialSubscription,
buildCommercialAnalyticsSearch,
buildCommercialPricingScenario,
createCommercialPlan,
createCommercialSubscription,
fetchCommercialAccount,
fetchCommercialAnalytics,
fetchCommercialCostEvents,
fetchCommercialEntitlements,
fetchCommercialPlans,
fetchCommercialSubscriptions,
fetchCommercialUsageEvents,
normalizeCommercialPayload,
recordCommercialCost,
recordCommercialUsage,
serializeCommercialPayload,
transitionCommercialSubscription,
upsertCommercialEntitlement
} from '../src/services/commercial.js'
function installFetch(handler) {
const original = globalThis.fetch
globalThis.fetch = handler
return () => { globalThis.fetch = original }
}
function jsonResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' }
})
}
test('商业接口递归转换 snake_case 与 camelCase 且保留数组和金额字符串', () => {
const normalized = normalizeCommercialPayload({
data_status: 'partial',
customer_roi: { required_inputs: ['收费'], ratios: [{ reporting_currency: 'CNY', ratio: '1.20' }] }
})
assert.equal(normalized.dataStatus, 'partial')
assert.equal(normalized.customerRoi.requiredInputs[0], '收费')
assert.equal(normalized.customerRoi.ratios[0].reportingCurrency, 'CNY')
assert.equal(normalized.customerRoi.ratios[0].ratio, '1.20')
assert.deepEqual(serializeCommercialPayload({
planCode: 'enterprise',
configJson: { billingMode: 'overage' },
requiredInputs: ['invoice']
}), {
plan_code: 'enterprise',
config_json: { billing_mode: 'overage' },
required_inputs: ['invoice']
})
})
test('普通账号只读取自身账户,平台管理员显式选择租户后才使用跨租户路径', async () => {
const urls = []
const restore = installFetch(async (url) => {
urls.push(String(url))
return jsonResponse({ tenant_id: 'tenant-a', data_status: 'unavailable', quotas: [] })
})
try {
await fetchCommercialAccount({ platformAdmin: false, tenantId: 'should-not-leak' })
await fetchCommercialAccount({ platformAdmin: true, tenantId: 'tenant/a', asOf: '2026-07-16T00:00:00Z' })
} finally {
restore()
}
assert.equal(urls[0], '/api/v1/commercial/account')
assert.match(urls[1], /\/commercial\/admin\/tenants\/tenant%2Fa\/account\?as_of=2026-07-16T00%3A00%3A00Z$/u)
})
test('商业分析时间参数显式序列化且不附加客户端汇率或合计字段', async () => {
const search = buildCommercialAnalyticsSearch({
start: '2026-04-01T00:00:00Z',
end: '2026-07-01T00:00:00Z',
asOf: '2026-07-01T00:00:00Z',
fxRate: '7.2'
})
assert.equal(search.get('as_of'), '2026-07-01T00:00:00Z')
assert.equal(search.has('fx_rate'), false)
let requestedUrl = ''
const restore = installFetch(async (url) => {
requestedUrl = String(url)
return jsonResponse({
tenant_id: 'tenant-a',
data_quality_status: 'unavailable',
customer_charges: {},
internal_costs: {},
contribution_margin: {},
verified_cash_savings: {},
customer_roi: {},
customer_labor_value: {}
})
})
try {
await fetchCommercialAnalytics('tenant-a', Object.fromEntries(search))
} finally {
restore()
}
assert.match(requestedUrl, /\/commercial\/admin\/tenants\/tenant-a\/analytics/u)
assert.match(requestedUrl, /start=2026-04-01T00%3A00%3A00Z/u)
})
test('定价场景使用平台租户 POST 契约且只提交服务端 schema 字段', async () => {
let request = null
const restore = installFetch(async (url, options = {}) => {
request = { url: String(url), method: options.method, body: JSON.parse(options.body) }
return jsonResponse({
tenant_id: 'tenant-a',
recommended_model: 'pilot_collecting',
evidence_status: 'unavailable',
scenarios: [],
notes: ['不会自动修改套餐']
})
})
try {
const response = await buildCommercialPricingScenario('tenant/a', {
start: '2026-04-01T00:00:00Z',
end: '2026-07-01T23:59:59Z',
asOf: '2026-07-02T00:00:00Z',
targetContributionMarginRate: '0.65',
maxVerifiedSavingsShare: '0.25'
})
assert.equal(response.recommendedModel, 'pilot_collecting')
} finally {
restore()
}
assert.equal(request.method, 'POST')
assert.match(request.url, /\/commercial\/admin\/tenants\/tenant%2Fa\/pricing-scenarios$/u)
assert.deepEqual(Object.keys(request.body).sort(), [
'as_of',
'end',
'max_verified_savings_share',
'start',
'target_contribution_margin_rate'
])
assert.equal(request.body.target_contribution_margin_rate, '0.65')
assert.equal(request.body.max_verified_savings_share, '0.25')
})
test('商业写接口覆盖套餐、订阅、权益、激活、用量与成本并统一递归序列化', async () => {
const requests = []
const restore = installFetch(async (url, options = {}) => {
requests.push({ url: String(url), method: options.method, headers: options.headers, body: JSON.parse(options.body) })
return jsonResponse({ id: 'resource-1', created: true })
})
try {
await createCommercialPlan('tenant-a', { planCode: 'enterprise', contractTermsJson: { approvedBy: 'cfo' } })
await activateCommercialPlan('tenant-a', 'plan-1', 2, '启用新套餐版本')
await createCommercialSubscription('tenant-a', { subscriptionKey: 'sub-1', autoRenew: true })
await activateCommercialSubscription('tenant-a', 'sub-1', 3, '恢复客户订阅')
await transitionCommercialSubscription('tenant-a', 'sub-1', { expectedVersion: 4, targetStatus: 'suspended', reason: '合同暂停' })
await upsertCommercialEntitlement('tenant-a', { entitlementKey: 'ocr', configJson: { billingMode: 'all_usage' } })
await activateCommercialEntitlement('tenant-a', 'ent-1', 4, '恢复 OCR 权益')
await recordCommercialUsage('tenant-a', { idempotencyKey: 'usage-1', subjectType: 'claim' })
await recordCommercialCost('tenant-a', { idempotencyKey: 'cost-1', originalCurrency: 'CNY' })
} finally {
restore()
}
assert.deepEqual(requests.map((item) => item.method), ['POST', 'POST', 'POST', 'POST', 'POST', 'PUT', 'POST', 'POST', 'POST'])
assert.match(requests[0].url, /\/plans$/u)
assert.equal(requests[0].body.contract_terms_json.approved_by, 'cfo')
assert.equal(requests[1].body.expected_version, 2)
assert.equal(requests[1].body.reason, '启用新套餐版本')
assert.ok(requests.every((item) => item.headers['X-Request-Id']))
assert.equal(requests[2].body.auto_renew, true)
assert.match(requests[4].url, /\/subscriptions\/sub-1\/transition$/u)
assert.equal(requests[4].body.target_status, 'suspended')
assert.equal(requests[5].body.config_json.billing_mode, 'all_usage')
assert.match(requests[7].url, /\/usage-events$/u)
assert.match(requests[8].url, /\/cost-events$/u)
})
test('五类商业历史按最终分资源契约读取并正确序列化筛选', async () => {
const urls = []
const restore = installFetch(async (url) => {
urls.push(String(url))
return jsonResponse([])
})
try {
await fetchCommercialPlans('tenant-a', { planStatus: 'active', limit: 20, offset: 1 })
await fetchCommercialSubscriptions('tenant-a', { subscriptionStatus: 'suspended' })
await fetchCommercialEntitlements('tenant-a', { subscriptionId: 'sub-1', entitlementStatus: 'active' })
await fetchCommercialUsageEvents('tenant-a', { subscriptionId: 'sub-1', start: '2026-07-01T00:00:00Z', end: '2026-08-01T00:00:00Z' })
await fetchCommercialCostEvents('tenant-a', { limit: 50 })
} finally {
restore()
}
assert.match(urls[0], /\/plans\?plan_status=active&limit=20&offset=1$/u)
assert.match(urls[1], /\/subscriptions\?subscription_status=suspended$/u)
assert.match(urls[2], /\/entitlements\?subscription_id=sub-1&entitlement_status=active$/u)
assert.match(urls[3], /\/usage-events\?subscription_id=sub-1&start=.*&end=.*/u)
assert.match(urls[4], /\/cost-events\?limit=50$/u)
})
test('后端权限和冲突错误保留状态与可执行提示', async () => {
const restore = installFetch(async () => jsonResponse({ detail: '只有平台管理员可以执行该操作。' }, 403))
try {
await assert.rejects(
() => fetchCommercialAnalytics('tenant-a'),
(error) => error.status === 403 && /平台管理员/u.test(error.message)
)
} finally {
restore()
}
})

View File

@@ -0,0 +1,269 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAnalyticsWindow,
buildCommercialMutation,
buildCommercialValueLedgers,
buildPricingScenarioPayload,
buildPricingScenarioView,
buildQuotaRows,
classifyCommercialAccountState,
createDefaultPricingScenarioForm,
formatCurrencyAmount,
formatMetricValues,
formatRatio
} from '../src/components/commercial/commercialWorkspaceModel.js'
test('客户价值账和平台经营账保持严格分离', () => {
const ledgers = buildCommercialValueLedgers({
verifiedCashSavings: metric('verified_cash_savings', '客户财务确认现金节省', [{ currency: 'CNY', amount: '100' }]),
customerCharges: metric('customer_charges', '客户合同收费', [{ currency: 'CNY', amount: '40' }], 'partial'),
customerRoi: { ...metric('customer_roi', '客户现金 ROI', [], 'partial'), ratios: [{ currency: 'CNY', ratio: '1.5', numerator: '60', denominator: '40' }] },
customerLaborValue: unavailable('customer_labor_value', '客户认可工时价值'),
internalCosts: metric('internal_costs', '平台内部成本', [{ currency: 'CNY', amount: '12' }]),
contributionMargin: metric('contribution_margin', '贡献毛利', [{ currency: 'CNY', amount: '28' }], 'partial')
})
assert.deepEqual(ledgers.customer.map((item) => item.category), [
'verifiedCashSavings', 'customerCharges', 'customerRoi', 'customerLaborValue'
])
assert.deepEqual(ledgers.platform.map((item) => item.category), ['internalCosts', 'contributionMargin'])
assert.equal(ledgers.customer[2].displayValue, '150.0%')
assert.equal(ledgers.platform[0].displayValue, formatCurrencyAmount('12', 'CNY'))
})
test('不同币种逐行保留,绝不在前端求和或换算', () => {
const values = formatMetricValues(metric('verified', '已核验', [
{ currency: 'CNY', amount: '100' },
{ currency: 'USD', amount: '20' }
]))
assert.deepEqual(values.map((item) => item.currency), ['CNY', 'USD'])
assert.deepEqual(values.map((item) => item.amount), [100, 20])
assert.match(values[0].displayValue, /100/u)
assert.match(values[1].displayValue, /20/u)
assert.equal(formatRatio('0.25'), '25.0%')
})
test('没有真实财务证据时明确不可用,不把未知伪装成零', () => {
const [cash, labor] = buildCommercialValueLedgers({
verifiedCashSavings: unavailable('verified_cash_savings', '客户财务确认现金节省'),
customerCharges: unavailable('customer_charges', '客户合同收费'),
customerRoi: unavailable('customer_roi', '客户现金 ROI'),
customerLaborValue: unavailable('customer_labor_value', '客户认可工时价值'),
internalCosts: unavailable('internal_costs', '平台内部成本'),
contributionMargin: unavailable('contribution_margin', '贡献毛利')
}).customer
assert.equal(cash.status, 'unavailable')
assert.equal(cash.displayValue, '不可用')
assert.deepEqual(cash.values, [])
assert.equal(labor.displayValue, '不可用')
assert.deepEqual(labor.requiredInputs, ['真实财务证据'])
})
test('账户状态完整区分加载、权限、错误、未配置、部分与就绪', () => {
assert.equal(classifyCommercialAccountState({ loading: true }), 'loading')
assert.equal(classifyCommercialAccountState({ error: { status: 403 } }), 'permission')
assert.equal(classifyCommercialAccountState({ error: new Error('offline') }), 'error')
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'unavailable' } }), 'unavailable')
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'partial' } }), 'partial')
assert.equal(classifyCommercialAccountState({ account: { dataStatus: 'available' } }), 'ready')
})
test('权益列表保持失败关闭语义并区分不限量和未知硬上限', () => {
const rows = buildQuotaRows({ quotas: [
{ status: 'unlimited', usedQuantity: '12', hardLimitRemaining: null, commerciallyAllowed: true, entitlement: { id: 'e1', entitlementKey: 'ocr', metricKey: 'ocr_calls', unit: '次', entitlementType: 'unlimited', version: 1 } },
{ status: 'inactive', usedQuantity: '0', hardLimitRemaining: null, commerciallyAllowed: false, reason: '订阅暂停', entitlement: { id: 'e2', entitlementKey: 'ai', metricKey: 'ai_calls', unit: '次', entitlementType: 'metered', version: 2 } }
] })
assert.equal(rows[0].remainingLabel, '不限量')
assert.equal(rows[1].remainingLabel, '未配置硬上限')
assert.equal(rows[1].commerciallyAllowed, false)
assert.equal(rows[1].reason, '订阅暂停')
})
test('分析窗口生成显式时区并拒绝倒序和超过两年', () => {
const window = buildAnalyticsWindow({ start: '2026-04-01', end: '2026-07-01' })
assert.match(window.start, /(Z|[+-]\d{2}:\d{2})$/u)
assert.match(window.end, /(Z|[+-]\d{2}:\d{2})$/u)
assert.throws(() => buildAnalyticsWindow({ start: '2026-07-01', end: '2026-04-01' }), /早于/u)
assert.throws(() => buildAnalyticsWindow({ start: '2023-01-01', end: '2026-07-01' }), /731/u)
})
test('定价场景将百分比和时间窗转换为后端精确契约并执行前置校验', () => {
const defaults = createDefaultPricingScenarioForm(
{ start: '2026-04-01', end: '2026-07-01' },
new Date('2026-07-02T08:30:00Z')
)
assert.equal(defaults.targetContributionMarginPercent, '65')
assert.equal(defaults.maxVerifiedSavingsSharePercent, '25')
const payload = buildPricingScenarioPayload({
...defaults,
asOf: '2026-07-02T08:30:00Z',
targetContributionMarginPercent: '65',
maxVerifiedSavingsSharePercent: '25'
})
assert.match(payload.start, /(Z|[+-]\d{2}:\d{2})$/u)
assert.match(payload.end, /(Z|[+-]\d{2}:\d{2})$/u)
assert.equal(payload.asOf, '2026-07-02T08:30:00.000Z')
assert.equal(payload.targetContributionMarginRate, '0.65')
assert.equal(payload.maxVerifiedSavingsShare, '0.25')
assert.throws(() => buildPricingScenarioPayload({
...defaults, targetContributionMarginPercent: '95'
}), /小于 95/u)
assert.throws(() => buildPricingScenarioPayload({
...defaults, targetContributionMarginPercent: '94.999999'
}), /小于 95/u)
assert.equal(buildPricingScenarioPayload({
...defaults, targetContributionMarginPercent: '94.9999'
}).targetContributionMarginRate, '0.949999')
assert.throws(() => buildPricingScenarioPayload({
...defaults, maxVerifiedSavingsSharePercent: '0'
}), /大于 0/u)
assert.throws(() => buildPricingScenarioPayload({
...defaults, asOf: '2026-03-01T00:00:00Z'
}), /不能早于/u)
})
test('定价结果逐币种展示价格走廊且绝不把缺失事实显示为零', () => {
const view = buildPricingScenarioView({
tenantId: 'tenant-a',
start: '2026-04-01T00:00:00Z',
end: '2026-07-01T23:59:59Z',
asOf: '2026-07-02T00:00:00Z',
targetContributionMarginRate: '0.65',
maxVerifiedSavingsShare: '0.25',
recommendedModel: 'hybrid',
evidenceStatus: 'partial',
scenarios: [
{
currency: 'CNY', status: 'feasible', internalCost: '20', verifiedCashSavings: '200',
minimumSustainableCharge: '57.1429', maximumValueAlignedCharge: '50',
maximumSuccessFee: '0', customerRoiAtMinimumCharge: '2.5',
contributionMarginAtValueCeiling: '0.6', reason: '独立 CNY 走廊'
},
{
currency: 'USD', status: 'cost_only', internalCost: '5', verifiedCashSavings: null,
minimumSustainableCharge: '14.2857', maximumValueAlignedCharge: null,
maximumSuccessFee: null, customerRoiAtMinimumCharge: null,
contributionMarginAtValueCeiling: null, reason: '缺少 USD 价值证据'
}
],
notes: ['结果不会自动写套餐']
})
assert.equal(view.recommendedModelLabel, '混合定价(基础订阅 + 封顶成功费)')
assert.equal(view.multiCurrency, true)
assert.deepEqual(view.rows.map((row) => row.currency), ['CNY', 'USD'])
assert.match(view.rows[0].minimumSustainableChargeLabel, /57/u)
assert.equal(view.rows[1].maximumValueAlignedChargeLabel, '不可用')
assert.equal(view.rows[1].maximumSuccessFeeLabel, '不可用')
assert.equal(view.rows[1].customerRoiLabel, '—')
assert.deepEqual(view.rows[1].missingInputs, ['同币种财务确认现金节省'])
assert.doesNotMatch(view.rows[1].maximumValueAlignedChargeLabel, /0/u)
})
test('完全无证据的定价响应保留 unavailable 与缺失说明', () => {
const view = buildPricingScenarioView({
recommendedModel: 'pilot_collecting', evidenceStatus: 'unavailable', scenarios: [], notes: []
})
assert.equal(view.evidenceStatus, 'unavailable')
assert.equal(view.evidenceStatusLabel, '不可用')
assert.equal(view.recommendedModelLabel, '试点采集证据')
assert.deepEqual(view.rows, [])
})
test('套餐、订阅与权益表单构造服务端事实时间和版本化载荷', () => {
const plan = buildCommercialMutation('createPlan', {
planCode: 'enterprise', name: '企业版', pricingModel: 'hybrid', billingInterval: 'annual',
currency: 'cny', baseFee: '1000', includedSeats: 20, overageEnabled: true,
effectiveFrom: '2026-07-16T09:30', effectiveTo: '', contractTermsJson: '{"approved_by":"cfo"}'
})
assert.equal(plan.currency, 'CNY')
assert.match(plan.effectiveFrom, /Z$/u)
assert.equal(plan.contractTermsJson.approved_by, 'cfo')
const subscription = buildCommercialMutation('createSubscription', {
subscriptionKey: 'sub-enterprise', status: 'active', startsAt: '2026-07-16T09:30',
currentPeriodStart: '2026-07-16T09:30', currentPeriodEnd: '2026-08-16T09:30',
seats: 20, metadataJson: '{}'
}, { plan: { id: 'plan-1' } })
assert.equal(subscription.planId, 'plan-1')
assert.match(subscription.currentPeriodEnd, /Z$/u)
const entitlement = buildCommercialMutation('upsertEntitlement', {
entitlementKey: 'ocr', metricKey: 'ocr_calls', entitlementType: 'metered', unit: '次',
includedQuantity: '100', hardLimitQuantity: '120', resetInterval: 'monthly',
overagePolicy: 'block', effectiveFrom: '2026-07-16T09:30', configJson: '{}'
}, { subscription: { id: 'sub-1' } })
assert.equal(entitlement.subscriptionId, 'sub-1')
assert.equal(entitlement.hardLimitQuantity, '120')
})
test('客户端在写入前拒绝不完整窗口、降配和无效 JSON', () => {
assert.throws(() => buildCommercialMutation('createPlan', {
planCode: 'p', name: 'P', pricingModel: 'subscription', billingInterval: 'annual', currency: 'CNY',
baseFee: '0', includedSeats: 1, effectiveFrom: '2026-08-01T00:00', effectiveTo: '2026-07-01T00:00', contractTermsJson: '{}'
}), /晚于/u)
assert.throws(() => buildCommercialMutation('upsertEntitlement', {
entitlementKey: 'ocr', metricKey: 'ocr', entitlementType: 'metered', unit: '次', includedQuantity: '100',
hardLimitQuantity: '10', resetInterval: 'monthly', overagePolicy: 'block', effectiveFrom: '2026-07-01T00:00', configJson: '{}'
}, { subscription: { id: 'sub-1' } }), /不能小于/u)
assert.throws(() => buildCommercialMutation('createPlan', {
planCode: 'p', name: 'P', pricingModel: 'subscription', billingInterval: 'annual', currency: 'CNY',
baseFee: '0', includedSeats: 1, effectiveFrom: '2026-07-01T00:00', contractTermsJson: '{bad}'
}), /有效 JSON/u)
})
test('用量和成本事件在客户端执行幂等、符号、冲回与币种边界校验', () => {
const account = { subscription: { id: 'sub-1' } }
const usage = buildCommercialMutation('recordUsage', {
entitlementId: 'ent-1', eventType: 'usage', quantity: '1', occurredAt: '2026-07-16T09:30',
sourceSystem: 'web', idempotencyKey: 'usage-1', metadataJson: '{}'
}, account)
assert.equal(usage.subscriptionId, 'sub-1')
assert.match(usage.occurredAt, /Z$/u)
assert.throws(() => buildCommercialMutation('recordUsage', {
entitlementId: 'ent-1', eventType: 'credit', quantity: '1', occurredAt: '2026-07-16T09:30',
sourceSystem: 'web', idempotencyKey: 'usage-2', metadataJson: '{}'
}, account), /必须小于 0/u)
assert.throws(() => buildCommercialMutation('recordUsage', {
entitlementId: 'ent-1', eventType: 'reversal', quantity: '-1', occurredAt: '2026-07-16T09:30',
sourceSystem: 'web', idempotencyKey: 'usage-3', metadataJson: '{}'
}, account), /被冲回/u)
const cost = buildCommercialMutation('recordCost', {
eventType: 'incurred', costCategory: 'ai_inference', quantity: '3', unit: '次', unitCost: '0.02',
originalCurrency: 'usd', reportingCurrency: 'cny', fxRate: '7.2', allocationKey: 'ai-july',
occurredAt: '2026-07-16T09:30', sourceSystem: 'billing', idempotencyKey: 'cost-1', metadataJson: '{}'
}, account)
assert.equal(cost.originalCurrency, 'USD')
assert.equal(cost.reportingCurrency, 'CNY')
assert.equal(cost.fxRate, '7.2')
})
test('订阅状态变更必须使用版本、受限目标状态和可审计原因', () => {
const transition = buildCommercialMutation('transitionSubscription', {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'suspended', reason: '客户合同暂停'
})
assert.deepEqual(transition, {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'suspended', reason: '客户合同暂停'
})
assert.throws(() => buildCommercialMutation('transitionSubscription', {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'active', reason: '恢复'
}), /目标订阅状态无效/u)
assert.throws(() => buildCommercialMutation('transitionSubscription', {
resourceId: 'sub-1', expectedVersion: 3, targetStatus: 'canceled', reason: 'x'
}), /至少填写 2/u)
})
function metric(key, label, values, status = 'available') {
return { key, label, status, values, ratios: [], reason: '真实事实口径', requiredInputs: [], notes: [] }
}
function unavailable(key, label) {
return { key, label, status: 'unavailable', values: [], ratios: [], reason: '缺少真实财务证据', requiredInputs: ['真实财务证据'], notes: [] }
}

View File

@@ -9,6 +9,10 @@ const topBar = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const topBarOverviewRange = readFileSync(
fileURLToPath(new URL('../src/components/layout/useTopBarOverviewRange.js', import.meta.url)),
'utf8'
)
const overviewView = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
@@ -62,7 +66,10 @@ test('digital employee dashboard normalizes backend payload fields', () => {
})
test('digital employee dashboard is wired into overview dashboard switch', () => {
assert.match(topBar, /label: '数字员工看板', value: 'digitalEmployee'/)
assert.match(topBar, /import \{ useTopBarOverviewRange \} from '\.\/useTopBarOverviewRange\.js'/)
assert.match(topBar, /:options="overviewDashboardOptions"/)
assert.match(topBar, /useTopBarOverviewRange\(props, emit\)/)
assert.match(topBarOverviewRange, /label: '数字员工看板', value: 'digitalEmployee'/)
assert.match(overviewView, /<DigitalEmployeeDashboard/)
assert.match(overviewView, /activeDashboard === 'digitalEmployee'/)
assert.match(overviewView, /digitalEmployeeKpiMetrics/)

View File

@@ -149,10 +149,10 @@ const flowScript = [
'../src/views/scripts/travelReimbursementFlowToolModel.js',
'../src/views/scripts/travelReimbursementFlowTiming.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const personalWorkbenchAiModeScript = [
'../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js',
'../src/composables/workbenchAiMode/useWorkbenchAiIntentExecution.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
@@ -276,7 +276,7 @@ test('AI workbench routes compact travel direct-submit planner into preview with
)
assert.match(
personalWorkbenchAiModeScript,
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
/resolveExecutableTravelApplicationPlan\(\s*buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)\s*\)/
)
assert.match(
personalWorkbenchAiModeScript,

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
import {
fetchFinancialConnectorObservability,
fetchFinancialPaymentEvidence
} from '../src/services/financialConnectors.js'
const panelPath = fileURLToPath(new URL('../src/components/dashboard/FinancialConnectorHealthPanel.vue', import.meta.url))
const panelSource = readFileSync(panelPath, 'utf8')
const overviewSource = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
test('连接器观测与付款证据接口使用租户内路径并限制窗口', async () => {
const requests = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (url) => {
requests.push(String(url))
return new Response(JSON.stringify({ summary: {}, items: [] }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await fetchFinancialConnectorObservability({ windowHours: 999 })
await fetchFinancialPaymentEvidence('claim/a')
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requests[0], '/api/v1/financial-connectors/observability?window_hours=720')
assert.equal(requests[1], '/api/v1/financial-connectors/payment-evidence/claim%2Fa')
await assert.rejects(() => fetchFinancialPaymentEvidence(''), /不能为空/u)
})
test('连接器健康面板可以独立编译并接入财务看板', () => {
const parsed = parse(panelSource, { filename: panelPath })
assert.deepEqual(parsed.errors, [])
const script = compileScript(parsed.descriptor, { id: 'financial-connector-health-panel' })
const template = compileTemplate({
source: parsed.descriptor.template?.content || '',
filename: panelPath,
id: 'financial-connector-health-panel',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
assert.match(overviewSource, /<FinancialConnectorHealthPanel/u)
assert.match(overviewSource, /activeDashboard === 'finance'/u)
})
test('面板明确区分证据等级且不把未采集运行指标伪装成零', () => {
assert.match(panelSource, /生产外部回执 · 高可信现金事实/u)
assert.match(panelSource, /人工付款确认 · 低等级内部状态/u)
assert.match(panelSource, /模拟\/预发布回执 · 仅验证,不入核心账/u)
assert.match(panelSource, /retry_count == null \? '待耐久遥测'/u)
assert.match(panelSource, /signature_failure_count == null \? '待耐久遥测'/u)
assert.doesNotMatch(panelSource, /raw_payload|signature_header|secret_ref/u)
})

View File

@@ -13,7 +13,7 @@ function testIngestTimeUsesDedicatedColumn() {
)
assert.match(
policiesView,
/<td>\s*<div class="state-cell">\s*<span class="state-tag"[^>]*>\{\{ doc\.state \}\}<\/span>\s*<\/div>\s*<\/td>\s*<td class="ingest-time-cell">\{\{ doc\.ingestTime \|\| '—' \}\}<\/td>/,
/<td data-label="状态">\s*<div class="state-cell">\s*<span class="state-tag"[^>]*>\{\{ doc\.state \}\}<\/span>\s*<\/div>\s*<\/td>\s*<td class="ingest-time-cell" data-label="归纳时间">\{\{ doc\.ingestTime \|\| '—' \}\}<\/td>/,
'状态列只展示状态标签,归纳时间需要放到独立单元格'
)
assert.doesNotMatch(

View File

@@ -10,6 +10,7 @@ function readProjectFile(path) {
function testReceiptFolderViewSurface() {
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
const formatting = readProjectFile('web/src/views/scripts/receiptFolderFormatting.js')
assert.match(view, /activeStatus = ref\('all'\)/)
assert.match(view, /value: 'all'/)
@@ -32,7 +33,9 @@ function testReceiptFolderViewSurface() {
assert.match(view, /receiptEditLogs/)
assert.match(view, /previewFrameUrl/)
assert.match(view, /previewTransform/)
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /formatReceiptDateTime as formatDateTime/)
assert.match(view, /formatReceiptRecognitionScore as formatScore/)
assert.match(formatting, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /openAssociateDialogForCurrentReceipt/)
assert.match(view, /createReceiptDetailDashboardModel/)
assert.match(view, /createReceiptDetailFieldModel/)

View File

@@ -40,6 +40,10 @@ const topBarComponent = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const topBarOverviewRange = readFileSync(
fileURLToPath(new URL('../src/components/layout/useTopBarOverviewRange.js', import.meta.url)),
'utf8'
)
const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
@@ -144,10 +148,13 @@ test('risk dashboard follows the top overview range without card-level selectors
test('overview custom date defaults use current year instead of hard-coded legacy dates', () => {
assert.match(topBarComponent, /createCurrentYearDateRange/)
assert.match(topBarComponent, /formatDateValue/)
assert.match(topBarComponent, /useTopBarOverviewRange/)
assert.match(topBarOverviewRange, /formatDateValue/)
assert.match(topBarOverviewRange, /useTopBarOverviewRange\(props, emit\)/)
assert.match(appShellComposable, /createCurrentYearDateRange\(\)/)
assert.match(legacyAppScript, /createCurrentYearDateRange\(\)/)
assert.doesNotMatch(topBarComponent, /2024-07-06|2024-07-12/)
assert.doesNotMatch(topBarOverviewRange, /2024-07-06|2024-07-12/)
assert.doesNotMatch(appShellComposable, /2024-07-06|2024-07-12/)
assert.doesNotMatch(legacyAppScript, /2024-07-06|2024-07-12/)
})

View File

@@ -7,10 +7,23 @@ const detailTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
const detailEntryScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const detailSetupScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSetup.js', import.meta.url)),
'utf8'
)
const detailApprovalFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailApprovalFlow.js', import.meta.url)),
'utf8'
)
const detailRiskSubmitScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailRiskSubmit.js', import.meta.url)),
'utf8'
)
const detailScript = [detailSetupScript, detailApprovalFlowScript, detailRiskSubmitScript].join('\n')
const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
@@ -60,7 +73,12 @@ function extractFunction(source, name) {
}
test('approval-mode detail collects leader opinion inside confirm dialog before API call', () => {
assert.match(detailScript, /approvalMode:/)
assert.match(detailEntryScript, /import \{ useTravelRequestDetailSetup \} from '\.\/travelRequestDetailSetup\.js'/)
assert.match(detailEntryScript, /setup: useTravelRequestDetailSetup/)
assert.match(detailSetupScript, /import \{ useTravelRequestDetailApprovalFlow \} from '\.\/useTravelRequestDetailApprovalFlow\.js'/)
assert.match(detailSetupScript, /useTravelRequestDetailApprovalFlow\(\{/)
assert.match(detailSetupScript, /approvalRiskConfirmItems: riskSubmit\.approvalRiskConfirmItems/)
assert.match(detailEntryScript, /approvalMode:/)
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const approvalRiskConfirmed = ref\(false\)/)

View File

@@ -15,6 +15,10 @@ const responsiveStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
'utf8'
)
const applicationFactsStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
@@ -25,8 +29,8 @@ test('detail hero facts keep document number and date on one row on laptop scree
assert.match(detailStyles, /\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(240px,\s*1\.25fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(280px,\s*1\.4fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact strong \{[\s\S]*white-space:\s*nowrap/)
assert.match(detailStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
assert.match(applicationFactsStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
assert.match(applicationFactsStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
})

View File

@@ -127,6 +127,14 @@ const relatedApplicationCardTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestRelatedApplicationCard.vue', import.meta.url)),
'utf8'
)
const applicationFactsTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApplicationFacts.vue', import.meta.url)),
'utf8'
)
const applicationFactsStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
'utf8'
)
const progressCardTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
'utf8'
@@ -1583,10 +1591,12 @@ test('application detail uses application labels instead of reimbursement labels
assert.match(progressCardTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
assert.match(detailViewTemplate, /class="application-detail-facts"/)
assert.match(detailViewTemplate, /applicationDetailFactItems/)
assert.match(detailViewTemplate, /<TravelRequestApplicationFacts/)
assert.match(detailViewTemplate, /:fact-items="applicationDetailFactItems"/)
assert.match(applicationFactsTemplate, /class="application-detail-facts"/)
assert.match(applicationFactsTemplate, /v-for="item in factItems"/)
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
assert.match(applicationFactsStyle, /\.application-detail-fact\.highlight strong/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
@@ -1595,10 +1605,13 @@ test('application detail uses application labels instead of reimbursement labels
test('draft or returned application detail edits allowed facts inline', () => {
assert.doesNotMatch(detailViewTemplate, /修改申请/)
assert.match(detailViewTemplate, /canEditApplicationDetailItem\(item\)/)
assert.match(detailViewTemplate, /application-detail-edit-btn/)
assert.match(detailViewTemplate, /openApplicationDetailEditor\(item\)/)
assert.match(detailViewTemplate, /saveApplicationDetailEdit\(item\)/)
assert.match(detailViewTemplate, /:can-edit-item="canEditApplicationDetailItem"/)
assert.match(detailViewTemplate, /@open="openApplicationDetailEditor"/)
assert.match(detailViewTemplate, /@save="saveApplicationDetailEdit"/)
assert.match(applicationFactsTemplate, /canEditItem\(item\)/)
assert.match(applicationFactsTemplate, /application-detail-edit-btn/)
assert.match(applicationFactsTemplate, /emit\('open', item\)/)
assert.match(applicationFactsTemplate, /emit\('save', item\)/)
assert.doesNotMatch(detailViewScript, /handleModifyApplication/)
assert.match(
detailViewScript,

View File

@@ -16,7 +16,10 @@ const composerComponent = readSource('../src/components/business/workbench-ai/Wo
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue')
const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css')
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const aiModeRuntime = [
readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
readSource('../src/composables/workbenchAiMode/useWorkbenchAiConversationRuntime.js')
].join('\n')
const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
function countOccurrences(source, pattern) {

View File

@@ -20,10 +20,10 @@ import {
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { createWorkbenchAiMessageRuntime } from '../src/composables/workbenchAiMode/workbenchAiMessageModel.js'
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const personalWorkbenchAiModeScript = [
'../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js',
'../src/composables/workbenchAiMode/useWorkbenchAiIntentExecution.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const stewardFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
'utf8'

View File

@@ -1,15 +1,28 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js'
import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js'
import { normalizeInlineApplicationStatusLabel } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
const aiMode = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
'utf8'
)
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const applicationPreviewFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js')
const applicationPreviewModel = readSource('../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js')
const aiMode = [
readSource('../src/components/business/PersonalWorkbenchAiMode.vue'),
readSource('../src/components/business/PersonalWorkbenchAiMode.template.html'),
readSource('../src/utils/aiDocumentQueryModel.js'),
...readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readSource(`../src/composables/workbenchAiMode/${file}`))
].join('\n')
test('expense scene selection message asks for type first and mentions application gate', () => {
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
@@ -32,10 +45,10 @@ test('expense scene actions mark travel and meal as requiring application', () =
assert.equal(transport.payload.next_session_type, 'expense')
})
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
test('AI mode quick reimbursement card enters the reimbursement association gate before steward plan', () => {
assert.match(
aiMode,
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
/function runAiModeAction\(item\) {[\s\S]{0,300}expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/
)
})
@@ -53,27 +66,28 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
assert.match(aiMode, /applicationPreview:\s*preview/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(registeredPreview\)/)
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
})
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
test('AI mode steward reimbursement action enters the association gate while standalone flow keeps scene selection', () => {
assert.match(aiMode, /buildExpenseSceneSelectionMessage/)
assert.match(aiMode, /buildExpenseSceneSelectionActions/)
assert.match(aiMode, /SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/)
assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/)
assert.match(aiMode, /action\?\.payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)/)
assert.match(
aiMode,
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
/SESSION_TYPE_EXPENSE && carryText === '我要报销'[\s\S]{0,160}expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)[\s\S]{0,40}return/
)
assert.match(aiMode, /SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]{0,180}pushInlineExpenseSceneSelectionPrompt/)
})
test('AI mode attaches required application lookup result before steward planning', () => {
assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/)
assert.match(aiMode, /fetchExpenseClaims\(\)/)
assert.match(aiMode, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
assert.match(aiMode, /required_application_gate/)
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
@@ -95,7 +109,8 @@ test('AI mode handles document query prompts locally before steward planning', (
assert.match(aiMode, /查询业务单据接口/)
assert.match(aiMode, /组合筛选单据/)
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable\(detailRequest\)/)
assert.match(aiMode, /emit\('open-document', detailRequest\)/)
})
test('AI mode asks for manual confirmation before generating application preview table', () => {
@@ -114,8 +129,8 @@ test('AI mode asks for manual confirmation before generating application preview
})
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
const startPreviewFunction = aiMode.match(
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
const startPreviewFunction = applicationPreviewFlow.match(
/async function startAiApplicationPreview[\s\S]*?\n }\n\n return \{/
)?.[0] || ''
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
@@ -142,8 +157,8 @@ test('AI mode handles application preview save and submit through buttons or tex
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
assert.match(aiMode, /if \(applicationFlow\.handleInlineApplicationPreviewTextAction\(cleanPrompt, applicationPreviewEstimatePending\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
@@ -155,22 +170,23 @@ test('AI mode handles application preview save and submit through buttons or tex
test('AI mode keeps missing application fields editable in the preview table without quick template action', () => {
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
assert.match(aiMode, /label:\s*'保存草稿'/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
assert.doesNotMatch(aiMode, /label:\s*'快速模板'/)
assert.doesNotMatch(aiMode, /action_type:\s*'prefill_composer'/)
assert.doesNotMatch(aiMode, /buildInlineApplicationPreviewTemplatePrefill/)
assert.doesNotMatch(aiMode, /applyInlineApplicationPreviewTemplateText/)
})
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
test('AI mode confirms a new direct submit before adding the action while allowing contextual saved-draft submit', () => {
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly)')
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
assert.match(executeBlock, /const shouldSubmitSavedDraftDirectly = isSubmit/)
assert.ok(confirmGateIndex >= 0, '非上下文草稿提交应先进入确认分支')
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
assert.match(
@@ -178,22 +194,22 @@ test('AI mode waits for submit confirmation before adding submit action to the c
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
)
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
const confirmStart = applicationPreviewFlow.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = applicationPreviewFlow.indexOf('\n async function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = applicationPreviewFlow.slice(confirmStart, confirmEnd)
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
assert.match(confirmBlock, /skipUserMessage:\s*false/)
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
const cancelStart = applicationPreviewFlow.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = applicationPreviewFlow.indexOf('\n function confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = applicationPreviewFlow.slice(cancelStart, cancelEnd)
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
})
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
assert.match(aiMode, /submitted:\s*'审批中'/)
assert.equal(normalizeInlineApplicationStatusLabel('submitted'), '审批中')
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
@@ -204,9 +220,9 @@ test('AI mode formats saved application draft as a detail table without continui
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
const resultBlock = aiMode.slice(resultStart, resultEnd)
const resultStart = applicationPreviewModel.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = applicationPreviewModel.indexOf('\nexport function buildInlineApplicationDetailAction', resultStart)
const resultBlock = applicationPreviewModel.slice(resultStart, resultEnd)
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
const saveBranch = resultBlock.slice(saveBranchIndex)
@@ -219,15 +235,16 @@ test('AI mode formats saved application draft as a detail table without continui
)
assert.doesNotMatch(saveBranch, /进入审批流程/)
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
assert.doesNotMatch(
executeBlock,
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
)
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
assert.match(executeBlock, /const detailActions = buildInlineApplicationDetailAction\(draftPayload\)/)
assert.match(executeBlock, /suggestedActions:\s*shouldAutoContinueNextTask[\s\S]*detailActions/)
})
test('AI mode locks application preview actions while estimate refresh is pending', () => {
@@ -236,13 +253,13 @@ test('AI mode locks application preview actions while estimate refresh is pendin
aiMode,
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
assert.match(
aiMode,
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
/message\.suggestedActions = \[\][\s\S]*const committed = await commitBaseApplicationPreviewEditor\(message\)/
)
assert.match(
aiMode,

View File

@@ -28,128 +28,130 @@ function countGifFrameBlocks(buffer) {
return count
}
function readAssetFrames(assetPath, options = {}) {
const metadata = execFileSync('identify', ['-format', '%w %h %n\n', assetPath], {
encoding: 'utf8'
}).trim().split('\n')[0].split(/\s+/).map(Number)
const sourceWidth = metadata[0]
const sourceHeight = metadata[1]
const frameCount = metadata[2] || 1
const width = options.width || sourceWidth
const height = options.height || sourceHeight
const convertArgs = [assetPath, '-coalesce']
if (options.width || options.height) {
convertArgs.push('-resize', `${width}x${height}!`)
}
convertArgs.push('-alpha', 'off', '-depth', '8', 'rgb:-')
const pixels = execFileSync('convert', convertArgs, {
maxBuffer: 64 * 1024 * 1024
})
const frameSize = width * height * 3
return {
frameCount: Math.min(frameCount, Math.floor(pixels.length / frameSize)),
frameSize,
height,
pixels,
width
}
}
function measureGifMotion(assetPath) {
const script = `
from PIL import Image, ImageSequence
import json
import sys
image = Image.open(sys.argv[1])
frames = [frame.convert("RGB").resize((64, 64)) for frame in ImageSequence.Iterator(image)]
def delta(left, right):
left_pixels = left.load()
right_pixels = right.load()
total = 0
for y in range(64):
for x in range(64):
a = left_pixels[x, y]
b = right_pixels[x, y]
total += abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])
return total / (64 * 64 * 3)
adjacent = [delta(frames[index], frames[index + 1]) for index in range(len(frames) - 1)]
adjacent_sorted = sorted(adjacent)
median = adjacent_sorted[len(adjacent_sorted) // 2]
print(json.dumps({
"medianAdjacentDelta": median,
"seamDelta": delta(frames[-1], frames[0])
}))
`
return JSON.parse(execFileSync('python3', ['-', assetPath], {
encoding: 'utf8',
input: script
}).trim())
const { frameCount, frameSize, pixels } = readAssetFrames(assetPath, { width: 64, height: 64 })
const delta = (leftFrame, rightFrame) => {
const leftOffset = leftFrame * frameSize
const rightOffset = rightFrame * frameSize
let total = 0
for (let index = 0; index < frameSize; index += 1) {
total += Math.abs(pixels[leftOffset + index] - pixels[rightOffset + index])
}
return total / frameSize
}
const adjacent = Array.from(
{ length: Math.max(0, frameCount - 1) },
(_, index) => delta(index, index + 1)
).sort((left, right) => left - right)
return {
medianAdjacentDelta: adjacent[Math.floor(adjacent.length / 2)] || 0,
seamDelta: delta(frameCount - 1, 0)
}
}
function measureGifDuration(assetPath) {
const script = `
from PIL import Image
import sys
image = Image.open(sys.argv[1])
total = 0
for index in range(getattr(image, "n_frames", 1)):
image.seek(index)
total += image.info.get("duration", 0)
print(total)
`
return Number(execFileSync('python3', ['-', assetPath], {
encoding: 'utf8',
input: script
}).trim())
const delays = execFileSync('identify', ['-format', '%T\n', assetPath], {
encoding: 'utf8'
}).trim().split('\n').map(Number)
return delays.reduce((total, delay) => total + delay * 10, 0)
}
function measureOrbAssetPresentation(assetPath) {
const script = `
from PIL import Image
import json
import sys
const { frameCount, frameSize, height, pixels, width } = readAssetFrames(assetPath)
let minimumCornerLuma = 255
let maximumCornerLuma = 0
let minimumBackgroundSimilarityRatio = 1
let minimumForegroundWidthRatio = 1
let minimumForegroundHeightRatio = 1
image = Image.open(sys.argv[1])
frame_count = getattr(image, "n_frames", 1)
width, height = image.size
minimum_corner_luma = 255
maximum_corner_luma = 0
minimum_background_similarity_ratio = 1
minimum_foreground_width_ratio = 1
minimum_foreground_height_ratio = 1
for index in range(frame_count):
if frame_count > 1:
image.seek(index)
rgb = image.convert("RGB")
corners = [
rgb.getpixel((0, 0)),
rgb.getpixel((width - 1, 0)),
rgb.getpixel((0, height - 1)),
rgb.getpixel((width - 1, height - 1)),
for (let frame = 0; frame < frameCount; frame += 1) {
const frameOffset = frame * frameSize
const pixelAt = (x, y) => {
const offset = frameOffset + (y * width + x) * 3
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]]
}
const corners = [
pixelAt(0, 0),
pixelAt(width - 1, 0),
pixelAt(0, height - 1),
pixelAt(width - 1, height - 1)
]
corner_lumas = [sum(pixel) / 3 for pixel in corners]
minimum_corner_luma = min(minimum_corner_luma, min(corner_lumas))
maximum_corner_luma = max(maximum_corner_luma, max(corner_lumas))
background = tuple(round(sum(pixel[channel] for pixel in corners) / len(corners)) for channel in range(3))
foreground_mask = Image.new("L", (width, height), 0)
foreground_pixels = foreground_mask.load()
background_similarity = 0
rgb_pixels = rgb.load()
for y in range(height):
for x in range(width):
pixel = rgb_pixels[x, y]
diff = sum(abs(pixel[channel] - background[channel]) for channel in range(3))
if diff > 22:
foreground_pixels[x, y] = 255
if diff <= 12:
background_similarity += 1
foreground_box = foreground_mask.getbbox()
if foreground_box:
minimum_foreground_width_ratio = min(
minimum_foreground_width_ratio,
(foreground_box[2] - foreground_box[0]) / width
)
minimum_foreground_height_ratio = min(
minimum_foreground_height_ratio,
(foreground_box[3] - foreground_box[1]) / height
)
minimum_background_similarity_ratio = min(
minimum_background_similarity_ratio,
background_similarity / (width * height)
)
const cornerLumas = corners.map((pixel) => (pixel[0] + pixel[1] + pixel[2]) / 3)
minimumCornerLuma = Math.min(minimumCornerLuma, ...cornerLumas)
maximumCornerLuma = Math.max(maximumCornerLuma, ...cornerLumas)
const background = [0, 1, 2].map((channel) => Math.round(
corners.reduce((total, pixel) => total + pixel[channel], 0) / corners.length
))
let backgroundSimilarity = 0
let minX = width
let minY = height
let maxX = -1
let maxY = -1
print(json.dumps({
"minimumCornerLuma": minimum_corner_luma,
"maximumCornerLuma": maximum_corner_luma,
"minimumBackgroundSimilarityRatio": minimum_background_similarity_ratio,
"minimumForegroundWidthRatio": minimum_foreground_width_ratio,
"minimumForegroundHeightRatio": minimum_foreground_height_ratio,
"width": width,
"height": height
}))
`
return JSON.parse(execFileSync('python3', ['-', assetPath], {
encoding: 'utf8',
input: script
}).trim())
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixel = pixelAt(x, y)
const diff = pixel.reduce(
(total, value, channel) => total + Math.abs(value - background[channel]),
0
)
if (diff > 22) {
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
if (diff <= 12) {
backgroundSimilarity += 1
}
}
}
if (maxX >= minX && maxY >= minY) {
minimumForegroundWidthRatio = Math.min(minimumForegroundWidthRatio, (maxX - minX + 1) / width)
minimumForegroundHeightRatio = Math.min(minimumForegroundHeightRatio, (maxY - minY + 1) / height)
}
minimumBackgroundSimilarityRatio = Math.min(
minimumBackgroundSimilarityRatio,
backgroundSimilarity / (width * height)
)
}
return {
minimumCornerLuma,
maximumCornerLuma,
minimumBackgroundSimilarityRatio,
minimumForegroundWidthRatio,
minimumForegroundHeightRatio,
width,
height
}
}
const appShell = readSource('../src/views/AppShellRouteView.vue')
@@ -230,7 +232,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /费用测算中,请稍等/)
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /<article[\s\S]{0,140}v-for="file in runtime\.selectedFileCards"[\s\S]{0,140}class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
@@ -239,7 +241,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
assert.match(aiModeSurface, /const selectedFileCards = computed/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file, previewAsset\)/)
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
@@ -345,7 +347,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*url\("\.\.\/\.\.\/ai-document-card-bg\.png"\);/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*background-color:\s*#ffffff;/)
assert.doesNotMatch(aiModeStyles, /ai-document-card-bg\.png/)
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)::before/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: var\(--ai-document-card-head-bg\);/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/)
@@ -422,7 +425,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-mode\s*\{[\s\S]*min-height:\s*100%;[\s\S]*background:/)
assert.match(aiModeStyles, /\.workbench-ai-mode\.has-conversation\s*\{[\s\S]*place-items:\s*stretch;[\s\S]*padding:\s*0;/)
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*border-radius:\s*20px;[\s\S]*box-shadow:/)
assert.match(fileStripRule, /flex-wrap:\s*wrap;/)
assert.match(fileStripRule, /flex-wrap:\s*nowrap;/)
assert.match(fileStripRule, /overflow-x:\s*auto;/)
assert.match(fileStripRule, /justify-content:\s*flex-start;/)
assert.match(fileCardRule, /grid-template-columns:\s*48px minmax\(0,\s*1fr\) 30px;/)
assert.match(fileCardRule, /border-radius:\s*16px;/)
@@ -579,7 +583,7 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files(?:, previousFiles = \[\])?\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)

View File

@@ -451,7 +451,7 @@ test('standalone reimbursement draft branch asks before creating a new draft', (
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match(
personalWorkbenchAiMode,
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
/import\s*\{\s*isReimbursementCreationIntent\s*\}\s*from '\.\/workbenchAiApplicationGateModel\.js'/
)
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)

View File

@@ -46,7 +46,8 @@ test('workbench document detail keeps workbench as the return target', () => {
assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/)
assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/)
assert.match(appShellComposable, /const detailReturnTarget = computed/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
assert.match(appShellComposable, /if \(detailReturnTarget\.value === 'workbench'\) return '返回首页'/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'value' \? '返回经营价值' : '返回单据中心'/)
assert.match(appShellComposable, /const returnTo = resolveDocumentDetailReturnTarget\(options\.returnTo\)/)
assert.match(appShellComposable, /nextQuery\.returnTo = returnTo/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
@@ -57,7 +58,7 @@ test('AI conversation document detail returns to the active conversation content
assert.match(aiDetailReference, /source:\s*'ai-conversation'/)
assert.match(aiDetailReference, /returnTo:\s*'conversation'/)
assert.match(appShell, /@back-to-requests="handleDocumentDetailBack"/)
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation'\]\)/)
assert.match(appShell, /const DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/)
assert.match(
appShell,
/function handleDocumentDetailBack\(\) \{[\s\S]*detailReturnTarget\.value === 'conversation'[\s\S]*dispatchAiSidebarCommand\('open-recent', activeConversation\)/