feat(pl): 按运输段产品维护合法使用证明并隔离异步会话。

This commit is contained in:
wuzilong committed 2026-11-01 23:15:17 +08:00
1 parent 722076fb3f
commit 4cb2d88dab
8 files changed
+1103 -404

No files matched your search

+3 -48
View File
@@ -20,6 +20,7 @@ function deferred() {
})
return { promise, resolve, reject }
}
// 段产品证明已改为服务端单选筛选,其回归见 stop-documents.test.ts。
// Run production setup, including its real synchronous session watchers. Only I/O is substituted.
function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: true }) {
const lists: any[] = [],
@@ -116,7 +117,7 @@ function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: tr
}
}
for (const kind of ['cert', 'legal'] as const) {
for (const kind of ['cert'] as const) {
test(`${kind}: late list cannot overwrite latest refresh`, async () => {
const h = pageHarness(kind),
s = h.state
@@ -130,7 +131,7 @@ for (const kind of ['cert', 'legal'] as const) {
h.stop()
})
}
for (const kind of ['cert', 'legal'] as const) {
for (const kind of ['cert'] as const) {
const fixture = (index: number) =>
kind === 'cert'
? {
@@ -280,49 +281,3 @@ test('plan: actual header filters send server conditions, reset page, preserve r
await flush()
h.stop()
})
test('legal: mixed-product upload filters preserve any-match and OR semantics', async () => {
const h = pageHarness('legal'),
s = h.state
const row = (id: string, statuses: string[]) => ({
id,
planNo: id,
products: statuses.map((documentStatus) => ({ productName: '产品', documentStatus }))
})
s.rows.value = [
row('mixed', ['pending', 'uploaded']),
row('pending', ['pending']),
row('uploaded', ['uploaded'])
]
s.setFilterState('documentStatus', { selected: ['uploaded'] })
assert.deepEqual(
s.filteredRows.value.map((r: any) => r.id),
['mixed', 'uploaded']
)
s.setFilterState('documentStatus', { selected: ['pending'] })
assert.deepEqual(
s.filteredRows.value.map((r: any) => r.id),
['mixed', 'pending']
)
s.setFilterState('documentStatus', { selected: ['pending', 'uploaded'] })
assert.deepEqual(
s.filteredRows.value.map((r: any) => r.id),
['mixed', 'pending', 'uploaded']
)
assert.deepEqual(
s.collectUniqueOptions('documentStatus').map((o: any) => o.count),
[2, 2]
)
s.setFilterState('documentStatus', { selected: ['uploaded'] })
s.setFilterState('planNo', { text: 'mixed' })
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
['mixed']
)
s.clearFilter('planNo')
s.visibleColumns.value = s.visibleColumns.value.filter((p: string) => p !== 'documentStatus')
await vue.nextTick()
assert.equal(s.filteredRows.value.length, 3)
assert.equal(s.hasActiveFilters.value, false)
h.stop()
})
+511
View File
@@ -0,0 +1,511 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve()
}
function deferred() {
let resolve!: (value: any) => void, reject!: (error: any) => void
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return { promise, resolve, reject }
}
function harness(fixed?: string) {
const viewportWidth = vue.ref(1120)
const calls: any[] = [],
permissions: any[] = [],
unmount: (() => void)[] = []
const props = vue.reactive({
contractId: fixed,
active: true,
embedded: !!fixed
})
const user = vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } })
const queue = (method: string, args: any[]) => {
const pending = deferred()
calls.push({ method, args, ...pending })
return pending.promise
}
const api = Object.fromEntries(
['listStops', 'getStops', 'saveStops'].map((method) => [
method,
(...args: any[]) => queue(method, args)
])
)
const env = {
...Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
),
defineProps: () => props,
withDefaults: (v: any) => v,
useUserStore: () => user,
fetchGetMenuCodes: () => {
const p = deferred()
permissions.push(p)
return p.promise
},
useElementSize: () => ({ width: viewportWidth }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
usePersistedListColumns,
useServerColumnFilters,
legalDocumentApi: api,
ElMessage: { success() {}, warning() {} },
onBeforeUnmount: (fn: () => void) => unmount.push(fn)
}
const src = ts.createSourceFile(
'panel.ts',
parse(
readFileSync(
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
'utf8'
)
).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
src.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(src))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const scope = vue.effectScope()
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code}; return { rows, details, load, loadDetail, allowed, canUpload, keyword, setFilterState, page, total, expansionStyle, open, save, visible, editing, upload, saving, editorLoading, status, search, statusLabel }`
)(...Object.values(env))
)
return {
state,
viewportWidth,
calls,
permissions,
props,
user,
stop: () => {
unmount.forEach((fn) => fn())
scope.stop()
}
}
}
test('global and fixed contract preserve server counts and scope every request', async () => {
for (const fixed of [undefined, 'contract-one']) {
const h = harness(fixed),
s = h.state
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list', 'pl:plan-cert:upload'] })
await flush()
assert.equal(h.calls[0].method, 'listStops')
assert.equal(h.calls[0].args[0].contractId, fixed)
h.calls[0].resolve({
list: [{ id: 'plan', uploadedCount: 1, requiredCount: fixed ? 1 : 3 }],
total: 45
})
await flush()
assert.equal(s.rows.value[0].requiredCount, fixed ? 1 : 3)
const pending = s.open({ id: 'plan' }, { stopType: 'delivery', stopProductId: 'd2' })
assert.equal(h.calls[1].args[1], fixed)
h.calls[1].resolve({
id: 'plan',
items: [
{ stopType: 'delivery', stopProductId: 'd2', productId: 'same', legalUseCertUrl: 'a,b' }
]
})
await pending
const saving = s.save()
assert.deepEqual(h.calls[2].args, [
'plan',
[{ stopType: 'delivery', stopProductId: 'd2', legalUseCertUrl: 'a,b' }],
fixed
])
h.calls[2].resolve({})
await saving
h.stop()
}
})
test('busy uploads refuse saves and failures preserve draft', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [], total: 0 })
await flush()
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[1].resolve({
id: 'p',
items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'original' }]
})
await opened
s.editing.value.legalUseCertUrl = 'draft'
s.upload.value = { hasUploading: () => true }
await s.save()
assert.equal(h.calls.length, 2)
s.upload.value = { hasUploading: () => false }
const saving = s.save()
h.calls[2].reject(Error('failed'))
await saving
assert.equal(s.editing.value.legalUseCertUrl, 'draft')
assert.equal(s.visible.value, true)
h.stop()
})
for (const change of ['contract', 'tenant', 'close'])
test(`old editor response is discarded after ${change}`, async () => {
const h = harness('one'),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const old = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
if (change === 'contract') h.props.contractId = 'two'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'close') s.visible.value = false
h.calls[1].resolve({ id: 'p', items: [{ stopType: 'load', stopProductId: 'l' }] })
await old
assert.equal(s.editing.value, undefined)
h.stop()
})
test('search during permission initialization does not cancel it; filters are independent', async () => {
const h = harness(),
s = h.state
s.keyword.value = 'word'
s.setFilterState('planNo', { text: 'PL' })
await s.load()
h.permissions[0].resolve({ all: true })
await flush()
assert.equal(h.calls.length, 1)
assert.equal(h.calls[0].args[0].keyword, 'word')
assert.equal(h.calls[0].args[0].planNo, 'PL')
h.stop()
})
test('refresh preserves page and total; not_required is not uploaded', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [], total: 45 })
await flush()
s.page.value = 2
const pending = s.load()
assert.equal(s.total.value, 45)
assert.equal(h.calls[1].args[0].page, 2)
h.calls[1].reject(Error('failed'))
await pending
assert.equal(s.page.value, 2)
assert.equal(s.total.value, 45)
assert.equal(s.statusLabel('not_required'), '无需上传')
h.stop()
})
// Mount the real Element Plus pager. Its page-count watcher must remain active.
import ElPagination from 'element-plus/es/components/pagination/src/pagination.mjs'
test('real pagination keeps page two while loading and preserves count after request failure', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
h.calls[0].resolve({ list: [{ id: 'first' }], total: 45 })
await flush()
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp({
render: () =>
vue.h(ElPagination, {
currentPage: s.page.value,
total: s.total.value,
pageSize: 20,
layout: '',
'onUpdate:current-page': (value: number) => {
s.page.value = value
},
onCurrentChange: () => {
void s.load()
}
})
})
app.mount({})
s.page.value = 2
const pending = s.load()
await vue.nextTick()
await flush()
assert.equal(s.page.value, 2)
assert.equal(h.calls.length, 2)
assert.equal(h.calls[1].args[0].page, 2)
h.calls[1].resolve({ list: [{ id: 'second' }], total: 45 })
await pending
await vue.nextTick()
assert.equal(s.page.value, 2)
assert.equal(s.rows.value[0].id, 'second')
const failed = s.load()
h.calls[2].reject(Error('failed'))
await failed
await vue.nextTick()
assert.equal(s.page.value, 2)
assert.equal(s.total.value, 45)
assert.deepEqual(s.rows.value, [])
h.user.getUserInfo.tenantNo = 'next'
await vue.nextTick()
assert.equal(s.page.value, 1)
assert.equal(s.total.value, 0)
app.unmount()
h.stop()
})
import { compileScript } from '@vue/compiler-sfc'
function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemplate = true) {
const descriptor = parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: path, inlineTemplate })
const source = ts.createSourceFile('compiled.ts', compiled.content, ts.ScriptTarget.Latest, true)
const env: Record<string, any> = Object.fromEntries(
Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
)
Object.assign(env, dependencies)
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue
const clause = statement.importClause
if (clause?.name) env[clause.name.text] = dependencies[clause.name.text] || {}
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const item of clause.namedBindings.elements) {
if (item.isTypeOnly) continue
const name = (item.propertyName || item.name).text
env[item.name.text] =
statement.moduleSpecifier.getText(source) === '"vue"' ||
statement.moduleSpecifier.getText(source) === "'vue'"
? (vue as any)[name]
: dependencies[name]
}
}
}
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source))
.join('\n')
.replace('export default', 'const component ='),
{ target: ts.ScriptTarget.ESNext }
)
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
}
for (const active of [undefined, false])
test(`compiled proof component active=${active}`, async () => {
const calls: string[] = []
const component = compiledSfc(
'../src/views/pl/legal-use-document/StopDocuments.vue',
{
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
fetchGetMenuCodes: async () => ({ menuCodes: ['pl:legal-use-document:list'] }),
legalDocumentApi: {
listStops: async () => {
calls.push('proof')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
useElementSize: () => ({ width: vue.ref(1000) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) })
},
false
)
component.render = () => null
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText() {},
setElementText() {},
parentNode: () => null,
nextSibling: () => null,
insert() {},
remove() {},
patchProp() {}
})
const app = renderer.createApp(component, active === undefined ? {} : { active })
app.mount({})
await flush()
assert.deepEqual(calls, active === false ? [] : ['proof'])
app.unmount()
})
test('closing and reopening rejects old response and finally without clearing new loading', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const first = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
s.visible.value = false
const second = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l' }] })
await first
assert.equal(s.editorLoading.value, true)
assert.equal(s.editing.value, undefined)
h.calls[2].resolve({ items: [{ stopType: 'delivery', stopProductId: 'd' }] })
await second
assert.equal(s.editorLoading.value, false)
assert.equal(s.editing.value.stopProductId, 'd')
h.stop()
})
test('historical attachments have a separate readonly field and viewport aligned expansion', () => {
const descriptor = parse(
readFileSync(
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
'utf8'
)
).descriptor
assert.match(descriptor.template!.content, /历史计划级资料/)
assert.match(
descriptor.template!.content,
/:model-value="product.legalUseCertUrl \|\| ''"\s+disabled/
)
assert.match(descriptor.styles[0].content, /text-align: left/)
const h = harness()
assert.equal(h.state.expansionStyle.value.width, '1120px')
h.viewportWidth.value = 760
assert.equal(h.state.expansionStyle.value.width, '760px')
h.stop()
})
test('stop API preserves original legacy methods and scopes all three new endpoints', async () => {
const calls: any[] = []
const source = ts.createSourceFile(
'api.ts',
readFileSync(new URL('../src/api/pl/quota-cert.ts', import.meta.url), 'utf8'),
ts.ScriptTarget.Latest,
true
)
const code = ts.transpile(
source.statements
.filter((s) => !ts.isImportDeclaration(s))
.map((s) => s.getText(source).replace(/^export /, ''))
.join('\n'),
{ target: ts.ScriptTarget.ESNext }
)
const api = new Function('request', 'pickBxData', `${code};return legalDocumentApi`)(
{
get: (...args: any[]) => {
calls.push(args)
return Promise.resolve({})
},
patch: (...args: any[]) => {
calls.push(args)
return Promise.resolve({})
}
},
(v: any) => v
)
await api.listStops({ contractId: 'c', page: 2, status: 'pending', planNo: 'PL' })
await api.getStops('p', 'c')
await api.saveStops(
'p',
[{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }],
'c'
)
assert.deepEqual(calls, [
[
'/transport/legal-use-documents/stops',
{ params: { contractId: 'c', page: 2, status: 'pending', planNo: 'PL' } }
],
['/transport/legal-use-documents/stops/p', { params: { contractId: 'c' } }],
[
'/transport/legal-use-documents/stops/p',
{ items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }] },
{ params: { contractId: 'c' } }
]
])
for (const method of ['list', 'get', 'save']) assert.equal(typeof api[method], 'function')
})
test('view permission loads proof only and prevents upload requests', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] })
await flush()
assert.equal(s.allowed.value, true)
assert.equal(s.canUpload.value, false)
await s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
await s.save()
assert.deepEqual(
h.calls.map((c) => c.method),
['listStops']
)
h.stop()
})
test('old save completion cannot close or clear a reopened editor', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'a' }] })
await opened
const oldSave = s.save()
s.visible.value = false
const reopened = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
h.calls[3].resolve({
items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'b' }]
})
await reopened
const newSave = s.save()
h.calls[2].resolve({})
await oldSave
assert.equal(s.visible.value, true)
assert.equal(s.saving.value, true)
assert.equal(s.editing.value.stopProductId, 'd')
assert.equal(h.calls.length, 5)
h.calls[4].reject(Error('retry'))
await newSave
assert.equal(s.editing.value.legalUseCertUrl, 'b')
assert.equal(s.saving.value, false)
h.stop()
})
test('old list and permission responses cannot reauthorize another tenant', async () => {
const h = harness(),
s = h.state
h.user.getUserInfo.tenantNo = 'b'
h.permissions[0].resolve({ all: true })
await flush()
assert.equal(h.calls.length, 0)
h.permissions[1].resolve({ all: true })
await flush()
h.user.getUserInfo.tenantNo = 'c'
h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 })
await flush()
assert.deepEqual(s.rows.value, [])
assert.equal(s.allowed.value, false)
h.permissions[2].resolve({ menuCodes: [] })
await flush()
assert.equal(h.calls.length, 1)
h.stop()
})
test('status is a server single filter and combines with the number filter', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
s.status.value = 'uploaded'
s.search()
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
s.setFilterState('planNo', { text: 'PL' })
assert.equal(h.calls.at(-1).args[0].planNo, 'PL')
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
h.calls.forEach((c) => c.resolve({ list: [], total: 0 }))
await flush()
h.stop()
})