Files
shanxia2.0/web-admin/scripts/mobile-crm-detail-filters.test.ts
T

620 lines
21 KiB
TypeScript

import * as packageSpec from '../src/utils/crm-package-spec'
import {
cleanQuoteQuery,
defaultQuoteColumns,
isQuoteListReturnRoute
} from '../src/views/m/crm/quote/listState'
import {
cleanContractQuery,
isContractListReturnRoute
} from '../src/views/m/crm/contract/listState'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { compile, computed, createSSRApp, reactive, ref, watch } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import { isFrameworkContractType } from '../src/views/crm/contract/contractType'
import {
displayFrameworkMoney,
displayFrameworkQuantity
} from '../src/views/crm/contract/frameworkProductPricing'
const root = new URL('../src/views/m/crm/', import.meta.url)
const detail = 'contract/detail.vue'
const lists = ['quote/MpQuoteList.vue', 'contract/MpContractList.vue']
function source(file: string) {
const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
return {
descriptor,
ast: ts.createSourceFile(file, descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true)
}
}
function functions(file: string, names: string[], bindings: Record<string, any>, prelude = '') {
const { ast } = source(file)
const declarations = ast.statements.filter(
(item) => ts.isFunctionDeclaration(item) && names.includes(item.name?.text || '')
)
const code = ts.transpile(declarations.map((item) => item.getText(ast)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
return new Function(
...Object.keys(bindings),
`${prelude}\n${code}\nreturn typeof ${names[0]} === "undefined" ? undefined : ${names[0]}`
)(...Object.values(bindings))
}
function value(file: string, name: string, bindings: Record<string, any>) {
const { ast } = source(file)
const declaration = ast.statements
.filter(ts.isVariableStatement)
.flatMap((item) => item.declarationList.declarations)
.find((item) => item.name.getText(ast) === name)
assert.ok(declaration, `Missing ${name}`)
const code = ts.transpile(`const result = ${declaration.initializer!.getText(ast)}`, {
target: ts.ScriptTarget.ESNext
})
return new Function(...Object.keys(bindings), `${code}\nreturn result`)(
...Object.values(bindings)
)
}
async function render(fragment: string, bindings: Record<string, any>) {
const compiled = compile(fragment)
return renderToString(createSSRApp({ render: () => compiled.call(bindings, bindings, []) }))
}
function deferred<T>() {
let resolve!: (result: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
// Execute the installed Vant click handler so identical-value suppression stays covered.
function clickAmountOption(
modelValue: string,
nextValue: string,
emit: (event: string, value: string) => void
) {
const file = readFileSync(
new URL('../node_modules/vant/es/dropdown-item/DropdownItem.mjs', import.meta.url),
'utf8'
)
const ast = ts.createSourceFile('DropdownItem.mjs', file, ts.ScriptTarget.Latest, true)
let clickExpression = ''
const visit = (node: ts.Node) => {
if (ts.isVariableDeclaration(node) && node.name.getText(ast) === 'onClick') {
clickExpression = node.initializer!.getText(ast)
}
ts.forEachChild(node, visit)
}
visit(ast)
assert.ok(clickExpression)
new Function('props', 'option', 'emit', 'state', 'disabled', `return ${clickExpression}`)(
{ modelValue },
{ value: nextValue },
emit,
{},
false
)()
}
function productBindings(contract: Record<string, any>, product: Record<string, any>) {
const formatContractMoney = (amount: any) => `¥${Number(amount || 0).toFixed(2)}`
const productMoney = functions(detail, ['productMoney'], {
contract: ref(contract),
isFrameworkContractType,
displayFrameworkMoney,
formatContractMoney
})
return {
props: { embedded: false, hideProductPrice: false },
...packageSpec,
isFrameworkContractType,
displayFrameworkQuantity,
formatProductPlanQty: functions(detail, ['formatProductPlanQty', 'formatPlanQty'], {
...packageSpec
}),
contract,
products: [product],
productMoney,
displayCategory: functions(detail, ['displayCategory'], { categoryPaths: ref(new Map()) }),
formatContractMoney,
unitLabel: () => '吨',
formatBrandConstraintLabel: () => ''
}
}
test('contract detail tabs select the requested pane and load transport plans only when needed', () => {
const tabs = value(detail, 'detailTabs', {})
assert.deepEqual(
tabs.map((t: any) => t.key),
['contract', 'plans', 'payment', 'invoice', 'approval', 'operation']
)
const activeTab = ref('contract'),
linkedPlans = ref<any[]>([]),
linkedPlansLoading = ref(false)
let loads = 0,
scrolls = 0
const change = functions(detail, ['onDetailTabChange'], {
activeTab,
linkedPlans,
linkedPlansLoading,
loadLinkedPlans: () => loads++,
window: { scrollTo: () => scrolls++ }
})
change('plans')
assert.equal(activeTab.value, 'plans')
assert.equal(loads, 1)
linkedPlans.value = [{ id: '1' }]
change('plans')
assert.equal(loads, 1)
change('approval')
assert.equal(activeTab.value, 'approval')
assert.equal(scrolls, 3)
})
function approvalState(overrides: Record<string, any> = {}) {
const contract = ref({ status: 'approving', ...overrides })
const approvalList = ref<any[]>([
{
id: 'a',
step: 1,
approverId: 'other',
approvalResult: 'approved',
isCurrent: 0,
createTime: '2026-10-21 10:00:00'
},
{
id: 'b',
step: 2,
approverId: 'me',
approvalResult: 'pending',
isCurrent: 1,
createTime: '2026-10-21 10:00:00'
}
])
const pending = value(detail, 'pendingApproval', {
computed,
contract,
approvalList,
approvalError: ref(''),
approvalLoading: ref(false),
resolveCurrentUserId: () => 'me'
})
return { contract, approvalList, pending }
}
test('contract approval allows the current pending node after all earlier steps passed', () => {
assert.equal(approvalState().pending.value?.id, 'b')
})
for (const status of ['draft', 'approved', 'executing', 'finished', 'invalid', '']) {
test(`contract approval denies status ${status || '(empty)'}`, () => {
assert.equal(approvalState({ status }).pending.value, null)
})
}
for (const prior of ['rejected', 'pending', undefined]) {
test(`contract approval denies earlier ${prior ?? '(missing)'} result`, () => {
const state = approvalState()
state.approvalList.value[0].approvalResult = prior
assert.equal(state.pending.value, null)
})
}
for (const current of [0, undefined]) {
test(`contract approval denies isCurrent=${current}`, () => {
const state = approvalState()
state.approvalList.value[1].isCurrent = current
assert.equal(state.pending.value, null)
})
}
for (const result of ['approved', 'rejected', undefined]) {
test(`contract approval denies own ${result ?? '(missing)'} result`, () => {
const state = approvalState()
state.approvalList.value[1].approvalResult = result
assert.equal(state.pending.value, null)
})
}
test('contract approval denies a node belonging to another user', () => {
const state = approvalState()
state.approvalList.value[1].approverId = 'someone'
assert.equal(state.pending.value, null)
})
test('contract approval ignores previous submission rounds after rejection and resubmission', () => {
const state = approvalState()
state.approvalList.value = [
{
id: 'old',
step: 1,
approverId: 'me',
approvalResult: 'rejected',
isCurrent: 0,
createTime: '2026-10-20 10:00:00'
},
{
id: 'first',
step: 1,
approverId: 'other',
approvalResult: 'approved',
isCurrent: 0,
createTime: '2026-10-21 10:00:00'
},
{
id: 'current',
step: 2,
approverId: 'me',
approvalResult: 'pending',
isCurrent: 1,
createTime: '2026-10-21 10:00:00'
}
]
assert.equal(state.pending.value?.id, 'current')
state.approvalList.value[1].approvalResult = 'rejected'
assert.equal(state.pending.value, null, 'a rejection in the current round still blocks approval')
})
for (const createTime of ['', undefined]) {
test(`contract approval without pending timestamps (${createTime === '' ? 'empty API value' : 'absent field'}) matches the backend fallback`, () => {
const state = approvalState()
state.approvalList.value = [
{
id: 'old',
step: 1,
approverId: 'other',
approvalResult: 'rejected',
isCurrent: 0,
createTime
},
{
id: 'new-first',
step: 1,
approverId: 'other',
approvalResult: 'approved',
isCurrent: 0,
createTime
},
{
id: 'current',
step: 2,
approverId: 'me',
approvalResult: 'pending',
isCurrent: 1,
createTime
}
]
assert.equal(state.pending.value?.id, 'current')
state.approvalList.value[1].approvalResult = 'pending'
assert.equal(
state.pending.value,
null,
'an earlier pending step still blocks approval without timestamps'
)
})
}
function detailLoader(api: Record<string, any> = {}) {
const bindings = {
props: { embedded: false },
route: reactive({
name: 'MpCrmContractDetail',
get path() {
return `/m/crm/contract/${this.params.id}`
},
params: { id: 'A' }
}),
contract: ref<any>(null),
products: ref<any[]>([]),
approvalList: ref<any[]>([]),
approvalError: ref(''),
approvalLoading: ref(false),
loading: ref(false),
errorText: ref(''),
getContract: async (id: string) => ({
data: { id, status: 'approving', quoteId: 'Q', products: [] }
}),
getQuote: async () => ({ data: { quoteNo: 'SXDD-20261009005' } }),
listContractApproval: async () => ({ data: [{ id: 'approval-A' }] }),
runRouteAction: async () => {},
categoryPaths: ref(new Map()),
loadCategoryPaths: async () => {},
...api
}
const load = functions(
detail,
['loadDetail', 'isCurrentLoad', 'currentRecordId', 'loadApprovalHistory'],
bindings,
'let detailRequestSeq = 0; let approvalRequestSeq = 0; const shellReady = true; const disposed = false'
)
return { ...bindings, load }
}
test('contract detail loads the linked quote number using quoteId', async () => {
const calls: string[] = []
const state = detailLoader({
getQuote: async (id: string) => {
calls.push(id)
return { data: { quoteNo: 'SXDD-20261009005' } }
}
})
await state.load()
assert.deepEqual(calls, ['Q'])
assert.equal(state.contract.value.quoteNo, 'SXDD-20261009005')
})
test('a failed quote lookup preserves contract and approval details', async () => {
const state = detailLoader({
getQuote: async () => {
throw new Error('quote unavailable')
}
})
await state.load()
assert.equal(state.contract.value.id, 'A')
assert.equal(state.approvalList.value[0].id, 'approval-A')
assert.equal(state.errorText.value, '')
})
test('a failed approval lookup still shows the linked quote and main detail', async () => {
const state = detailLoader({
listContractApproval: async () => {
throw new Error('approval unavailable')
}
})
await state.load()
assert.equal(state.contract.value.quoteNo, 'SXDD-20261009005')
assert.deepEqual(state.approvalList.value, [])
assert.equal(state.errorText.value, '')
})
test('older main detail responses cannot replace a newer route', async () => {
const old = deferred<any>()
const state = detailLoader({
getContract: (id: string) => (id === 'A' ? old.promise : Promise.resolve({ data: { id } }))
})
const first = state.load()
state.route.params.id = 'B'
await state.load()
old.resolve({ data: { id: 'A', products: [{ id: 'old' }] } })
await first
assert.equal(state.contract.value.id, 'B')
assert.deepEqual(state.products.value, [])
})
test('older quote and approval responses cannot alter the newer route', async () => {
const oldQuote = deferred<any>()
const oldApproval = deferred<any>()
const state = detailLoader({
getContract: async (id: string) => ({ data: { id, quoteId: `Q-${id}` } }),
getQuote: (id: string) => {
if (id === 'Q-A') return oldQuote.promise
return Promise.resolve({ data: { quoteNo: 'quote-B' } })
},
listContractApproval: (id: string) =>
id === 'A' ? oldApproval.promise : Promise.resolve({ data: [{ id: 'approval-B' }] })
})
const first = state.load()
// Flush microtasks without waiting forever when the old implementation never fetches a quote.
await Promise.resolve()
await Promise.resolve()
state.route.params.id = 'B'
await state.load()
oldQuote.resolve({ data: { quoteNo: 'quote-A' } })
oldApproval.resolve({ data: [{ id: 'approval-A' }] })
await first
assert.equal(state.contract.value.quoteNo, 'quote-B')
assert.equal(state.approvalList.value[0].id, 'approval-B')
})
test('contract mobile rendering matches quote number, unit dictionary, tax and payable total', async () => {
const template = source(detail).descriptor.template!.content
const section = (id: string) =>
template.match(new RegExp(`<section\\s+[^>]*id="contract-sec-${id}"[\\s\\S]*?<\\/section>`))![0]
const contract = { amount: 750, freight: 20, barrelDeposit: 30, amountWithTax: 795 }
const dictStore = {
getLabel: (type: string, unit: string) =>
type === 'crm_product_unit' && unit === 'dun' ? '吨' : unit
}
const unitLabel = functions(detail, ['unitLabel'], { dictStore }) || ((unit: string) => unit)
const bindings = {
...productBindings(contract, {}),
contract,
products: [{ id: 'p', unit: 'dun', taxAmount: 86.28, subtotal: 750 }],
formatContractMoney: (amount: any) => `¥${Number(amount || 0).toFixed(2)}`,
formatBrandConstraintLabel: () => '',
unitLabel,
dictStore
}
// Read the amount expression from the real template; no mirrored total calculation in this test.
const html = await render(section('amount') + section('products'), bindings)
assert.match(html, /吨/)
assert.match(html, /税费[\s\S]*¥86\.28/)
assert.match(html, /合计金额[\s\S]*¥800\.00/)
assert.doesNotMatch(html, /¥795\.00/)
})
for (const contractType of [
'framework_sale',
'framework_purchase',
'framework',
'sales',
'purchase'
]) {
for (const priceMode of ['market', 'fixed']) {
test(`contract ${contractType}/${priceMode} renders all three monetary fields consistently`, async () => {
const template = source(detail).descriptor.template!.content
const products = template.match(/<section id="contract-sec-products"[\s\S]*?<\/section>/)![0]
const row = { priceMode, price: 100, subtotal: 750, taxAmount: 86.28 }
const html = await render(products, productBindings({ contractType }, row))
const market = contractType.startsWith('framework') && priceMode === 'market'
for (const [label, money] of [
['含税单价(元)', '¥100.00'],
['含税总价(元)', '¥750.00'],
['税费(元)', '¥86.28']
]) {
assert.ok(
html.includes(`<span>${label}</span><span>${market ? '随行就市' : money}</span>`),
`${label} must follow ${contractType}/${priceMode}`
)
}
if (market) assert.doesNotMatch(html, /¥(?:100\.00|750\.00|86\.28)/)
})
}
}
test('framework market zero and legacy snake_case modes use the same Web semantics', async () => {
const products = source(detail).descriptor.template!.content.match(
/<section id="contract-sec-products"[\s\S]*?<\/section>/
)![0]
for (const row of [
{ priceMode: 'market', price: 0, subtotal: 0, taxAmount: 0 },
{ price_mode: 'market', price: 15, subtotal: 100, taxAmount: 13 },
{ price: 0, subtotal: 0, taxAmount: 0 }
]) {
const html = await render(products, productBindings({ contractType: 'framework_sale' }, row))
assert.equal(html.match(/随行就市/g)?.length, 3)
assert.doesNotMatch(html, /¥0\.00/)
}
})
test('fixed zero pricing remains numeric for framework contracts', async () => {
const products = source(detail).descriptor.template!.content.match(
/<section id="contract-sec-products"[\s\S]*?<\/section>/
)![0]
const html = await render(
products,
productBindings(
{ contractType: 'framework_purchase' },
{ priceMode: 'fixed', price: 0, subtotal: 0, taxAmount: 0 }
)
)
assert.equal(html.match(/¥0\.00/g)?.length, 3)
assert.doesNotMatch(html, /随行就市/)
})
function amountState(page: string) {
let reloads = 0
const state: Record<string, any> = {
keyword: ref('搜索词'),
view: ref('all'),
query: ref({}),
minAmount: ref(''),
maxAmount: ref(''),
amountRange: ref(''),
contractType: ref(''),
quoteType: ref(''),
moreOpen: ref(false),
visibleColumns: ref(defaultQuoteColumns),
cleanQuoteQuery,
cleanContractQuery,
reload: () => reloads++,
requestSnapshot: () => JSON.stringify(state.query.value)
}
const operation = (name: string) =>
functions(page, [name, 'syncQuickFilters', 'applyAmountRange'], state)
return { state, operation, reloads: () => reloads }
}
for (const page of lists) {
const field = page.startsWith('quote') ? 'finalAmount' : 'amount'
for (const [min, max, expected] of [
['10000', '100000', '1-10w'],
['10000.00', '100000.00', '1-10w'],
['', '10000', 'lt1w'],
['100000', '', 'gt10w'],
['12000', '23000', 'custom'],
['', '', '']
]) {
test(`${page}: applied query preserves amount preset ${min}..${max}`, () => {
const h = amountState(page)
const q =
min || max
? {
[field + 'Op']: 'between',
...(min ? { [field + 'Min']: min } : {}),
...(max ? { [field + 'Max']: max } : {})
}
: {}
h.operation('applyFilters')(q)
assert.equal(h.state.amountRange.value, expected)
assert.equal(h.state.minAmount.value, min)
assert.equal(h.state.maxAmount.value, max)
assert.equal(h.state.keyword.value, '搜索词')
})
}
test(`${page}: selecting all in Vant clears custom amount comparison and preserves other filters`, () => {
const h = amountState(page)
h.operation('applyFilters')({ [field + 'Op']: 'gte', [field]: '23000', statusIn: 'draft' })
assert.equal(h.state.amountRange.value, 'custom')
const before = h.reloads()
clickAmountOption(h.state.amountRange.value, '', (event, v) => {
if (event === 'update:modelValue') h.state.amountRange.value = v
if (event === 'change') h.operation('onAmountRangeChange')()
})
assert.deepEqual(h.state.query.value, { statusIn: 'draft' })
assert.equal(h.reloads(), before + 1)
})
test(`${page}: custom amount label is rendered and empty filters clear the active marker`, () => {
const h = amountState(page)
h.operation('applyFilters')({ [field + 'Min']: '12000', [field + 'Max']: '23000' })
const active = value(page, 'moreActive', { computed, query: h.state.query })
assert.equal(active.value, true)
const dropdown = source(page).descriptor.template!.content.match(
/<van-dropdown-item[^>]*v-model="amountRange"[^>]*\/>/
)![0]
const title = dropdown.match(/:title="([^"]+)"/)![1]
assert.equal(
new Function('amountRange', `return ${title}`)(h.state.amountRange.value),
'自定义金额'
)
h.operation('applyFilters')({})
assert.equal(active.value, false)
assert.equal(h.state.amountRange.value, '')
})
test(`${page}: detail return restores the query and derives the same custom amount label`, async () => {
const path = page.startsWith('quote') ? '/m/crm/quote' : '/m/crm/contract'
let leave: any
const runtime = {
ref,
watch,
nextTick: async () => {},
useRoute: () => ({ path }),
useRouter: () => ({ afterEach: () => () => {} }),
useUserStore: () => ({ accessToken: 'session', getUserInfo: { id: 'me' } }),
onBeforeRouteLeave: (fn: any) => {
leave = fn
},
onBeforeUnmount() {},
window: { scrollY: 0, scrollTo() {} }
}
const helper = readFileSync(new URL('useListReturnState.ts', root), 'utf8')
.replace(/^import .*$/gm, '')
.replace('export function', 'function')
const useListReturnState = new Function(
...Object.keys(runtime),
ts.transpile(helper, { target: ts.ScriptTarget.ESNext }) + ';return useListReturnState'
)(...Object.values(runtime))
const create = () => {
const h = amountState(page)
const returned = value(page, 'listReturnState', {
...h.state,
tabQueries: ref({}),
isQuoteListReturnRoute,
isContractListReturnRoute,
useListReturnState,
pageNum: ref(1),
expandedIds: ref(new Set()),
hasMore: ref(false),
fetchPage: async () => true
})
return { ...h, returned }
}
const first = create()
first.operation('applyFilters')({ [field + 'Min']: '12000', [field + 'Max']: '23000' })
leave({ path: `${path}/detail-id`, query: {} })
const back = create()
await back.returned.restore()
back.operation('syncQuickFilters')()
assert.equal(back.state.amountRange.value, 'custom')
assert.equal(back.state.minAmount.value, '12000')
assert.equal(back.state.maxAmount.value, '23000')
})
}