mobile-pl-plan-list-parity.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import ts from 'typescript'
  5. import { parse } from '@vue/compiler-sfc'
  6. import { ref } from 'vue'
  7. import { formatBrandConstraintLabel } from '../src/utils/crm-product-brand'
  8. import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
  9. import { plPlanColumnFilterDefs } from '../src/views/pl/plan/columnFilterDefs'
  10. import { plPlanListTabToFilters } from '../src/utils/pl-display'
  11. import {
  12. applyMergedCustomerQueryParams,
  13. applyContractSideQueryParams
  14. } from '../src/utils/pl-plan-pair-list-query'
  15. import { facetPlanDateParams } from '../src/views/m/pl/plan-cert/model'
  16. function productLine() {
  17. const source = ts.createSourceFile(
  18. 'card.ts',
  19. readFileSync(new URL('../src/utils/mp-pl-plan-card.ts', import.meta.url), 'utf8'),
  20. ts.ScriptTarget.Latest,
  21. true
  22. )
  23. const names = [
  24. 'formatStopProductLine',
  25. 'resolveLinkedPlanProduct',
  26. 'formatQty',
  27. 'resolveUnitLabel'
  28. ]
  29. const code = ts.transpile(
  30. source.statements
  31. .filter((node) => ts.isFunctionDeclaration(node) && names.includes(node.name!.text))
  32. .map((node) => node.getText(source))
  33. .join('\n'),
  34. { target: ts.ScriptTarget.ESNext }
  35. )
  36. const env = {
  37. formatBrandConstraintLabel,
  38. resolveProductHazardFlags: () => ({ isToxic: false, isExplosive: false }),
  39. getPlanProductActualQuantity: () => '0',
  40. resolveMpPackageSpecLabel: (v: string) => v || '—',
  41. resolveMpProductUnitLabel: (v: string) => v || '—'
  42. }
  43. return new Function(...Object.keys(env), `${code};return formatStopProductLine`)(
  44. ...Object.values(env)
  45. )
  46. }
  47. test('mobile list product brands and aliases follow the exact plan line before product ID fallback', () => {
  48. const format = productLine()
  49. const plan = {
  50. products: [
  51. {
  52. id: 'first',
  53. productId: 'p',
  54. productName: '标准名称',
  55. brandMode: 'required',
  56. brandName: '品牌甲',
  57. aliasName: '别名甲'
  58. },
  59. {
  60. id: 'second',
  61. productId: 'p',
  62. productName: '标准名称',
  63. brandMode: 'required',
  64. brandName: '品牌乙',
  65. aliasName: ' 别名乙 '
  66. }
  67. ]
  68. }
  69. const second = format(
  70. plan,
  71. { kind: 'load' },
  72. { productId: 'p', planProductId: 'second', productName: '段上别名', planQty: 2 }
  73. )
  74. assert.equal(second.name, '标准名称')
  75. assert.equal(second.brand, '品牌乙')
  76. assert.equal(second.alias, '别名乙')
  77. assert.equal(second.planQty, '2')
  78. const fallback = format(plan, { kind: 'delivery' }, { productId: 'p' })
  79. assert.equal(fallback.brand, '品牌甲')
  80. assert.equal(fallback.alias, '别名甲')
  81. })
  82. test('mobile product metadata uses the Web brand constraint label and empty placeholders', () => {
  83. const format = productLine()
  84. for (const brandMode of ['any', 'required', undefined]) {
  85. const product = { id: 'line', productId: 'p', brandMode, brandName: '', aliasName: '' }
  86. const line = format({ products: [product] }, { kind: 'load' }, { planProductId: 'line' })
  87. assert.equal(line.brand, formatBrandConstraintLabel(brandMode, ''))
  88. assert.equal(line.alias, '—')
  89. }
  90. const missing = format({ products: [] }, { kind: 'load' }, { productId: 'missing' })
  91. assert.equal(missing.brand, '—')
  92. assert.equal(missing.alias, '—')
  93. })
  94. for (const metadata of [
  95. { aliasName: '送货别名', brandMode: 'any', brandName: '' },
  96. { aliasName: '', brandMode: '', brandName: '' },
  97. {},
  98. { aliasName: '另一段别名', brandMode: 'required', brandName: '另一段品牌' }
  99. ]) {
  100. test(`segment metadata overrides the same root product, including explicit empty values: ${JSON.stringify(metadata)}`, () => {
  101. const format = productLine()
  102. const root = {
  103. id: 'root',
  104. productId: 'p',
  105. productName: '标准名称',
  106. aliasName: '装货根别名',
  107. brandMode: 'required',
  108. brandName: '装货根品牌'
  109. }
  110. const plan = { products: [root] }
  111. const load = format(plan, { kind: 'load' }, { productId: 'p', planProductId: 'root' })
  112. const delivery = format(
  113. plan,
  114. { kind: 'delivery' },
  115. {
  116. productId: 'p',
  117. planProductId: 'root',
  118. mobileProductMeta: metadata
  119. }
  120. )
  121. assert.equal(load.alias, '装货根别名')
  122. assert.equal(load.brand, '装货根品牌')
  123. assert.equal(delivery.alias, metadata.aliasName || '—')
  124. assert.equal(delivery.brand, formatBrandConstraintLabel(metadata.brandMode, metadata.brandName))
  125. assert.equal(root.aliasName, '装货根别名')
  126. assert.equal(root.brandName, '装货根品牌')
  127. })
  128. }
  129. function queryFunction(path: string, name: string, bindings: Record<string, any>) {
  130. const ast = ts.createSourceFile(
  131. 'query.ts',
  132. parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
  133. ts.ScriptTarget.Latest,
  134. true
  135. )
  136. const fn = ast.statements.find(
  137. (node) => ts.isFunctionDeclaration(node) && node.name?.text === name
  138. )!
  139. return new Function(
  140. ...Object.keys(bindings),
  141. `${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return ${name}`
  142. )(...Object.values(bindings))
  143. }
  144. for (const tab of ['pending_arrange', 'all', 'inbound', 'outbound']) {
  145. test(`mobile ${tab} list query matches the Web keyword, filter, tab, sort and contract-side parameters`, () => {
  146. const state = useServerColumnFilters(plPlanColumnFilterDefs)
  147. Object.assign(state.filters, {
  148. followStatus: { selected: ['accepted'], text: '' },
  149. planType: { selected: ['direct'], text: '' },
  150. planNo: { selected: ['PO1', 'PO2'], text: '' },
  151. planDate: { from: '2026-10-01', to: '2026-10-10' },
  152. deliveryTimes: { from: '2026-10-02', to: '2026-10-11' },
  153. loadCustomer: { selected: ['供方'], text: '' },
  154. deliveryCustomer: { selected: ['需方'], text: '' },
  155. productContract: { selected: ['load', 'CT1'], text: '' },
  156. personnelSummary: { text: '司机', selected: [] },
  157. planQty: { op: 'between', min: 1, max: 9 }
  158. })
  159. const keyword = ref(' 搜索关键词 '),
  160. activeTab = ref(tab),
  161. dateRange = ref(['2026-09-01', '2026-09-30'])
  162. const sort = { sortField: 'deliveryTimes', sortOrder: 'asc' }
  163. const bindings = {
  164. ...state,
  165. keyword,
  166. activeTab,
  167. dateRange,
  168. sort,
  169. pageSize: 20,
  170. plPlanListTabToFilters,
  171. applyMergedCustomerQueryParams,
  172. applyContractSideQueryParams,
  173. isStockList: ref(false),
  174. quickSearch: keyword,
  175. activePlanTypeTab: activeTab,
  176. toSortQueryParams: () => sort
  177. }
  178. const mobile = queryFunction('../src/views/m/pl/plan/index.vue', 'buildParams', bindings)()
  179. const web = queryFunction('../src/views/pl/plan/index.vue', 'buildListQueryParams', bindings)()
  180. assert.deepEqual(mobile, { ...web, pageSize: 20 })
  181. })
  182. }
  183. test('plan facets preserve independent load and delivery customer restrictions from the draft', () => {
  184. const state = useServerColumnFilters(plPlanColumnFilterDefs)
  185. Object.assign(state.filters, {
  186. loadCustomer: { text: '', selected: ['装货甲'] },
  187. deliveryCustomer: { text: '', selected: ['送货乙'] }
  188. })
  189. const source = ts.createSourceFile(
  190. 'filter.ts',
  191. parse(readFileSync(new URL('../src/views/m/pl/plan/PlanFilters.vue', import.meta.url), 'utf8'))
  192. .descriptor.scriptSetup!.content,
  193. ts.ScriptTarget.Latest,
  194. true
  195. )
  196. const node = source.statements.find(
  197. (node) =>
  198. ts.isVariableStatement(node) &&
  199. node.declarationList.declarations.some((d) => d.name.getText(source) === 'picker')
  200. )!
  201. const env = {
  202. defs: plPlanColumnFilterDefs,
  203. draft: state.filters,
  204. toQueryParams: state.toQueryParams,
  205. props: {
  206. context: {
  207. followStatus: 'accepted',
  208. page: 2,
  209. loadCustomersIn: '旧装货',
  210. deliveryCustomersIn: '旧送货'
  211. },
  212. filters: {}
  213. },
  214. userStore: {},
  215. useMobileCertFacet: (config: any) => config,
  216. fetchPlanColumnFacetValues: () => {},
  217. applyMergedCustomerQueryParams,
  218. applyContractSideQueryParams,
  219. facetPlanDateParams
  220. }
  221. const picker = new Function(
  222. ...Object.keys(env),
  223. `${ts.transpile(node.getText(source), { target: ts.ScriptTarget.ESNext })};return picker`
  224. )(...Object.values(env))
  225. const q = picker.context()
  226. assert.equal(q.loadCustomersIn, '装货甲')
  227. assert.equal(q.deliveryCustomersIn, '送货乙')
  228. assert.equal(q.stopCustomers, undefined)
  229. assert.equal(q.followStatus, 'accepted')
  230. })
  231. import * as Vue from 'vue'
  232. import { useMobileCertFacet } from '../src/views/m/pl/plan-cert/useMobileCertFacet'
  233. import {
  234. withPlanContractIoFacetOptions,
  235. PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS
  236. } from '../src/utils/pl-plan-contract-display'
  237. test('plan filter pickers confirm drafts, keep server query semantics and reset all filters', async () => {
  238. const source = ts.createSourceFile(
  239. 'filters.ts',
  240. parse(readFileSync(new URL('../src/views/m/pl/plan/PlanFilters.vue', import.meta.url), 'utf8'))
  241. .descriptor.scriptSetup!.content,
  242. ts.ScriptTarget.Latest,
  243. true
  244. )
  245. const code = source.statements
  246. .filter((node) => !ts.isImportDeclaration(node))
  247. .map((node) => node.getText(source))
  248. .join('\n')
  249. const props = Vue.reactive({
  250. modelValue: false,
  251. dateOnly: false,
  252. filters: {} as Record<string, any>,
  253. sort: { sortField: '', sortOrder: 'desc' },
  254. context: { followStatus: 'accepted' }
  255. })
  256. const events: any[] = [],
  257. requests: any[] = [],
  258. warnings: string[] = []
  259. const bindings = {
  260. ...Object.fromEntries(
  261. Object.entries(Vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
  262. ),
  263. defs: plPlanColumnFilterDefs,
  264. useServerColumnFilters,
  265. useMobileCertFacet,
  266. applyMergedCustomerQueryParams,
  267. applyContractSideQueryParams,
  268. facetPlanDateParams,
  269. withPlanContractIoFacetOptions,
  270. PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS,
  271. defineProps: () => props,
  272. defineEmits:
  273. () =>
  274. (...args: any[]) =>
  275. events.push(args),
  276. useUserStore: () => ({ accessToken: 'test', getUserInfo: { id: 'U' } }),
  277. fetchPlanColumnFacetValues: async (query: any) => {
  278. requests.push(query)
  279. return { list: [{ value: 'P1', label: '计划一' }] }
  280. },
  281. ElMessage: { warning: (text: string) => warnings.push(text) }
  282. }
  283. const scope = Vue.effectScope()
  284. const h = scope.run(() =>
  285. new Function(
  286. ...Object.keys(bindings),
  287. ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
  288. ';return {picker,draft,confirmPicker,enumSummary,openSingle,confirmSingle,setNumberValue,openDate,confirmDate,toQueryParams,apply,resetAndApply,singleOpen,dateOpen,visibleDateDefs}'
  289. )(...Object.values(bindings))
  290. )
  291. const flush = async () => {
  292. for (let i = 0; i < 10; i++) await Promise.resolve()
  293. }
  294. try {
  295. props.modelValue = true
  296. await Vue.nextTick()
  297. h.picker.open('planType', [])
  298. await flush()
  299. assert.equal(requests.length, 0, 'fixed options do not call the remote facet API')
  300. h.picker.toggle('inbound')
  301. h.picker.close()
  302. assert.equal(h.draft.planType, undefined)
  303. h.picker.open('planType', [])
  304. await flush()
  305. h.picker.toggle('inbound')
  306. h.confirmPicker()
  307. assert.equal(h.enumSummary('planType'), '入库单')
  308. h.picker.open('planNo', [])
  309. await flush()
  310. assert.equal(requests[0].followStatus, 'accepted')
  311. h.picker.toggle('P1')
  312. h.confirmPicker()
  313. assert.equal(h.enumSummary('planNo'), '计划一')
  314. h.openSingle('numberOp', 'planQty')
  315. h.confirmSingle({ selectedValues: ['gte'] })
  316. h.setNumberValue('planQty', 'value', '10')
  317. h.openDate('planDate', 'from')
  318. h.confirmDate({ selectedValues: ['2026', '10', '24'] })
  319. h.openSingle('sortField')
  320. h.confirmSingle({ selectedValues: ['planDate'] })
  321. assert.deepEqual(h.toQueryParams(), {
  322. planNoIn: 'P1',
  323. planTypeIn: 'inbound',
  324. planDateFrom: '2026-10-24',
  325. productPlanQtyOp: 'gte',
  326. productPlanQty: '10'
  327. })
  328. h.apply()
  329. assert.deepEqual(events.find((event) => event[0] === 'apply').slice(2), [
  330. { sortField: 'planDate', sortOrder: 'desc' },
  331. false
  332. ])
  333. h.openDate('planDate', 'to')
  334. h.confirmDate({ selectedValues: ['2026', '10', '23'] })
  335. h.apply()
  336. assert.equal(warnings.length, 1)
  337. h.openSingle('sortOrder')
  338. h.openDate('planDate', 'to')
  339. props.modelValue = false
  340. await Vue.nextTick()
  341. assert.equal(h.singleOpen.value, false)
  342. assert.equal(h.dateOpen.value, false)
  343. h.resetAndApply()
  344. assert.deepEqual(events.filter((event) => event[0] === 'apply').at(-1), [
  345. 'apply',
  346. {},
  347. { sortField: '', sortOrder: 'desc' },
  348. true
  349. ])
  350. props.modelValue = false
  351. await Vue.nextTick()
  352. props.dateOnly = true
  353. props.filters = {
  354. planNo: { selected: ['P1'] },
  355. planDate: { from: '2026-10-24', to: '2026-10-25' }
  356. }
  357. props.sort = { sortField: 'createTime', sortOrder: 'asc' }
  358. props.modelValue = true
  359. await Vue.nextTick()
  360. assert.deepEqual(
  361. h.visibleDateDefs.value.map((d: any) => d.prop),
  362. ['planDate']
  363. )
  364. h.openDate('planDate', 'from')
  365. h.confirmDate({ selectedValues: ['2026', '10', '23'] })
  366. assert.equal(
  367. props.filters.planDate.from,
  368. '2026-10-24',
  369. 'date edits stay in draft until confirmed'
  370. )
  371. h.resetAndApply()
  372. assert.deepEqual(events.filter((event) => event[0] === 'apply').at(-1), [
  373. 'apply',
  374. { planNo: { selected: ['P1'] } },
  375. { sortField: 'createTime', sortOrder: 'asc' },
  376. true
  377. ])
  378. } finally {
  379. scope.stop()
  380. }
  381. })
  382. test('date entry opens only date fields and preserves applied quick dates until confirmed', () => {
  383. const source = ts.createSourceFile(
  384. 'list.ts',
  385. parse(readFileSync(new URL('../src/views/m/pl/plan/index.vue', import.meta.url), 'utf8'))
  386. .descriptor.scriptSetup!.content,
  387. ts.ScriptTarget.Latest,
  388. true
  389. )
  390. const names = [
  391. 'rangeFilters',
  392. 'onRangeOpen',
  393. 'onMoreOpen',
  394. 'onDateDropdownChange',
  395. 'pickDate',
  396. 'applyFilters'
  397. ]
  398. const code = source.statements
  399. .filter((node) =>
  400. ts.isFunctionDeclaration(node)
  401. ? names.includes(node.name!.text)
  402. : ts.isVariableStatement(node) &&
  403. node.declarationList.declarations.some((d) => names.includes(d.name.getText(source)))
  404. )
  405. .map((node) => node.getText(source))
  406. .join('\n')
  407. let reloads = 0
  408. const bindings = {
  409. computed: Vue.computed,
  410. filters: Vue.reactive<Record<string, any>>({ planNo: { selected: ['P1'] } }),
  411. dateShortcut: ref('today'),
  412. dateRange: ref(['2026-10-24', '2026-10-24']),
  413. customFrom: ref(''),
  414. customTo: ref(''),
  415. customDateLabel: ref(''),
  416. dateOnly: ref(false),
  417. filterOpen: ref(false),
  418. rangeDropdown: ref(null),
  419. moreDropdown: ref(null),
  420. sort: { sortField: 'createTime', sortOrder: 'asc' },
  421. reload: () => reloads++,
  422. closeFilter: () => {},
  423. rangeFor: () => ['2026-10-25', '2026-10-25']
  424. }
  425. const h = new Function(
  426. ...Object.keys(bindings),
  427. ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
  428. ';return {rangeFilters,onRangeOpen,onMoreOpen,onDateDropdownChange,pickDate,applyFilters}'
  429. )(...Object.values(bindings))
  430. h.onRangeOpen()
  431. assert.equal(bindings.dateOnly.value, true)
  432. assert.equal(bindings.filterOpen.value, true)
  433. assert.equal(bindings.dateShortcut.value, 'today')
  434. assert.equal(bindings.filters.planDate, undefined)
  435. assert.deepEqual(h.rangeFilters.value.planDate, { from: '2026-10-24', to: '2026-10-24' })
  436. bindings.filterOpen.value = false
  437. h.onDateDropdownChange('custom')
  438. assert.equal(bindings.dateShortcut.value, 'today')
  439. assert.equal(reloads, 0)
  440. h.applyFilters(
  441. { ...bindings.filters, planDate: { from: '2026-10-20', to: '2026-10-22' } },
  442. { ...bindings.sort }
  443. )
  444. assert.equal(bindings.dateShortcut.value, 'custom')
  445. assert.deepEqual(bindings.dateRange.value, [])
  446. assert.equal(bindings.customDateLabel.value, '2026-10-20 ~ 2026-10-22')
  447. h.onMoreOpen()
  448. assert.equal(bindings.dateOnly.value, false)
  449. h.pickDate('custom')
  450. assert.equal(bindings.filters.planDate.from, '2026-10-20')
  451. h.pickDate('tomorrow')
  452. assert.equal(bindings.filters.planDate, undefined)
  453. assert.deepEqual(bindings.dateRange.value, ['2026-10-25', '2026-10-25'])
  454. assert.deepEqual(bindings.filters.planNo, { selected: ['P1'] })
  455. })