| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { existsSync, readFileSync } from 'node:fs'
- import * as Vue from 'vue'
- import { parse } from '@vue/compiler-sfc'
- import ts from 'typescript'
- import * as card from '../src/utils/mp-crm-quote-card'
- import { buildQuoteColumnFilterDefs } from '../src/views/crm/quote/columnFilterDefs'
- async function model() {
- assert.ok(
- existsSync(new URL('../src/views/m/crm/quote/listState.ts', import.meta.url)),
- 'mobile quote query model must exist'
- )
- return import('../src/views/m/crm/quote/listState')
- }
- test('all/submitted/accepted use the Web implicit scopes and explicit status overrides in both modes', async () => {
- const m = await model()
- for (const mode of ['manage', 'approve'] as const)
- for (const view of ['all', 'submitted', 'accepted'] as const) {
- const base = {
- mode,
- view,
- keyword: ' 搜索 ',
- currentUserId: 'u1',
- pageNum: 2,
- pageSize: 20,
- visible: m.quoteColumnDefs.map((d) => d.prop)
- }
- const q = m.buildMobileQuoteQuery({
- ...base,
- query: { quoteNo: '旧', quoteTitle: '旧', customerName: '旧', quoteNoIn: 'Q1' }
- })
- assert.equal(q.excludeStatus, 'invalid')
- assert.equal(q.keyword, '搜索')
- assert.equal(q.quoteTitle, undefined)
- assert.equal(q.quoteNoIn, 'Q1')
- assert.equal(q.status, view === 'submitted' ? 'submitted' : undefined)
- assert.equal(q.pendingApprovalUserId, view === 'submitted' ? 'u1' : undefined)
- assert.equal(q.approvedByUserId, view === 'accepted' ? 'u1' : undefined)
- const explicit = m.buildMobileQuoteQuery({ ...base, query: { statusIn: 'invalid,approved' } })
- assert.equal(explicit.excludeStatus, undefined)
- assert.equal(explicit.status, undefined)
- assert.equal(explicit.statusIn, 'invalid,approved')
- }
- })
- test('filter serialization maps dates, enum and numbers and rejects reversed ranges; hidden filters and sorts are removed', async () => {
- const m = await model()
- const defs = buildQuoteColumnFilterDefs(() => [])
- const draft = m.createQuoteFilterDraft(
- {
- quoteNoIn: 'Q1,Q2',
- quoteDateBegin: '2026-01-01',
- beginTime: '2026-02-01',
- finalAmountOp: 'gte',
- finalAmount: '0'
- },
- defs
- )
- assert.deepEqual(m.quoteFilterQuery(draft, defs), {
- quoteNoIn: 'Q1,Q2',
- quoteDateBegin: '2026-01-01',
- beginTime: '2026-02-01',
- finalAmountOp: 'gte',
- finalAmount: '0'
- })
- draft.finalAmount = { op: 'between', min: '5', max: '2', value: '' }
- assert.throws(() => m.quoteFilterQuery(draft, defs), /最小值不能大于最大值/)
- draft.finalAmount = { op: 'between', min: '', max: '', value: '' }
- draft.quoteDate = { from: '2026-03-01', to: '2026-02-01' }
- assert.throws(() => m.quoteFilterQuery(draft, defs), /开始日期不能晚于结束日期/)
- assert.deepEqual(
- m.cleanQuoteQuery(
- {
- quoteNoIn: 'Q',
- quoteTitle: 'T',
- statusIn: 'invalid',
- beginTime: '2026',
- sortField: 'createTime',
- sortOrder: 'desc'
- },
- []
- ),
- { quoteNoIn: 'Q', quoteTitle: 'T' }
- )
- })
- async function harness(
- page: string,
- props: any = { mode: 'manage' },
- overrides: Record<string, any> = {}
- ) {
- const file = new URL(`../src/views/m/crm/quote/${page}.vue`, import.meta.url)
- assert.ok(existsSync(file), `${page} must exist`)
- const source = readFileSync(file, 'utf8')
- const ast = ts.createSourceFile(
- 'page.ts',
- parse(source).descriptor.scriptSetup!.content,
- ts.ScriptTarget.Latest,
- true
- )
- const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
- const names = statements.flatMap((s) =>
- ts.isFunctionDeclaration(s) && s.name
- ? [s.name.text]
- : ts.isVariableStatement(s)
- ? s.declarationList.declarations.flatMap((d) =>
- ts.isIdentifier(d.name)
- ? [d.name.text]
- : ts.isObjectBindingPattern(d.name)
- ? d.name.elements.map((e) => e.name.getText(ast))
- : []
- )
- : []
- )
- const bindings: Record<string, any> = { ...Vue }
- for (const s of ast.statements)
- if (ts.isImportDeclaration(s)) {
- if (s.importClause?.name) bindings[s.importClause.name.text] = () => {}
- const n = s.importClause?.namedBindings
- if (n && ts.isNamedImports(n)) for (const e of n.elements) bindings[e.name.text] = () => {}
- }
- const requests: any[] = [],
- events: any[] = [],
- pushes: any[] = [],
- facets: any[] = []
- const user = Vue.reactive({ accessToken: 'token', info: { id: 'u1' }, getUserInfo: { id: 'u1' } })
- Object.assign(
- bindings,
- card,
- await model(),
- {
- buildQuoteColumnFilterDefs,
- defineProps: () => props,
- defineEmits:
- () =>
- (...args: any[]) =>
- events.push(args),
- useRouter: () => ({ push: (v: any) => pushes.push(v) }),
- useUserStore: () => user,
- useDictStore: () => ({
- getOptions: () => [{ value: 'invalid', label: '作废' }],
- getLabel: (_: any, value: any) => value,
- getTagType: () => 'info'
- }),
- useListReturnState: () => ({ restoring: Vue.ref(false), restore: async () => {} }),
- useIntersectionObserver: () => {},
- useDebounceFn: (fn: any) => fn,
- listQuote: async (q: any) => {
- requests.push(q)
- return { rows: [], total: 0 }
- },
- fetchQuoteColumnFacetValues: async (q: any) => {
- facets.push(q)
- return {
- list: [
- { value: 'A', label: '甲' },
- { value: 'B', label: '乙' }
- ]
- }
- },
- HttpError: class extends Error {},
- ApiStatus: { forbidden: 403 },
- onMounted: () => {},
- onBeforeUnmount: () => {}
- },
- overrides
- )
- for (const key of Object.keys(bindings))
- if (!/^[a-zA-Z_$][\w$]*$/.test(key) || key === 'default') delete bindings[key]
- const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
- target: ts.ScriptTarget.ESNext
- })
- const scope = Vue.effectScope()
- const api = scope.run(() =>
- new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)(
- ...Object.values(bindings)
- )
- )
- return { api, requests, events, pushes, facets, user, source, stop: () => scope.stop() }
- }
- test('both list modes use fixed Web defaults regardless of old mobile preferences', async () => {
- const m = await model()
- Object.assign(globalThis, {
- localStorage: {
- getItem: () => '[]',
- setItem: () => {
- throw new Error('must not persist columns')
- }
- }
- })
- try {
- for (const mode of ['manage', 'approve']) {
- const h = await harness('MpQuoteList', { mode })
- try {
- assert.deepEqual([...h.api.visibleColumns.value], m.defaultQuoteColumns)
- assert.equal(h.api.visibleColumns.value.length, 11)
- assert.equal(h.api.selecting, undefined)
- assert.equal(h.api.columnsOpen, undefined)
- h.api.applyFilters({ statusIn: 'invalid', contactEmail: 'hidden', quoteTitle: '主题' })
- assert.deepEqual(h.api.query.value, { statusIn: 'invalid', quoteTitle: '主题' })
- h.api.quotes.value = [{ id: '1' }]
- h.user.accessToken = 'other'
- assert.deepEqual(h.api.quotes.value, [])
- } finally {
- h.stop()
- }
- }
- } finally {
- delete (globalThis as any).localStorage
- }
- })
- test('failed requests provide a retryable error and a successful retry clears it without skipping page', async () => {
- let failing = true
- const requested: any[] = []
- const h = await harness(
- 'MpQuoteList',
- { mode: 'manage' },
- {
- listQuote: async (q: any) => {
- requested.push(q)
- if (failing) throw new Error('离线')
- return { rows: [{ id: '1' }], total: 2 }
- }
- }
- )
- try {
- await h.api.fetchPage(true)
- assert.equal(h.api.errorText.value, '离线')
- failing = false
- await h.api.fetchPage(true)
- assert.equal(h.api.errorText.value, '')
- assert.equal(h.api.quotes.value.length, 1)
- failing = true
- await h.api.fetchPage(false)
- failing = false
- await h.api.fetchPage(false)
- assert.deepEqual(
- requested.map((q) => q.pageNum),
- [1, 1, 2, 2]
- )
- } finally {
- h.stop()
- }
- })
- test('quote facet excludes itself and page context, searches locally, and closing pending load permits retry', async () => {
- const props = Vue.reactive({
- modelValue: true,
- query: { customerNameIn: 'A' },
- context: { keyword: '酸', customerNameIn: 'A', pendingApprovalUserId: 'u1', pageNum: 2 },
- visibleColumns: ['customerName']
- })
- let resolve!: (value: any) => void
- let calls = 0
- const h = await harness('QuoteFilters', props, {
- fetchQuoteColumnFacetValues: (q: any) => {
- calls++
- h.facets.push(q)
- return calls === 1
- ? new Promise((r) => {
- resolve = r
- })
- : Promise.resolve({ list: [{ value: 'B', label: '乙' }] })
- }
- })
- try {
- const first = h.api.loadFacet('customerName')
- props.modelValue = false
- await Vue.nextTick()
- assert.equal(h.api.facetLoading.customerName, false)
- props.modelValue = true
- await Vue.nextTick()
- await h.api.loadFacet('customerName')
- resolve({ list: [{ value: 'old', label: '旧' }] })
- await first
- assert.deepEqual(h.facets.at(-1), {
- keyword: '酸',
- pendingApprovalUserId: 'u1',
- field: 'customerName',
- limit: 500
- })
- h.api.searches.customerName = '乙'
- assert.deepEqual(
- h.api.optionsFor('customerName').map((o: any) => o.value),
- ['B']
- )
- assert.deepEqual(h.api.draft.value.customerName, ['A'])
- } finally {
- h.stop()
- }
- })
- test('tab filters are independent and single-record copy retains its route', async () => {
- const h = await harness('MpQuoteList')
- try {
- h.api.keyword.value = '全部关键词'
- h.api.query.value = { quoteNoIn: 'Q' }
- h.api.view.value = 'submitted'
- h.api.onViewChange('submitted')
- assert.equal(h.api.keyword.value, '')
- h.api.query.value = { customerNameIn: '客户' }
- h.api.view.value = 'all'
- h.api.onViewChange('all')
- assert.equal(h.api.keyword.value, '全部关键词')
- assert.deepEqual(h.api.query.value, { quoteNoIn: 'Q' })
- h.api.openDetailAction('1', 'copy')
- assert.deepEqual(h.pushes[0], {
- path: '/m/crm/quote/1',
- query: { from: 'manage', action: 'copy' }
- })
- } finally {
- h.stop()
- }
- })
- test('actual list and filter SFC retain default fields without removed list tools', async () => {
- const { compileScript, compileTemplate } = await import('@vue/compiler-sfc')
- const { renderToString } = await import('vue/server-renderer')
- const h = await harness('MpQuoteList')
- try {
- h.api.quotes.value = [
- {
- id: '1',
- quoteTitle: '固定主题(固定单号)',
- quoteNo: '固定单号',
- status: '唯一状态',
- createDeptName: '唯一部门',
- updateByName: '唯一更新人'
- }
- ]
- h.api.loading.value = false
- const descriptor = parse(h.source).descriptor
- compileScript(descriptor, { id: 'quote-list' })
- const template = compileTemplate({
- id: 'quote-list',
- filename: 'MpQuoteList.vue',
- source: descriptor.template!.content
- })
- assert.deepEqual(template.errors, [])
- const exports: any = {}
- const stub = {
- setup:
- (_: any, { slots }: any) =>
- () =>
- Vue.h(
- 'div',
- Object.values(slots).flatMap((slot: any) => slot())
- )
- }
- new Function(
- 'require',
- 'exports',
- ts.transpile(template.code, {
- module: ts.ModuleKind.CommonJS,
- target: ts.ScriptTarget.ESNext
- })
- )(() => ({ ...Vue, resolveComponent: () => stub }), exports)
- const render = () =>
- renderToString(
- Vue.createSSRApp({ setup: () => () => exports.render(Vue.proxyRefs(h.api), []) })
- )
- const shown = await render()
- assert.match(shown, /class="mp-wv-card__no">固定主题<\/div>/)
- assert.match(shown, /class="mp-quote-list-card__number">固定单号<\/span>/)
- assert.equal(h.api.quotes.value[0].quoteTitle, '固定主题(固定单号)')
- for (const value of ['固定主题', '固定单号', '唯一状态']) assert.ok(shown.includes(value))
- for (const value of [
- '唯一部门',
- '唯一更新人',
- '刷新',
- '清空筛选',
- '字段设置',
- '列统计',
- '多选',
- '选择已加载'
- ])
- assert.ok(!shown.includes(value), value)
- const filters = parse(
- readFileSync(new URL('../src/views/m/crm/quote/QuoteFilters.vue', import.meta.url), 'utf8')
- ).descriptor
- const script = compileScript(filters, { id: 'quote-filters' })
- assert.deepEqual(
- compileTemplate({
- id: 'quote-filters',
- filename: 'QuoteFilters.vue',
- source: filters.template!.content,
- compilerOptions: { bindingMetadata: script.bindings }
- }).errors,
- []
- )
- } finally {
- h.stop()
- }
- })
- test('approval list requires explicit backend permission before displaying approve and reject actions', async () => {
- const h = await harness('MpQuoteList', { mode: 'approve' })
- try {
- for (const flag of [false, undefined, true]) {
- h.api.quotes.value = [{ id: '1', status: 'submitted', canApprove: flag }]
- const actions = h.api.cardActions(h.api.cards.value[0]).map((a: any) => a.key)
- assert.equal(actions.includes('approve'), flag === true)
- assert.equal(actions.includes('reject'), flag === true)
- assert.ok(actions.includes('view'))
- }
- } finally {
- h.stop()
- }
- })
- test('quote filter pickers preserve drafts on cancel and serialize confirmed enum, number and date values', async () => {
- const props = Vue.reactive({
- modelValue: true,
- query: { quoteTypeIn: 'sales' },
- context: {},
- visibleColumns: ['quoteType', 'finalAmount', 'quoteDate']
- })
- const h = await harness('QuoteFilters', props)
- try {
- h.api.openEnum('quoteType')
- h.api.toggleEnum('purchase')
- h.api.enumOpen.value = false
- assert.deepEqual(h.api.draft.value.quoteType, ['sales'])
- h.api.openEnum('quoteType')
- assert.deepEqual(h.api.enumDraft.value, ['sales'])
- h.api.toggleEnum('purchase')
- h.api.confirmEnum()
- assert.equal(h.api.enumSummary('quoteType'), '销售、采购')
- h.api.openSingle('numberOp', 'finalAmount')
- h.api.confirmSingle({ selectedValues: ['gte'] })
- h.api.draft.value.finalAmount.value = '100'
- h.api.openDate('quoteDate', 'from')
- h.api.confirmDate({ selectedValues: ['2026', '10', '24'] })
- h.api.apply()
- assert.deepEqual(h.events.find((e: any) => e[0] === 'apply')[1], {
- quoteTypeIn: 'sales,purchase',
- finalAmountOp: 'gte',
- finalAmount: '100',
- quoteDateBegin: '2026-10-24'
- })
- h.api.openEnum('quoteType')
- h.api.openSingle('sortField')
- h.api.openDate('quoteDate', 'to')
- props.modelValue = false
- await Vue.nextTick()
- assert.equal(h.api.enumOpen.value, false)
- assert.equal(h.api.singleOpen.value, false)
- assert.equal(h.api.dateOpen.value, false)
- } finally {
- h.stop()
- }
- })
|