| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838 |
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import test from 'node:test'
- import { compile, computed, createSSRApp, reactive, ref } 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 } 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 },
- contract,
- products: [product],
- productMoney,
- displayCategory: functions(detail, ['displayCategory'], { categoryPaths: ref(new Map()) }),
- formatContractMoney,
- unitLabel: () => '吨',
- formatBrandConstraintLabel: () => ''
- }
- }
- test('contract anchors follow the visible section order', () => {
- const tabs = value(detail, 'detailTabs', {})
- const anchors = [
- ...source(detail).descriptor.template!.content.matchAll(/<section id="([^"]+)"/g)
- ]
- .map((match) => match[1])
- .filter((id) => tabs.some((tab: any) => tab.anchor === id))
- assert.deepEqual(
- tabs.map((tab: any) => tab.anchor),
- anchors
- )
- })
- test('contract scroll highlights the closest section even when anchor declarations are unsorted', () => {
- const activeTab = ref('base')
- const top: Record<string, number> = { base: -600, products: 50, parties: -300 }
- const sync = functions(detail, ['syncActiveTabFromScroll'], {
- // Geometry cases assume a loaded current record; identity is covered by navigation tests.
- navigationReady: () => true,
- activeTab,
- contract: ref({ id: '1' }),
- scrollMargin: ref(100),
- scrollLockUntil: 0,
- detailTabs: ['base', 'products', 'parties'].map((key) => ({ key, anchor: key })),
- window: { scrollY: 500, innerHeight: 812 },
- document: {
- scrollingElement: { scrollHeight: 3000 },
- getElementById: (id: string) => ({ getBoundingClientRect: () => ({ top: top[id] }) })
- }
- })
- sync()
- assert.equal(activeTab.value, 'products')
- })
- for (const initial of ['products', 'attachments']) {
- test(`contract bottom scroll selects the final reachable anchor from ${initial}`, () => {
- const activeTab = ref(initial)
- const tabs = value(detail, 'detailTabs', {})
- const tops: Record<string, number> = {
- 'contract-sec-base': -2200,
- 'contract-sec-amount': -1600,
- 'contract-sec-parties': -1000,
- 'contract-sec-products': -300,
- 'contract-sec-approval': 400,
- 'contract-sec-attachments': 513.0625
- }
- const viewport = { scrollY: 2270.5, innerHeight: 812 }
- const document = {
- scrollingElement: { scrollHeight: 3083 },
- getElementById: (id: string) => ({ getBoundingClientRect: () => ({ top: tops[id] }) })
- }
- const sync = functions(detail, ['syncActiveTabFromScroll'], {
- // Geometry cases assume a loaded current record; identity is covered by navigation tests.
- navigationReady: () => true,
- activeTab,
- contract: ref({ id: '1' }),
- scrollMargin: ref(100),
- scrollLockUntil: 0,
- detailTabs: tabs,
- window: viewport,
- document
- })
- sync()
- assert.equal(activeTab.value, 'attachments')
- tops['contract-sec-attachments'] = 900
- sync()
- assert.equal(activeTab.value, 'products', 'an anchor outside the viewport is not forced active')
- tops['contract-sec-attachments'] = 513.0625
- // Scrolling up must immediately restore the normal closest-probe calculation.
- viewport.scrollY = 2200
- sync()
- assert.equal(activeTab.value, 'products')
- // Home must never be treated as the bottom of a short/unscrollable document.
- viewport.scrollY = 0
- document.scrollingElement.scrollHeight = 812
- Object.keys(tops).forEach((id, index) => { tops[id] = index * 100 + 100 })
- sync()
- assert.equal(activeTab.value, 'base')
- })
- }
- test('contract bottom detection respects the 700ms anchor click lock before updating', () => {
- let now = 100
- const activeTab = ref('approval')
- const sync = functions(detail, ['syncActiveTabFromScroll'], {
- // Geometry cases assume a loaded current record; identity is covered by navigation tests.
- navigationReady: () => true,
- activeTab,
- contract: ref({ id: '1' }),
- scrollMargin: ref(100),
- scrollLockUntil: now + 700,
- detailTabs: value(detail, 'detailTabs', {}),
- Date: { now: () => now },
- window: { scrollY: 2270.5, innerHeight: 812 },
- document: {
- scrollingElement: { scrollHeight: 3083 },
- getElementById: () => ({ getBoundingClientRect: () => ({ top: 513 }) })
- }
- })
- sync()
- assert.equal(activeTab.value, 'approval')
- now += 700
- sync()
- assert.equal(activeTab.value, 'attachments')
- })
- 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,
- 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({ params: { id: 'A' } }),
- contract: ref<any>(null),
- products: ref<any[]>([]),
- approvalList: ref<any[]>([]),
- 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'],
- bindings,
- 'let detailRequestSeq = 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 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, /随行就市/)
- })
- for (const page of lists) {
- 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}: more filters preserve preset ${min}..${max}`, () => {
- const state: Record<string, any> = {
- keyword: ref(''),
- view: ref('all'),
- contractType: ref(''),
- quoteDateBegin: ref(''),
- quoteDateEnd: ref(''),
- signDateBegin: ref(''),
- signDateEnd: ref(''),
- minAmount: ref(''),
- maxAmount: ref(''),
- amountRange: ref(''),
- moreOpen: ref(true),
- moreDraft: { keyword: 'test', minAmount: min, maxAmount: max },
- reload() {}
- }
- functions(page, ['applyMoreFilters', 'resolveAmountRange'], state)()
- assert.equal(state.amountRange.value, expected)
- assert.equal(state.minAmount.value, min)
- assert.equal(state.maxAmount.value, max)
- assert.equal(state.moreOpen.value, false)
- })
- }
- test(`${page}: a custom range is visibly labeled instead of all amounts`, async () => {
- const template = source(page).descriptor.template!.content
- const dropdown = template.match(/<van-dropdown-item[^>]*v-model="amountRange"[^>]*\/>/)![0]
- const title = dropdown.match(/:title="([^"]+)"/)?.[1]
- const bindings = { amountRange: 'custom', minAmount: '12000', maxAmount: '23000' }
- const label = title
- ? new Function(...Object.keys(bindings), `return ${title}`)(...Object.values(bindings))
- : ''
- assert.equal(label, '自定义金额')
- assert.equal(new Function(...Object.keys(bindings), `return ${title}`)('', '', ''), undefined)
- })
- test(`${page}: selecting all in Vant clears a custom amount range and reloads`, () => {
- let reloads = 0
- const state = {
- keyword: ref(''),
- view: ref('all'),
- contractType: ref(''),
- quoteDateBegin: ref(''),
- quoteDateEnd: ref(''),
- signDateBegin: ref(''),
- signDateEnd: ref(''),
- minAmount: ref(''),
- maxAmount: ref(''),
- amountRange: ref(''),
- moreOpen: ref(true),
- moreDraft: { keyword: '', minAmount: '12000', maxAmount: '23000' },
- reload() {
- reloads++
- }
- }
- functions(page, ['applyMoreFilters', 'resolveAmountRange'], state)()
- reloads = 0
- const changed = functions(page, ['onAmountRangeChange', 'applyAmountRange'], state)
- clickAmountOption(state.amountRange.value, '', (event, value) => {
- if (event === 'update:modelValue') state.amountRange.value = value
- if (event === 'change') changed()
- })
- assert.equal(state.minAmount.value, '')
- assert.equal(state.maxAmount.value, '')
- assert.equal(state.amountRange.value, '')
- assert.equal(reloads, 1)
- })
- test(`${page}: custom amounts mark more filters active while presets do not`, () => {
- const state = {
- computed,
- amountRange: ref('custom'),
- minAmount: ref('12000'),
- maxAmount: ref('23000'),
- quoteDateBegin: ref(''),
- quoteDateEnd: ref(''),
- signDateBegin: ref(''),
- signDateEnd: ref('')
- }
- const active = value(page, 'moreActive', state)
- assert.equal(active.value, true)
- state.amountRange.value = '1-10w'
- assert.equal(active.value, false)
- state.amountRange.value = ''
- state.minAmount.value = ''
- state.maxAmount.value = ''
- assert.equal(active.value, false)
- })
- test(`${page}: detail return restores the custom sentinel together with amount bounds`, async () => {
- const path = page.startsWith('quote') ? '/m/crm/quote' : '/m/crm/contract'
- let leave!: (target: { path: string }) => void
- const runtime = {
- ref,
- nextTick: async () => {},
- useRoute: () => ({ path }),
- useRouter: () => ({ afterEach: () => () => {} }),
- useUserStore: () => ({ accessToken: 'session', getUserInfo: { id: 'me' } }),
- onBeforeRouteLeave: (hook: typeof leave) => {
- leave = hook
- },
- onBeforeUnmount() {},
- window: { scrollY: 0, scrollTo() {} }
- }
- const helper = readFileSync(new URL('useListReturnState.ts', root), 'utf8')
- .replace(/^import .*\n/gm, '')
- .replace('export function', 'function')
- const code = ts.transpile(helper, { target: ts.ScriptTarget.ESNext })
- const useListReturnState = new Function(
- ...Object.keys(runtime),
- `${code}; return useListReturnState`
- )(...Object.values(runtime))
- const create = () => {
- const fields: Record<string, any> = {}
- for (const key of [
- 'keyword',
- 'view',
- 'quoteType',
- 'contractType',
- 'amountRange',
- 'minAmount',
- 'maxAmount',
- 'quoteDateBegin',
- 'quoteDateEnd',
- 'signDateBegin',
- 'signDateEnd'
- ])
- fields[key] = ref('')
- const state = value(page, 'listReturnState', {
- ...fields,
- useListReturnState,
- pageNum: ref(1),
- expandedIds: ref(new Set()),
- hasMore: ref(false),
- requestSnapshot: () => JSON.stringify([fields.minAmount.value, fields.maxAmount.value]),
- fetchPage: async () => true
- })
- return { fields, state }
- }
- const first = create()
- functions(page, ['applyMoreFilters', 'resolveAmountRange'], {
- ...first.fields,
- moreOpen: ref(true),
- moreDraft: { keyword: '', minAmount: '12000', maxAmount: '23000' },
- reload() {}
- })()
- leave({ path: `${path}/detail-id` })
- const returned = create()
- await returned.state.restore()
- assert.equal(returned.fields.amountRange.value, 'custom')
- assert.equal(returned.fields.minAmount.value, '12000')
- assert.equal(returned.fields.maxAmount.value, '23000')
- })
- test(`${page}: reset clears bounds and restores all amounts`, () => {
- const state: Record<string, any> = {
- props: { mode: 'manage' },
- keyword: ref('old'),
- view: ref('all'),
- contractType: ref(''),
- quoteDateBegin: ref(''),
- quoteDateEnd: ref(''),
- signDateBegin: ref(''),
- signDateEnd: ref(''),
- minAmount: ref('10000'),
- maxAmount: ref('100000'),
- amountRange: ref('1-10w'),
- moreOpen: ref(true),
- moreDraft: { keyword: 'old', minAmount: '10000', maxAmount: '100000' },
- reload() {}
- }
- functions(
- page,
- ['resetAndApplyMore', 'resetMoreFilters', 'applyMoreFilters', 'resolveAmountRange'],
- state
- )()
- assert.equal(state.minAmount.value, '')
- assert.equal(state.maxAmount.value, '')
- assert.equal(state.amountRange.value, '')
- })
- }
- for (const view of ['approved', 'submitted']) {
- test(`contract batch buttons reject ineligible selected rows in ${view}`, async () => {
- const file = 'contract/MpContractList.vue'
- const template = source(file).descriptor.template!.content
- const button = template.match(
- /<button[^>]*@click="openBatchApprove\('approved'\)"[^>]*>[\s\S]*?<\/button>/
- )![0]
- const selectedIds = ref(new Set(['1']))
- const contracts = ref([{ id: '1', canApprove: false, status: 'approved' }])
- const bindings = {
- computed,
- props: { mode: 'approve' },
- view: ref(view),
- selectedIds,
- contracts
- }
- const selectedRows = functions(file, ['selectedRows'], bindings)
- const ast = source(file).ast
- const hasComputed = ast.statements.some(
- (s) =>
- ts.isVariableStatement(s) &&
- s.declarationList.declarations.some((d) => d.name.getText(ast) === 'canApproveBatch')
- )
- const canApproveBatch = hasComputed
- ? value(file, 'canApproveBatch', { ...bindings, selectedRows }).value
- : undefined
- const html = await render(button, {
- selectedIds: selectedIds.value,
- acting: false,
- canApproveBatch,
- openBatchApprove() {}
- })
- assert.match(html, /disabled/)
- })
- }
- test('contract batch eligibility follows the active view and at least one selected eligible row', () => {
- const file = 'contract/MpContractList.vue'
- const bindings = {
- computed,
- props: { mode: 'approve' },
- view: ref('submitted'),
- selectedIds: ref(new Set(['yes', 'no'])),
- contracts: ref([
- { id: 'yes', status: 'approving', canApprove: true },
- { id: 'no', status: 'approved', canApprove: false }
- ])
- }
- const selectedRows = functions(file, ['selectedRows'], bindings)
- const eligible = value(file, 'canApproveBatch', { ...bindings, selectedRows })
- assert.equal(eligible.value, true)
- bindings.view.value = 'approved'
- assert.equal(eligible.value, false)
- bindings.view.value = 'submitted'
- bindings.selectedIds.value = new Set(['no'])
- assert.equal(eligible.value, false)
- bindings.selectedIds.value = new Set()
- assert.equal(eligible.value, false)
- })
- test('contract batch sheets open only for eligible targets and preserve the skipped count', () => {
- const file = 'contract/MpContractList.vue'
- const state = {
- canApproveBatch: ref(false),
- selectedRows: () => [
- { id: 'yes', status: 'approving', canApprove: true },
- { id: 'no', status: 'approved', canApprove: false }
- ],
- batchTargetIds: ref<string[]>([]),
- batchSkipped: ref(0),
- approveResult: ref(''),
- approveOpinion: ref(''),
- approveOpen: ref(false),
- ElMessage: { warning() {} }
- }
- const open = functions(file, ['openBatchApprove'], state)
- open('approved')
- assert.equal(state.approveOpen.value, false)
- state.canApproveBatch.value = true
- open('approved')
- assert.equal(state.approveOpen.value, true)
- assert.deepEqual(state.batchTargetIds.value, ['yes'])
- assert.equal(state.batchSkipped.value, 1)
- })
- test('contract batch approval keeps mixed results and refreshes after partial success', async () => {
- let reloads = 0
- const warnings: string[] = []
- const state = {
- approveResult: ref('approved'),
- approveOpinion: ref(''),
- batchTargetIds: ref(['a', 'b']),
- batchSkipped: ref(1),
- acting: ref(false),
- approveOpen: ref(true),
- selectMode: ref(true),
- approveContract: async (id: string) => {
- if (id === 'b') throw new Error('changed')
- },
- rejectContract: async () => {},
- clearSelection() {},
- reload() {
- reloads++
- },
- ElMessage: { warning: (message: string) => warnings.push(message), error() {}, success() {} }
- }
- await functions('contract/MpContractList.vue', ['confirmBatchApprove'], state)()
- assert.match(warnings[0], /成功通过 1 条,失败 1 条,跳过 1 条/)
- assert.equal(reloads, 1)
- assert.equal(state.approveOpen.value, false)
- assert.equal(state.acting.value, false)
- })
|