mobile-customer-sublist-parity.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync, existsSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import * as vue from 'vue'
  7. import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
  8. import * as defs from '../src/views/crm/customer/dialogColumnFilterDefs'
  9. const flush = async () => {
  10. for (let i = 0; i < 20; i++) await Promise.resolve()
  11. }
  12. // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred.
  13. const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url)
  14. function evaluate(page: string, props: any, overrides: Record<string, any>, events: any[]) {
  15. const source = parse(
  16. readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8')
  17. ).descriptor.scriptSetup!.content
  18. const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true)
  19. const vueEntries = Object.entries(vue).filter(
  20. ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default'
  21. )
  22. const bindings: Record<string, any> = Object.fromEntries(vueEntries)
  23. delete bindings.default
  24. for (const statement of ast.statements) {
  25. if (!ts.isImportDeclaration(statement)) continue
  26. const clause = statement.importClause
  27. if (clause?.name) bindings[clause.name.text] = () => undefined
  28. if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
  29. for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
  30. }
  31. if (existsSync(helperPath)) {
  32. const helper = ts.transpile(
  33. readFileSync(helperPath, 'utf8').replace('export function', 'function'),
  34. { target: ts.ScriptTarget.ESNext }
  35. )
  36. bindings.useCustomerPanelIdentity = new Function(
  37. ...vueEntries.map(([k]) => k),
  38. `${helper};return useCustomerPanelIdentity`
  39. )(...vueEntries.map(([, v]) => v))
  40. }
  41. Object.assign(bindings, {
  42. useClientColumnFilters,
  43. ...defs,
  44. OPPORTUNITY_SOURCE_FALLBACK: {},
  45. OPPORTUNITY_TYPE_FALLBACK: {},
  46. defineProps: () => props,
  47. defineEmits:
  48. () =>
  49. (...args: any[]) =>
  50. events.push(args),
  51. defineExpose() {},
  52. useRouter: () => ({ push: async () => {} }),
  53. useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }),
  54. unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []),
  55. unwrapData: (v: any) => v?.data || v,
  56. canDeleteByCreator: () => true,
  57. contactFields: [{ key: 'contactName', required: true }],
  58. invoiceFields: [{ key: 'companyName' }],
  59. requiredFieldsError: () => '',
  60. contactDetailsError: () => '',
  61. percentageError: () => '',
  62. apiTime: (v: any) => v,
  63. opportunityProgress: () => ({}),
  64. showCrmConfirmDialog: async () => {},
  65. fetchContactByCustomerId: async () => [],
  66. fetchFollowByCustomerId: async () => [],
  67. getInvoiceInfoByCustomerId: async () => [],
  68. fetchContactDetail: async (v: any) => v,
  69. fetchUserOptions: async () => [],
  70. fetchCustomerOptions: async () => [],
  71. listOpportunity: async () => [],
  72. listContract: async () => [],
  73. listOperLog: async () => [],
  74. getOpportunity: async (id: string) => ({ id }),
  75. listFollowByOpportunity: async () => [],
  76. getStageLogList: async () => [],
  77. ...overrides
  78. })
  79. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  80. const names = statements.flatMap((s) =>
  81. ts.isFunctionDeclaration(s)
  82. ? [s.name!.text]
  83. : ts.isVariableStatement(s)
  84. ? s.declarationList.declarations.flatMap((d) =>
  85. ts.isObjectBindingPattern(d.name)
  86. ? d.name.elements.map((e) => e.name.getText(ast))
  87. : [d.name.getText(ast)]
  88. )
  89. : []
  90. )
  91. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  92. target: ts.ScriptTarget.ESNext
  93. })
  94. return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  95. ...Object.values(bindings)
  96. )
  97. }
  98. function mount(page: string, kind = 'contract', extraProps: any = {}, overrides: any = {}) {
  99. let api: any
  100. const props = vue.shallowReactive({ customerId: 'A', kind, ...extraProps })
  101. const renderer = vue.createRenderer<any, any>({
  102. patchProp() {},
  103. insert() {},
  104. remove() {},
  105. createElement: () => ({}),
  106. createText: () => ({}),
  107. createComment: () => ({}),
  108. setText() {},
  109. setElementText() {},
  110. parentNode: () => null,
  111. nextSibling: () => null
  112. })
  113. const app = renderer.createApp({
  114. setup() {
  115. api = evaluate(page, props, overrides, [])
  116. return () => vue.h('div')
  117. }
  118. })
  119. app.mount({})
  120. return { api, props, stop: () => app.unmount() }
  121. }
  122. test('联系人姓名手机号组合、枚举并集和快捷筛选共享状态', async () => {
  123. const { api, stop } = mount('ContactPanel')
  124. await flush()
  125. assert.ok(api.filterApi, '面板应使用 Web 客户端筛选 API')
  126. api.rows.value = [
  127. { id: '1', contactName: '张甲', mobile: '138', gender: 'male', role: 'a' },
  128. { id: '2', contactName: '张乙', mobile: '139', gender: 'female', role: 'b' },
  129. { id: '3', contactName: '李甲', mobile: '138', gender: 'male', role: 'c' }
  130. ]
  131. api.filterApi.getFilterState('contactName').text = '张'
  132. api.filterApi.getFilterState('mobile').text = '138'
  133. assert.deepEqual(
  134. api.filtered.value.map((r: any) => r.id),
  135. ['1']
  136. )
  137. api.resetMore()
  138. api.filterApi.getFilterState('role').selected = ['a', 'b']
  139. assert.equal(api.filtered.value.length, 2)
  140. api.roleFilter.value = 'c'
  141. assert.deepEqual(
  142. api.filtered.value.map((r: any) => r.id),
  143. ['3']
  144. )
  145. api.resetMore()
  146. assert.equal(api.roleFilter.value, '')
  147. assert.equal(api.filtered.value.length, 3)
  148. stop()
  149. })
  150. test('联系人快速搜索同时匹配角色显示名称和原始编码', async () => {
  151. const { api, stop } = mount('ContactPanel', 'contract', {}, {
  152. useDictStore: () => ({
  153. getOptions: () => [],
  154. getLabel: (_code: string, value: string) =>
  155. ({ decision: '决策人', manager: '管理者' })[value] || value || ''
  156. })
  157. })
  158. try {
  159. await flush()
  160. api.rows.value = [
  161. { id: '1', contactName: '张甲', role: 'decision' },
  162. { id: '2', contactName: '李乙', role: 'manager' },
  163. { id: '3', contactName: '王丙', role: 'legacy' }
  164. ]
  165. for (const [keyword, expected] of [
  166. ['决策人', ['1']],
  167. [' 策人 ', ['1']],
  168. ['DECISION', ['1']],
  169. ['管理', ['2']],
  170. ['legacy', ['3']],
  171. ['不存在角色', []],
  172. ['', ['1', '2', '3']]
  173. ] as Array<[string, string[]]>) {
  174. api.keyword.value = keyword
  175. assert.deepEqual(api.filtered.value.map((row: any) => row.id), expected, keyword)
  176. }
  177. } finally {
  178. stop()
  179. }
  180. })
  181. test('联系人角色名称搜索与列筛选取交集,清空不会丢失原筛选', async () => {
  182. const { api, stop } = mount('ContactPanel', 'contract', {}, {
  183. useDictStore: () => ({
  184. getOptions: () => [],
  185. getLabel: (_code: string, value: string) => value === 'decision' ? '决策人' : value
  186. })
  187. })
  188. try {
  189. await flush()
  190. api.rows.value = [
  191. { id: '1', role: 'decision', gender: 'male' },
  192. { id: '2', role: 'decision', gender: 'female' },
  193. { id: '3', role: 'manager', gender: 'female' }
  194. ]
  195. api.genderFilter.value = 'female'
  196. api.keyword.value = '决策人'
  197. assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['2'])
  198. api.roleFilter.value = 'manager'
  199. assert.deepEqual(api.filtered.value, [])
  200. api.keyword.value = ''
  201. assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['3'])
  202. } finally {
  203. stop()
  204. }
  205. })
  206. test('联系人角色字典后加载时重新计算名称搜索结果', async () => {
  207. const labels = vue.reactive<Record<string, string>>({})
  208. const { api, stop } = mount('ContactPanel', 'contract', {}, {
  209. useDictStore: () => ({
  210. getOptions: () => [],
  211. getLabel: (_code: string, value: string) => labels[value] || value || ''
  212. })
  213. })
  214. try {
  215. await flush()
  216. api.rows.value = [{ id: '1', role: 'decision' }]
  217. api.keyword.value = '决策人'
  218. assert.deepEqual(api.filtered.value, [])
  219. labels.decision = '决策人'
  220. assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['1'])
  221. labels.decision = '决策负责人'
  222. assert.deepEqual(api.filtered.value, [])
  223. api.keyword.value = '决策负责人'
  224. assert.deepEqual(api.filtered.value.map((row: any) => row.id), ['1'])
  225. } finally {
  226. stop()
  227. }
  228. })
  229. test('开票默认多选与单位税号组合即时筛选', async () => {
  230. const { api, stop } = mount('InvoicePanel')
  231. await flush()
  232. assert.ok(api.filterApi)
  233. api.rows.value = [
  234. { id: '1', companyName: '甲', taxNo: '123', isDefault: 1 },
  235. { id: '2', companyName: '甲', taxNo: '234', isDefault: 0 },
  236. { id: '3', companyName: '乙', taxNo: '', isDefault: 0 }
  237. ]
  238. api.filterApi.getFilterState('isDefault').selected = ['1', '0']
  239. assert.equal(api.filtered.value.length, 3)
  240. api.filterApi.getFilterState('companyName').text = '甲'
  241. api.filterApi.getFilterState('taxNo').text = '123'
  242. assert.deepEqual(
  243. api.filtered.value.map((r: any) => r.id),
  244. ['1']
  245. )
  246. api.resetMore()
  247. assert.equal(api.filtered.value.length, 3)
  248. stop()
  249. })
  250. for (const [kind, date, number] of [
  251. ['contract', 'signDate', 'amount'],
  252. ['log', 'operTime', 'costTime'],
  253. ['opportunity', 'expectDealDate', '']
  254. ])
  255. test(`${kind} 日期范围及数值比较与 Web 一致`, async () => {
  256. const { api, props, stop } = mount('RelatedPanel', kind)
  257. await flush()
  258. assert.ok(api.filterApi)
  259. api.rows.value = [10, 20, 30].map((value, i) => ({
  260. id: String(i),
  261. [date]: `2026-10-${21 + i} 23:59:59`,
  262. [number]: value,
  263. stage: String(i),
  264. status: String(i),
  265. contractType: String(i),
  266. businessType: String(i)
  267. }))
  268. Object.assign(api.filterApi.getFilterState(date), { from: '2026-10-22', to: '2026-10-22' })
  269. assert.deepEqual(
  270. api.filtered.value.map((r: any) => r.id),
  271. ['1']
  272. )
  273. api.resetMore()
  274. assert.equal(api.filtered.value.length, 3)
  275. if (number) {
  276. for (const [op, ids] of [
  277. ['eq', ['1']],
  278. ['gt', ['2']],
  279. ['gte', ['1', '2']],
  280. ['lt', ['0']],
  281. ['lte', ['0', '1']],
  282. ['between', ['1']]
  283. ]) {
  284. Object.assign(api.filterApi.getFilterState(number), {
  285. op,
  286. value: '20',
  287. min: '15',
  288. max: '25'
  289. })
  290. assert.deepEqual(
  291. api.filtered.value.map((r: any) => r.id),
  292. ids
  293. )
  294. }
  295. }
  296. api.resetMore()
  297. const prop = kind === 'contract' ? 'contractType' : kind === 'log' ? 'businessType' : 'stage'
  298. api.filterApi.getFilterState(prop).selected = ['0', '2']
  299. assert.deepEqual(
  300. api.filtered.value.map((r: any) => r.id),
  301. ['0', '2']
  302. )
  303. api.quick1.value = '1'
  304. assert.deepEqual(
  305. api.filtered.value.map((r: any) => r.id),
  306. ['1']
  307. )
  308. api.resetMore()
  309. api.rows.value = []
  310. assert.equal(api.filtered.value.length, 0, '刷新集合为空时不保留子组件缓存行')
  311. props.customerId = 'B'
  312. await vue.nextTick()
  313. assert.equal(api.filtered.value.length, 0)
  314. if (kind === 'opportunity') {
  315. assert.equal(api.listFields.value.filter((f: any) => f.key === 'createTime').length, 1)
  316. assert.equal(api.detailFields.value.filter((f: any) => f.key === 'createTime').length, 1)
  317. }
  318. stop()
  319. })
  320. test('移动筛选控件使用父级状态、枚举搜索/清空和日期/比较器事件即时生效', () => {
  321. const rows = vue.ref([
  322. { gender: 'male', amount: 10, signDate: '2026-10-22' },
  323. { gender: 'female', amount: 30, signDate: '2026-10-23' }
  324. ])
  325. const definitions = [
  326. ...defs.buildDetailContactColumnFilterDefs(() => []),
  327. ...defs.buildDetailContractColumnFilterDefs(() => [])
  328. ]
  329. const filterApi = useClientColumnFilters(rows, definitions)
  330. const { api, stop } = mount('LocalFilters', '', { filterApi, definitions, rows: rows.value })
  331. api.toggleEnum('gender', 'male')
  332. assert.equal(filterApi.filteredRows.value.length, 1)
  333. api.enumKeywords.gender = '女'
  334. assert.deepEqual(
  335. api.enumOptions(definitions.find((d: any) => d.prop === 'gender')).map((o: any) => o.value),
  336. ['female']
  337. )
  338. api.toggleEnum('gender', 'female')
  339. assert.equal(filterApi.filteredRows.value.length, 2)
  340. api.clearField('gender')
  341. assert.equal(filterApi.hasActiveFilters.value, false)
  342. api.openDate('signDate', 'from')
  343. api.dateParts.value = ['2026', '10', '23']
  344. api.onDateConfirm()
  345. assert.equal(filterApi.filteredRows.value.length, 1)
  346. api.clearField('signDate')
  347. assert.equal(filterApi.filteredRows.value.length, 2)
  348. api.openOp('amount')
  349. api.onOpConfirm({ selectedValues: ['gte'] })
  350. api.setRangeValue('amount', 'value', '30')
  351. assert.equal(filterApi.filteredRows.value.length, 1)
  352. api.clearAllFilters()
  353. assert.equal(filterApi.filteredRows.value.length, 2)
  354. stop()
  355. })
  356. for (const [page, kind, builder] of [
  357. ['ContactPanel', '', defs.buildDetailContactColumnFilterDefs],
  358. ['InvoicePanel', '', defs.buildInvoiceInfoColumnFilterDefs],
  359. ['RelatedPanel', 'opportunity', defs.buildDetailOpportunityColumnFilterDefs],
  360. ['RelatedPanel', 'contract', defs.buildDetailContractColumnFilterDefs],
  361. ['RelatedPanel', 'log', defs.buildDetailOperLogColumnFilterDefs]
  362. ] as const) {
  363. test(`${page}/${kind}: 每个 Web 字段均绑定真实筛选状态,刷新立即移除旧行`, async () => {
  364. const { api, stop } = mount(page, kind)
  365. await flush()
  366. for (const def of builder(() => [])) {
  367. api.resetMore()
  368. assert.equal(api.filterApi.getColumnDef(def.prop)?.type, def.type)
  369. api.rows.value = [
  370. {
  371. id: 'a',
  372. [def.prop]:
  373. def.type === 'datetime' ? '2026-10-22 00:00:00' : def.type === 'number' ? 10 : 'alpha'
  374. },
  375. {
  376. id: 'b',
  377. [def.prop]:
  378. def.type === 'datetime' ? '2026-10-23 23:59:59' : def.type === 'number' ? 20 : 'beta'
  379. }
  380. ]
  381. const state = api.filterApi.getFilterState(def.prop)
  382. if (def.type === 'text') state.text = 'alpha'
  383. else if (def.type === 'enum') state.selected = ['alpha']
  384. else if (def.type === 'datetime')
  385. Object.assign(state, { from: '2026-10-22', to: '2026-10-22' })
  386. else Object.assign(state, { op: 'eq', value: '10' })
  387. assert.deepEqual(
  388. api.filtered.value.map((r: any) => r.id),
  389. ['a'],
  390. def.prop
  391. )
  392. api.filterApi.clearFilter(def.prop)
  393. assert.equal(api.filtered.value.length, 2, `${def.prop} 清空`)
  394. }
  395. const pending = api.load()
  396. assert.equal(api.filtered.value.length, 0, '刷新请求未完成时已移除旧行')
  397. await pending
  398. assert.equal(api.filtered.value.length, 0)
  399. stop()
  400. })
  401. }
  402. // Execute the installed Vant option handler, including its same-value no-emit guard.
  403. function clickVantOption(modelValue: unknown, value: string, update: (value: string) => void) {
  404. const source = readFileSync(
  405. new URL('../node_modules/vant/es/dropdown-item/DropdownItem.mjs', import.meta.url),
  406. 'utf8'
  407. )
  408. const ast = ts.createSourceFile('DropdownItem.mjs', source, ts.ScriptTarget.Latest, true)
  409. let handler = ''
  410. function visit(node: ts.Node) {
  411. if (ts.isVariableDeclaration(node) && node.name.getText(ast) === 'onClick' && node.initializer)
  412. handler = node.initializer.getText(ast)
  413. ts.forEachChild(node, visit)
  414. }
  415. visit(ast)
  416. assert.ok(handler, '读取真实 Vant option onClick')
  417. const click = new Function('disabled', 'state', 'option', 'props', 'emit', `return (${handler})`)(
  418. false,
  419. { showPopup: true },
  420. { value },
  421. { modelValue },
  422. (event: string, next: string) => {
  423. if (event === 'update:modelValue') update(next)
  424. }
  425. )
  426. click()
  427. }
  428. for (const [page, kind, prop, quick] of [
  429. ['ContactPanel', '', 'role', 'roleFilter'],
  430. ['ContactPanel', '', 'gender', 'genderFilter'],
  431. ['InvoicePanel', '', 'isDefault', 'defaultFilter'],
  432. ['RelatedPanel', 'opportunity', 'stage', 'quick1'],
  433. ['RelatedPanel', 'opportunity', 'status', 'quick2'],
  434. ['RelatedPanel', 'contract', 'contractType', 'quick1'],
  435. ['RelatedPanel', 'contract', 'status', 'quick2'],
  436. ['RelatedPanel', 'log', 'businessType', 'quick1'],
  437. ['RelatedPanel', 'log', 'status', 'quick2']
  438. ])
  439. test(`${page}/${kind}/${prop}: 多选后点击 Vant 全部确实清空`, async () => {
  440. const { api, stop } = mount(page, kind)
  441. await flush()
  442. api.rows.value = ['a', 'b', 'c'].map((value) => ({ id: value, [prop]: value }))
  443. api.filterApi.getFilterState(prop).selected = ['a', 'b']
  444. assert.equal(api.filtered.value.length, 2)
  445. clickVantOption(api[quick].value, '', (value) => {
  446. api[quick].value = value
  447. })
  448. assert.equal(api.filtered.value.length, 3)
  449. assert.equal(api.filterApi.isFilterActive(prop), false)
  450. stop()
  451. })
  452. test('日期筛选年份可输入、窗口有界、旧年份可达且确认直接生效', async () => {
  453. const definitions = defs.buildDetailContractColumnFilterDefs(() => [])
  454. const rows = vue.ref([{ signDate: '1800-02-28' }, { signDate: '2400-02-29' }])
  455. const filterApi = useClientColumnFilters(rows, definitions)
  456. const { api, stop } = mount('LocalFilters', '', { filterApi, definitions })
  457. api.openDate('signDate', 'from')
  458. assert.ok(api.yearInput, '日期筛选应支持输入年份')
  459. api.dateParts.value = ['2024', '02', '29']
  460. await vue.nextTick()
  461. api.yearInput.value = '1800'
  462. api.onDateConfirm()
  463. assert.equal((filterApi.getFilterState('signDate') as any).from, '1800-02-28')
  464. assert.equal(filterApi.filteredRows.value.length, 2)
  465. api.openDate('signDate', 'from')
  466. assert.equal(api.pickerYear.value, 1800)
  467. assert.equal(api.maxDate.value.getFullYear() - api.minDate.value.getFullYear(), 20)
  468. api.yearInput.value = '2400'
  469. api.onDateConfirm()
  470. assert.equal(filterApi.filteredRows.value.length, 1)
  471. api.openDate('signDate', 'to')
  472. api.yearInput.value = '9999'
  473. api.applyYear()
  474. assert.equal(api.maxDate.value.getFullYear(), 9999)
  475. api.yearInput.value = '1000'
  476. api.applyYear()
  477. assert.equal(api.minDate.value.getFullYear(), 1000)
  478. stop()
  479. })
  480. test('关联商机直接按ID打开,目标切换和卸载丢弃迟到响应', async () => {
  481. const pending: Record<string, (value: any) => void> = {}
  482. const { api, props, stop } = mount('RelatedPanel', 'opportunity', { targetOpportunityId: 'O1', readonly: true }, {
  483. getOpportunity: (id: string) => new Promise((resolve) => { pending[id] = resolve })
  484. })
  485. await flush()
  486. assert.equal(api.detailOpen.value, true)
  487. assert.ok(pending.O1, '无需列表命中即按ID读取')
  488. props.targetOpportunityId = 'O2'
  489. await flush()
  490. pending.O1({ id: 'O1' })
  491. await flush()
  492. assert.notEqual(api.detail.value?.id, 'O1')
  493. pending.O2({ id: 'O2' })
  494. await flush()
  495. assert.equal(api.detail.value.id, 'O2')
  496. props.targetOpportunityId = 'O3'
  497. await flush()
  498. stop()
  499. pending.O3({ id: 'O3' })
  500. await flush()
  501. assert.equal(api.detailOpen.value, false)
  502. assert.equal(api.detail.value, undefined)
  503. })
  504. test('关闭关联商机取消读取,重新打开同ID可以重试', async () => {
  505. let resolve!: (value: any) => void
  506. const { api, props, stop } = mount('RelatedPanel', 'opportunity', { targetOpportunityId: 'O1', readonly: true }, {
  507. getOpportunity: () => new Promise((r) => { resolve = r })
  508. })
  509. await flush()
  510. assert.equal(api.detailOpen.value, true)
  511. api.closeDetail()
  512. resolve({ id: 'O1' })
  513. await flush()
  514. assert.equal(api.detail.value, undefined)
  515. props.targetOpportunityId = ''
  516. await flush()
  517. props.targetOpportunityId = 'O1'
  518. await flush()
  519. resolve({ id: 'O1' })
  520. await flush()
  521. assert.equal(api.detail.value.id, 'O1')
  522. stop()
  523. })
  524. function rangeHarness(kind = 'contract') {
  525. const parent = mount('RelatedPanel', kind)
  526. const messages: string[] = []
  527. const child = mount('LocalFilters', '', {
  528. definitions: parent.api.filterDefinitions.value,
  529. filterApi: parent.api.filterApi
  530. }, { showFailToast: (message: string) => messages.push(message) })
  531. if (parent.api.localFilters) parent.api.localFilters.value = child.api
  532. const edit = (prop: string, field: string, value: string) => {
  533. if (child.api.setRangeValue) child.api.setRangeValue(prop, field, value)
  534. else child.api.state(prop)[field] = value
  535. }
  536. return { parent, child, edit, messages, stop: () => { child.stop(); parent.stop() } }
  537. }
  538. test('关联金额倒置草稿不污染有效筛选,确定保留面板并提示,修正后可提交', async () => {
  539. const h = rangeHarness()
  540. try {
  541. await flush()
  542. const number = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number')
  543. const prop = number.prop
  544. h.parent.api.rows.value = [{ id: 'a', [prop]: 100 }, { id: 'b', [prop]: 1000 }]
  545. h.edit(prop, 'min', '200')
  546. assert.deepEqual(h.parent.api.filtered.value.map((r: any) => r.id), ['b'])
  547. h.edit(prop, 'max', '100')
  548. assert.deepEqual(h.parent.api.filtered.value.map((r: any) => r.id), ['b'])
  549. assert.equal(h.child.api.state(prop).max, '100')
  550. h.parent.api.moreOpen.value = true
  551. h.parent.api.applyMore()
  552. assert.equal(h.parent.api.moreOpen.value, true)
  553. assert.deepEqual(h.messages, [`${number.label}:最小值不能大于最大值`])
  554. h.edit(prop, 'max', '1000')
  555. assert.equal(h.child.api.rangeError.value, '')
  556. h.parent.api.applyMore()
  557. assert.equal(h.parent.api.moreOpen.value, false)
  558. } finally { h.stop() }
  559. })
  560. test('所有关联日期字段拒绝倒置草稿,保留已生效日期且可修正', async () => {
  561. for (const kind of ['contract', 'opportunity', 'log']) {
  562. const h = rangeHarness(kind)
  563. try {
  564. await flush()
  565. for (const def of h.parent.api.filterDefinitions.value.filter((d: any) => d.type === 'datetime')) {
  566. h.child.api.clearAllFilters()
  567. h.edit(def.prop, 'from', '2025-01-01')
  568. h.edit(def.prop, 'to', '2024-01-01')
  569. assert.equal(h.parent.api.filterApi.getFilterState(def.prop).to, '', `${kind}/${def.prop}`)
  570. h.parent.api.moreOpen.value = true
  571. h.parent.api.applyMore()
  572. assert.equal(h.parent.api.moreOpen.value, true)
  573. assert.equal(h.messages.at(-1), `${def.label}:开始日期不能晚于结束日期`)
  574. h.edit(def.prop, 'to', '2025-01-01')
  575. assert.equal(h.child.api.rangeError.value, '')
  576. h.parent.api.applyMore()
  577. assert.equal(h.parent.api.moreOpen.value, false)
  578. }
  579. } finally { h.stop() }
  580. }
  581. })
  582. test('区间清空本项、全部清空及父面板重置同时清理错误草稿与生效条件', async () => {
  583. const h = rangeHarness()
  584. try {
  585. await flush()
  586. const prop = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number').prop
  587. for (const reset of [() => h.child.api.clearField(prop), () => h.child.api.clearAllFilters(), () => h.parent.api.resetMore()]) {
  588. h.edit(prop, 'min', '200')
  589. h.edit(prop, 'max', '100')
  590. reset()
  591. assert.equal(h.child.api.state(prop).min, '')
  592. assert.equal(h.child.api.state(prop).max, '')
  593. assert.equal(h.child.api.rangeError?.value || '', '')
  594. assert.equal(h.parent.api.filterApi.hasActiveFilters.value, false)
  595. }
  596. } finally { h.stop() }
  597. })
  598. test('区间合法零值、负数、小数、单边、相等及切换比较方式继续即时生效', async () => {
  599. const h = rangeHarness()
  600. try {
  601. await flush()
  602. const prop = h.parent.api.filterDefinitions.value.find((d: any) => d.type === 'number').prop
  603. for (const [min, max] of [['', ''], ['0', ''], ['', '0'], ['0', '0'], ['-2.5', '-1.5'], ['44.99', '44.99']]) {
  604. h.child.api.clearAllFilters()
  605. h.edit(prop, 'min', min)
  606. h.edit(prop, 'max', max)
  607. assert.equal(h.parent.api.filterApi.getFilterState(prop).min, min)
  608. assert.equal(h.parent.api.filterApi.getFilterState(prop).max, max)
  609. }
  610. for (const op of ['eq', 'gt', 'gte', 'lt', 'lte']) {
  611. h.child.api.clearAllFilters()
  612. h.edit(prop, 'min', '200')
  613. h.edit(prop, 'max', '100')
  614. h.child.api.openOp(prop)
  615. h.child.api.onOpConfirm({ selectedValues: [op] })
  616. h.edit(prop, 'value', '44.99')
  617. assert.equal(h.child.api.validate(), true)
  618. assert.equal(h.parent.api.filterApi.getFilterState(prop).op, op)
  619. assert.equal(h.parent.api.filterApi.getFilterState(prop).value, '44.99')
  620. h.child.api.onOpConfirm({ selectedValues: ['between'] })
  621. assert.equal(h.child.api.validate(), false)
  622. assert.equal(h.parent.api.filterApi.getFilterState(prop).op, op)
  623. }
  624. } finally { h.stop() }
  625. })
  626. for (const page of ['ContactPanel', 'InvoicePanel', 'RelatedPanel']) {
  627. test(`${page}: 重置清除所有枚举搜索词并恢复选项,未挂载筛选器时仍能清除条件`, async () => {
  628. const parent = mount(page)
  629. await flush()
  630. const definitions = vue.unref(parent.api.filterDefinitions)
  631. const child = mount('LocalFilters', '', { definitions, filterApi: parent.api.filterApi })
  632. try {
  633. if (parent.api.localFilters) parent.api.localFilters.value = child.api
  634. const enums = definitions.filter((d: any) => d.type === 'enum')
  635. assert.ok(enums.length)
  636. for (const def of enums) {
  637. child.api.enumKeywords[def.prop] = '无此选项'
  638. child.api.state(def.prop).selected = ['sample']
  639. }
  640. parent.api.resetMore()
  641. for (const def of enums) {
  642. assert.equal(child.api.enumKeywords[def.prop] || '', '', `${def.prop}搜索词应清空`)
  643. assert.deepEqual(child.api.state(def.prop).selected, [])
  644. assert.deepEqual(child.api.enumOptions(def), def.enumOptions || parent.api.filterApi.collectUniqueOptions(def.prop))
  645. }
  646. // 没有选中条件时再次搜索,组件内“清空筛选”也必须恢复完整选项。
  647. child.api.enumKeywords[enums[0].prop] = '不存在'
  648. child.api.clearAllFilters()
  649. assert.equal(child.api.enumKeywords[enums[0].prop] || '', '')
  650. parent.api.localFilters.value = undefined
  651. parent.api.filterApi.getFilterState(enums[0].prop).selected = ['sample']
  652. parent.api.resetMore()
  653. assert.equal(parent.api.filterApi.hasActiveFilters.value, false)
  654. } finally { child.stop(); parent.stop() }
  655. })
  656. }