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, 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) { 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) { const compiled = compile(fragment) return renderToString(createSSRApp({ render: () => compiled.call(bindings, bindings, []) })) } function deferred() { let resolve!: (result: T) => void let reject!: (error: Error) => void const promise = new Promise((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, product: Record) { 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([]), 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 = {}) { const contract = ref({ status: 'approving', ...overrides }) const approvalList = ref([ { 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 = {}) { const bindings = { props: { embedded: false }, route: reactive({ name: 'MpCrmContractDetail', get path() { return `/m/crm/contract/${this.params.id}` }, params: { id: 'A' } }), contract: ref(null), products: ref([]), approvalList: ref([]), 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() 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() const oldApproval = deferred() 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(`]*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(/
/)![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(`${label}${market ? '随行就市' : money}`), `${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( /
/ )![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( /
/ )![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 = { 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( /]*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') }) }