feat(pl): 按运输段产品维护合法使用证明并隔离异步会话。
This commit is contained in:
1 parent
722076fb3f
commit
4cb2d88dab
8 files changed
+1103
-404
No files matched your search
@@ -262,10 +262,11 @@ def list_documents(keyword: Optional[str] = None, status: Optional[str] = None,
|
||||
|
||||
@legal_router.get('/stops')
|
||||
def list_stop_documents(keyword: Optional[str] = None, status: Optional[str] = None,
|
||||
plan_no: Optional[str] = Query(None, alias='planNo'),
|
||||
contract_id: Optional[str] = Query(None, alias='contractId'),
|
||||
page: int = Query(1, ge=1), page_size: int = Query(20, alias='pageSize', ge=1, le=100),
|
||||
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
|
||||
return _wire(stop_documents.list_documents(db, user, keyword, status, contract_id, page, page_size))
|
||||
return _wire(stop_documents.list_documents(db, user, keyword, status, contract_id, page, page_size, plan_no=plan_no))
|
||||
|
||||
|
||||
@legal_router.get('/stops/{plan_id}')
|
||||
|
||||
@@ -70,7 +70,9 @@ def _details(db, user, plans, contract_id=None, lock=False):
|
||||
query = _query(db, user, side, model, parent, field, contract_id).filter(model.plan_id.in_(result))
|
||||
if lock:
|
||||
query = query.with_for_update().populate_existing()
|
||||
for row, stop, product, contract, document in query.order_by(parent.order_num, parent.id, model.order_num, model.id).all():
|
||||
stop_order = (func.coalesce(func.nullif(parent.seq, 0), parent.order_num)
|
||||
if side == 'delivery' else parent.order_num)
|
||||
for row, stop, product, contract, document in query.order_by(stop_order, parent.id, model.order_num, model.id).all():
|
||||
url = document.legal_use_cert_url if document else None
|
||||
result[row.plan_id]['items'].append({
|
||||
'stopType': side, 'stopId': stop.id, 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
|
||||
@@ -102,7 +104,7 @@ def get_documents(db, user, plan_id, contract_id=None):
|
||||
return _details(db, user, [plan], contract_id)[plan_id]
|
||||
|
||||
|
||||
def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20):
|
||||
def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20, plan_no=None):
|
||||
if status not in (None, 'pending', 'uploaded') or page < 1 or not 1 <= page_size <= 100:
|
||||
raise HTTPException(400, '无效资料状态或分页参数')
|
||||
if contract_id:
|
||||
@@ -124,6 +126,8 @@ def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1
|
||||
query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no)
|
||||
if keyword:
|
||||
query = query.filter(PlPlan.plan_no.contains(keyword))
|
||||
if plan_no:
|
||||
query = query.filter(PlPlan.plan_no.contains(plan_no))
|
||||
if status == 'pending':
|
||||
query = query.filter(counts.c.uploaded < counts.c.required)
|
||||
elif status == 'uploaded':
|
||||
|
||||
@@ -180,7 +180,7 @@ def test_multiple_plans_filter_then_page_and_route_shape(case):
|
||||
c.db.add(c.m.PlPlanDeliveryProduct(id=pid+'dp', delivery_id=pid+'d', plan_id=pid,
|
||||
plan_product_id=pid+'pp', product_id='sku', product_no='S', product_name='产品', tenant_no='t'))
|
||||
c.db.flush()
|
||||
first = list_stop_documents(keyword=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user)
|
||||
first = list_stop_documents(keyword=None, plan_no=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user)
|
||||
second = c.svc.list_documents(c.db, c.user, status='pending', page=2, page_size=2)
|
||||
assert first['total'] == second['total'] == 4
|
||||
assert len(first['list']) == len(second['list']) == 2
|
||||
@@ -269,3 +269,21 @@ def test_save_response_stop_details_keep_current_read(case):
|
||||
# 装送两侧均有校验读与最终响应读,均须保留当前读。
|
||||
assert len(statements) == 4
|
||||
assert all('FOR UPDATE' in sql for sql in statements)
|
||||
|
||||
|
||||
def test_keyword_and_plan_number_filter_are_combined(case):
|
||||
c = case
|
||||
assert c.svc.list_documents(c.db, c.user, keyword='P', plan_no='P')['total'] == 1
|
||||
assert c.svc.list_documents(c.db, c.user, keyword='P', plan_no='OTHER')['total'] == 0
|
||||
|
||||
|
||||
def test_delivery_details_follow_sequence_before_legacy_order(case):
|
||||
c = case
|
||||
c.db.get(c.m.PlPlanDelivery, 'd1').seq = 2
|
||||
c.db.get(c.m.PlPlanDelivery, 'd1').order_num = 1
|
||||
c.db.get(c.m.PlPlanDelivery, 'd2').seq = 1
|
||||
c.db.get(c.m.PlPlanDelivery, 'd2').order_num = 2
|
||||
c.db.flush()
|
||||
rows = c.svc.get_documents(c.db, c.user, 'p')['items']
|
||||
assert [row['stopProductId'] for row in rows] == ['lp', 'd2p', 'd1p']
|
||||
assert [row['stopOrder'] for row in rows] == [1, 1, 2]
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -159,7 +159,70 @@ export const quotaCertApi = {
|
||||
request.get(`${base}/${kind}/${id}/contracts`).then(pickBxData<ContractOption[]>)
|
||||
}
|
||||
const legalBase = '/transport/legal-use-documents'
|
||||
export interface StopLegalDocumentItem {
|
||||
stopType: 'load' | 'delivery'
|
||||
stopId: string
|
||||
stopOrder: number
|
||||
stopProductId: string
|
||||
productId: string
|
||||
productName: string
|
||||
model?: string
|
||||
unit?: string
|
||||
contractId?: string | null
|
||||
contractNo?: string | null
|
||||
contractProductId?: string | null
|
||||
partyName?: string | null
|
||||
address?: string | null
|
||||
legalUseCertUrl: string
|
||||
documentStatus: 'pending' | 'uploaded'
|
||||
uploadedBy?: string | null
|
||||
uploadedTime?: string | null
|
||||
}
|
||||
export interface StopLegalDocument {
|
||||
id: string
|
||||
planNo: string
|
||||
planDate?: string
|
||||
licensePlate?: string
|
||||
transportStatus: string
|
||||
archiveStatus: string
|
||||
requiredCount: number
|
||||
uploadedCount: number
|
||||
documentStatus: 'pending' | 'uploaded' | 'not_required'
|
||||
items: StopLegalDocumentItem[]
|
||||
legacyProducts: {
|
||||
id: string
|
||||
productId: string
|
||||
productName: string
|
||||
model?: string
|
||||
legalUseCertUrl?: string | null
|
||||
}[]
|
||||
}
|
||||
export const legalDocumentApi = {
|
||||
listStops: (params: {
|
||||
keyword?: string
|
||||
planNo?: string
|
||||
status?: 'pending' | 'uploaded'
|
||||
contractId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) =>
|
||||
request
|
||||
.get(`${legalBase}/stops`, { params })
|
||||
.then(
|
||||
pickBxData<{ list: StopLegalDocument[]; total: number; page: number; pageSize: number }>
|
||||
),
|
||||
getStops: (id: string, contractId?: string) =>
|
||||
request
|
||||
.get(`${legalBase}/stops/${id}`, { params: { contractId } })
|
||||
.then(pickBxData<StopLegalDocument>),
|
||||
saveStops: (
|
||||
id: string,
|
||||
items: Pick<StopLegalDocumentItem, 'stopType' | 'stopProductId' | 'legalUseCertUrl'>[],
|
||||
contractId?: string
|
||||
) =>
|
||||
request
|
||||
.patch(`${legalBase}/stops/${id}`, { items }, { params: { contractId } })
|
||||
.then(pickBxData<StopLegalDocument>),
|
||||
list: (params: { keyword?: string; status?: string }) =>
|
||||
request.get(legalBase, { params }).then(pickBxData<LegalDocument[]>),
|
||||
get: (id: string) => request.get(`${legalBase}/${id}`).then(pickBxData<LegalDocument>),
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
|
||||
<div v-if="!embedded" class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
|
||||
<div class="list-toolbar"
|
||||
><div class="list-toolbar-row">
|
||||
<ListQuickSearchInput
|
||||
v-model="keyword"
|
||||
placeholder="计划编号"
|
||||
@search="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-select
|
||||
v-model="status"
|
||||
clearable
|
||||
placeholder="上传情况"
|
||||
aria-label="上传情况"
|
||||
style="width: 150px"
|
||||
@change="search"
|
||||
>
|
||||
<el-option label="待上传" value="pending" /><el-option label="已上传" value="uploaded" />
|
||||
</el-select>
|
||||
<el-button v-if="keyword || status || hasActiveFilters" @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
|
||||
>
|
||||
<ListToolbarEndActions
|
||||
:loading="loading"
|
||||
:column-defs="columns"
|
||||
v-model:visible-columns="visibleColumns"
|
||||
v-model:check-all="checkAll"
|
||||
:is-indeterminate="isIndeterminate"
|
||||
@refresh="load"
|
||||
@reset-columns="resetToDefault"
|
||||
/> </div
|
||||
></div>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false"
|
||||
><el-button @click="allowed ? load() : init()"
|
||||
><el-icon><Refresh /></el-icon>重试</el-button
|
||||
></el-alert
|
||||
>
|
||||
<el-empty v-if="!allowed && !loading && !error" description="无合法使用证明查看权限" />
|
||||
<div v-else class="list-body list-wrap"
|
||||
><div ref="tableWrapRef" class="table-scroll">
|
||||
<ListTable
|
||||
ref="tableRef"
|
||||
table-key="pl-stop-documents"
|
||||
:height="tableHeight"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="list-table"
|
||||
v-loading="loading"
|
||||
@expand-change="expand"
|
||||
>
|
||||
<el-table-column type="expand"
|
||||
><template #default="{ row }">
|
||||
<div
|
||||
class="view-section documents-expansion"
|
||||
:style="expansionStyle"
|
||||
v-loading="details[row.id]?.loading"
|
||||
>
|
||||
<el-alert
|
||||
v-if="details[row.id]?.error"
|
||||
title="资料加载失败"
|
||||
type="error"
|
||||
:closable="false"
|
||||
><el-button @click="loadDetail(row)"
|
||||
><el-icon><Refresh /></el-icon>重试</el-button
|
||||
></el-alert
|
||||
>
|
||||
<template v-else-if="details[row.id]?.data">
|
||||
<el-table
|
||||
:data="details[row.id].data!.items"
|
||||
:row-key="itemKey"
|
||||
stripe
|
||||
class="list-table"
|
||||
>
|
||||
<el-table-column label="运输段" min-width="100"
|
||||
><template #default="{ row: item }"
|
||||
><el-tag size="small"
|
||||
>{{ item.stopType === 'load' ? '装货' : '送货' }}
|
||||
{{ item.stopOrder }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="productName" label="产品" min-width="140" />
|
||||
<el-table-column prop="model" label="规格" min-width="100" />
|
||||
<el-table-column prop="unit" label="单位" min-width="80" />
|
||||
<el-table-column prop="contractNo" label="合同编号" min-width="170" />
|
||||
<el-table-column prop="partyName" label="本段主体" min-width="180" />
|
||||
<el-table-column prop="address" label="本段位置" min-width="220" />
|
||||
<el-table-column label="上传情况" min-width="110"
|
||||
><template #default="{ row: item }"
|
||||
><el-tag size="small" :type="statusType(item.documentStatus)">{{
|
||||
statusLabel(item.documentStatus)
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="附件" min-width="220"
|
||||
><template #default="{ row: item }"
|
||||
><AttachmentUploadField
|
||||
:model-value="item.legalUseCertUrl || ''"
|
||||
disabled
|
||||
compact /></template
|
||||
></el-table-column>
|
||||
<el-table-column prop="uploadedTime" label="上传时间" min-width="170" />
|
||||
<el-table-column v-if="canUpload" label="操作" min-width="260" fixed="right"
|
||||
><template #default="{ row: item }"
|
||||
><el-button @click="open(row, item)"
|
||||
><el-icon><Document /></el-icon>维护本段资料</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<section v-if="details[row.id].data!.legacyProducts.length" class="view-section">
|
||||
<h3>历史计划级资料</h3
|
||||
><p class="form-tip">历史附件仅供查看,不计入运输段上传情况。</p>
|
||||
<div v-for="product in details[row.id].data!.legacyProducts" :key="product.id">
|
||||
<p>{{ product.productName }} {{ product.model || '' }}</p>
|
||||
<AttachmentUploadField
|
||||
:model-value="product.legalUseCertUrl || ''"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div> </template
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
v-for="column in visibleDefs"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
min-width="150"
|
||||
>
|
||||
<template #header
|
||||
><div class="col-header-with-filter"
|
||||
><span class="col-header-with-filter__label">{{ column.label }}</span
|
||||
><ColumnHeaderFilter
|
||||
v-if="column.prop === 'planNo'"
|
||||
prop="planNo"
|
||||
label="计划编号"
|
||||
type="text"
|
||||
:enable-sort="false"
|
||||
:model-value="filters.planNo || null"
|
||||
:is-active="isFilterActive('planNo')"
|
||||
@update:model-value="(value) => setFilterState('planNo', value)" /></div
|
||||
></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="column.prop === 'documentStatus'"
|
||||
size="small"
|
||||
:type="statusType(row.documentStatus)"
|
||||
>{{ statusLabel(row.documentStatus) }}</el-tag
|
||||
>
|
||||
<span v-else-if="column.prop === 'progress'"
|
||||
>{{ row.uploadedCount }} / {{ row.requiredCount }}</span
|
||||
>
|
||||
<span v-else>{{ row[column.prop] || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable> </div
|
||||
><div ref="pagerRef" class="pagination list-pager-bar"
|
||||
><el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="20"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load" /></div
|
||||
></div>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="本段产品合法使用证明"
|
||||
width="720px"
|
||||
align-center
|
||||
append-to-body
|
||||
class="dialog-form-layout"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div v-loading="editorLoading">
|
||||
<el-alert v-if="editorError" :title="editorError" type="error" :closable="false" />
|
||||
<template v-if="editing"
|
||||
><p
|
||||
>{{ editing.stopType === 'load' ? '装货' : '送货' }} {{ editing.stopOrder }} ·
|
||||
{{ editing.productName }} {{ editing.model }}</p
|
||||
><p
|
||||
>{{ editing.contractNo || '未关联合同' }} · {{ editing.partyName }} ·
|
||||
{{ editing.address }}</p
|
||||
>
|
||||
<el-form label-position="top"
|
||||
><el-form-item label="合法使用证明"
|
||||
><AttachmentUploadField
|
||||
:key="editorEpoch"
|
||||
ref="upload"
|
||||
v-model="editing.legalUseCertUrl"
|
||||
folder="pl/legal-use-document"
|
||||
:disabled="!canUpload || saving" /></el-form-item
|
||||
></el-form>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer
|
||||
><el-button @click="visible = false"
|
||||
><el-icon><Close /></el-icon>关闭</el-button
|
||||
><el-button
|
||||
v-if="canUpload"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="editorLoading || !editing || !!upload?.hasUploading()"
|
||||
@click="save"
|
||||
><el-icon><Check /></el-icon>保存本段资料</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Document, Refresh, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
legalDocumentApi,
|
||||
type StopLegalDocument,
|
||||
type StopLegalDocumentItem
|
||||
} from '@/api/pl/quota-cert'
|
||||
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
|
||||
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
|
||||
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
|
||||
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
|
||||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||||
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
|
||||
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(),
|
||||
{ active: true }
|
||||
)
|
||||
const user = useUserStore()
|
||||
const allowed = ref(false),
|
||||
canUpload = ref(false),
|
||||
loading = ref(false),
|
||||
error = ref(''),
|
||||
keyword = ref(''),
|
||||
status = ref<'' | 'pending' | 'uploaded'>(''),
|
||||
page = ref(1),
|
||||
total = ref(0)
|
||||
const rows = ref<StopLegalDocument[]>([])
|
||||
const details = reactive<
|
||||
Record<string, { loading: boolean; error: boolean; data?: StopLegalDocument }>
|
||||
>({})
|
||||
const visible = ref(false),
|
||||
saving = ref(false),
|
||||
editorLoading = ref(false),
|
||||
editorError = ref(''),
|
||||
editorEpoch = ref(0)
|
||||
const editing = ref<StopLegalDocumentItem>(),
|
||||
editingPlanId = ref('')
|
||||
const upload = ref<InstanceType<typeof AttachmentUploadField>>()
|
||||
let authEpoch = 0,
|
||||
listEpoch = 0
|
||||
const columns: ColumnFilterDef[] = [
|
||||
['planNo', '计划编号'],
|
||||
['planDate', '计划日期'],
|
||||
['licensePlate', '车牌号'],
|
||||
['progress', '已上传 / 应上传'],
|
||||
['documentStatus', '上传情况']
|
||||
].map(([prop, label]) => ({ prop, label, type: 'text', queryKey: prop }))
|
||||
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
|
||||
'pl-stop-documents-v1',
|
||||
columns
|
||||
)
|
||||
const visibleDefs = computed(() => columns.filter((c) => visibleColumns.value.includes(c.prop)))
|
||||
const {
|
||||
filters,
|
||||
hasActiveFilters,
|
||||
setFilterState,
|
||||
clearAllFilters,
|
||||
isFilterActive,
|
||||
toQueryParams,
|
||||
watchVisibleColumns
|
||||
} = useServerColumnFilters(
|
||||
columns.filter((c) => c.prop === 'planNo'),
|
||||
{ onChange: search }
|
||||
)
|
||||
watchVisibleColumns(visibleColumns)
|
||||
const statusLabel = (value: string) =>
|
||||
value === 'uploaded' ? '已上传' : value === 'not_required' ? '无需上传' : '待上传'
|
||||
const statusType = (value: string) =>
|
||||
value === 'uploaded' ? 'success' : value === 'not_required' ? 'info' : 'warning'
|
||||
const itemKey = (item: StopLegalDocumentItem) => `${item.stopType}:${item.stopProductId}`
|
||||
function clearDetails() {
|
||||
Object.keys(details).forEach((key) => delete details[key])
|
||||
}
|
||||
function closeEditor() {
|
||||
editorEpoch.value++
|
||||
editing.value = undefined
|
||||
editingPlanId.value = ''
|
||||
upload.value = undefined
|
||||
saving.value = false
|
||||
editorLoading.value = false
|
||||
editorError.value = ''
|
||||
}
|
||||
watch(
|
||||
visible,
|
||||
(value) => {
|
||||
if (!value) closeEditor()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
async function load() {
|
||||
if (!allowed.value || !props.active) return
|
||||
const current = ++listEpoch
|
||||
rows.value = []
|
||||
clearDetails()
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await legalDocumentApi.listStops({
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value,
|
||||
...toQueryParams(),
|
||||
status: status.value || undefined,
|
||||
contractId: props.contractId
|
||||
})
|
||||
if (current !== listEpoch) return
|
||||
rows.value = data.list
|
||||
total.value = data.total
|
||||
} catch {
|
||||
if (current === listEpoch) error.value = '资料加载失败,请重试'
|
||||
} finally {
|
||||
if (current === listEpoch) loading.value = false
|
||||
}
|
||||
}
|
||||
async function loadDetail(row: Pick<StopLegalDocument, 'id'>) {
|
||||
if (!allowed.value || !props.active) return
|
||||
const current = listEpoch,
|
||||
state = reactive({
|
||||
loading: true,
|
||||
error: false,
|
||||
data: undefined as StopLegalDocument | undefined
|
||||
})
|
||||
details[row.id] = state
|
||||
try {
|
||||
const result = await legalDocumentApi.getStops(row.id, props.contractId)
|
||||
if (current === listEpoch && details[row.id] === state) state.data = result
|
||||
} catch {
|
||||
if (current === listEpoch && details[row.id] === state) state.error = true
|
||||
} finally {
|
||||
if (current === listEpoch && details[row.id] === state) state.loading = false
|
||||
}
|
||||
}
|
||||
function expand(row: StopLegalDocument, expanded: StopLegalDocument[]) {
|
||||
if (expanded.some((r) => r.id === row.id) && !details[row.id]) void loadDetail(row)
|
||||
}
|
||||
async function open(
|
||||
row: Pick<StopLegalDocument, 'id'>,
|
||||
item: Pick<StopLegalDocumentItem, 'stopType' | 'stopProductId'>
|
||||
) {
|
||||
if (!allowed.value || !canUpload.value || !props.active) return
|
||||
closeEditor()
|
||||
visible.value = true
|
||||
editorLoading.value = true
|
||||
const current = editorEpoch.value
|
||||
try {
|
||||
const result = await legalDocumentApi.getStops(row.id, props.contractId)
|
||||
if (current !== editorEpoch.value) return
|
||||
const match = result.items.find(
|
||||
(candidate) => itemKey(candidate) === itemKey(item as StopLegalDocumentItem)
|
||||
)
|
||||
if (!match) {
|
||||
editorError.value = '本段产品已变更,请刷新列表'
|
||||
return
|
||||
}
|
||||
editing.value = { ...match, legalUseCertUrl: match.legalUseCertUrl || '' }
|
||||
editingPlanId.value = row.id
|
||||
} catch {
|
||||
if (current === editorEpoch.value) editorError.value = '资料加载失败,请关闭后重试'
|
||||
} finally {
|
||||
if (current === editorEpoch.value) editorLoading.value = false
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (
|
||||
!visible.value ||
|
||||
!editing.value ||
|
||||
!allowed.value ||
|
||||
!canUpload.value ||
|
||||
saving.value ||
|
||||
editorLoading.value
|
||||
)
|
||||
return
|
||||
if (upload.value?.hasUploading()) {
|
||||
ElMessage.warning('请等待附件上传完成')
|
||||
return
|
||||
}
|
||||
const current = editorEpoch.value,
|
||||
item = editing.value
|
||||
saving.value = true
|
||||
editorError.value = ''
|
||||
try {
|
||||
await legalDocumentApi.saveStops(
|
||||
editingPlanId.value,
|
||||
[
|
||||
{
|
||||
stopType: item.stopType,
|
||||
stopProductId: item.stopProductId,
|
||||
legalUseCertUrl: item.legalUseCertUrl
|
||||
}
|
||||
],
|
||||
props.contractId
|
||||
)
|
||||
if (current !== editorEpoch.value) return
|
||||
ElMessage.success('本段资料已保存')
|
||||
visible.value = false
|
||||
void load()
|
||||
} catch {
|
||||
if (current === editorEpoch.value) editorError.value = '保存失败,已保留填写内容,请重试'
|
||||
} finally {
|
||||
if (current === editorEpoch.value) saving.value = false
|
||||
}
|
||||
}
|
||||
function search() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
status.value = ''
|
||||
clearAllFilters()
|
||||
search()
|
||||
}
|
||||
async function init() {
|
||||
const current = ++authEpoch
|
||||
listEpoch++
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
page.value = 1
|
||||
clearDetails()
|
||||
visible.value = false
|
||||
closeEditor()
|
||||
allowed.value = false
|
||||
canUpload.value = false
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
if (!props.active || !user.accessToken) return
|
||||
loading.value = true
|
||||
try {
|
||||
const permissions = await fetchGetMenuCodes()
|
||||
if (current !== authEpoch) return
|
||||
const codes = permissions.menuCodes || [],
|
||||
all = !!permissions.all || !!permissions.isSuperAdmin
|
||||
allowed.value = all || codes.includes('pl:legal-use-document:list')
|
||||
canUpload.value = allowed.value && (all || codes.includes('pl:plan-cert:upload'))
|
||||
loading.value = false
|
||||
await load()
|
||||
} catch {
|
||||
if (current === authEpoch) {
|
||||
loading.value = false
|
||||
error.value = '权限加载失败,请重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.contractId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
|
||||
() => void init(),
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
onBeforeUnmount(() => {
|
||||
authEpoch++
|
||||
listEpoch++
|
||||
closeEditor()
|
||||
allowed.value = false
|
||||
canUpload.value = false
|
||||
rows.value = []
|
||||
clearDetails()
|
||||
})
|
||||
const tableWrapRef = ref<HTMLElement | null>(null),
|
||||
pagerRef = ref<HTMLElement | null>(null),
|
||||
tableRef = ref()
|
||||
const { width: tableViewportWidth } = useElementSize(tableWrapRef)
|
||||
const expansionStyle = computed(() => ({
|
||||
width: tableViewportWidth.value > 0 ? `${tableViewportWidth.value}px` : undefined
|
||||
}))
|
||||
const { tableHeight } = useListTableViewportHeight({
|
||||
tableEl: tableWrapRef,
|
||||
pagerEl: pagerRef,
|
||||
tableRef,
|
||||
enabled: computed(() => !props.embedded)
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.documents-expansion {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -1,356 +1,6 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
|
||||
<div class="list-toolbar"
|
||||
><div class="list-toolbar-row"
|
||||
><ListQuickSearchInput
|
||||
v-model="keyword"
|
||||
placeholder="计划编号"
|
||||
@search="search"
|
||||
@clear="search" /><el-button v-if="hasActiveFilters || keyword" @click="reset"
|
||||
><el-icon><RefreshLeft /></el-icon>清除筛选</el-button
|
||||
><ListToolbarEndActions
|
||||
:loading="loading"
|
||||
:column-defs="listColumnDefs"
|
||||
v-model:visible-columns="visibleColumns"
|
||||
v-model:check-all="checkAll"
|
||||
:is-indeterminate="isIndeterminate"
|
||||
@refresh="load"
|
||||
@reset-columns="resetToDefault" /></div
|
||||
></div>
|
||||
<div class="list-body list-wrap"
|
||||
><div ref="tableWrapRef" class="table-scroll"
|
||||
><ListTable
|
||||
ref="tableRef"
|
||||
:height="tableHeight"
|
||||
table-key="pl-legal-use-document"
|
||||
v-loading="loading"
|
||||
:data="pageRows"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="list-table"
|
||||
@row-dblclick="open"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in visibleDefs"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:min-width="column.type === 'text' ? 190 : 140"
|
||||
><template #header
|
||||
><div class="col-header-with-filter"
|
||||
><span class="col-header-with-filter__label">{{ column.label }}</span>
|
||||
<ColumnHeaderFilter
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:type="column.type"
|
||||
:enable-sort="false"
|
||||
:options="collectUniqueOptions(column.prop)"
|
||||
:is-active="isFilterActive(column.prop)"
|
||||
:model-value="filters[column.prop] || null"
|
||||
@update:model-value="(v) => setFilterState(column.prop, v)"
|
||||
/> </div></template
|
||||
><template #default="{ row }"
|
||||
><el-link v-if="column.prop === 'planNo'" type="primary" @click="open(row)">{{
|
||||
row.planNo
|
||||
}}</el-link
|
||||
><span v-else-if="column.prop === 'productNames'">{{ productNames(row) }}</span
|
||||
><el-tag v-else size="small" :type="pendingCount(row) ? 'warning' : 'success'">{{
|
||||
column.prop === 'documentStatus'
|
||||
? pendingCount(row)
|
||||
? '待上传'
|
||||
: '已上传'
|
||||
: `${pendingCount(row)} / ${row.products.length}`
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
><el-table-column label="操作" min-width="260" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button @click="open(row)"
|
||||
><el-icon><Document /></el-icon
|
||||
>{{ canUpload ? '维护资料' : '查看资料' }}</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</ListTable></div
|
||||
><div ref="pagerRef" class="pagination list-pager-bar"
|
||||
><el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="20"
|
||||
:total="filteredRows.length"
|
||||
layout="total, prev, pager, next" /></div
|
||||
></div>
|
||||
</section>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="`合法使用证明 · ${detail?.planNo || ''}`"
|
||||
width="900px"
|
||||
align-center
|
||||
class="dialog-form-layout"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
>
|
||||
<section v-for="p in detail?.products || []" :key="p.id" class="view-section">
|
||||
<h3>{{ p.productName }} {{ p.model || '' }}</h3>
|
||||
<p v-for="context in p.contexts || []" :key="context.stopProductId"
|
||||
>{{ context.stopType === 'load' ? '装货' : '送货' }}:{{
|
||||
context.partyName || '未填写单位'
|
||||
}}
|
||||
· {{ context.address || '未填写地址' }} · 合同
|
||||
{{ context.contractNo || '未关联或无查看权限' }}</p
|
||||
>
|
||||
<el-tag size="small" :type="p.legalUseCertUrl ? 'success' : 'warning'">{{
|
||||
p.legalUseCertUrl ? '已上传' : '待上传'
|
||||
}}</el-tag>
|
||||
<AttachmentUploadField
|
||||
:ref="(el) => setUpload(p.id, el)"
|
||||
v-model="p.legalUseCertUrl"
|
||||
folder="pl/legal-use-document"
|
||||
:disabled="!canUpload || saving"
|
||||
/>
|
||||
</section>
|
||||
<template #footer
|
||||
><el-button :disabled="saving" @click="visible = false"
|
||||
><el-icon><Close /></el-icon>关闭</el-button
|
||||
><el-button v-if="canUpload" type="primary" :loading="saving" @click="save"
|
||||
><el-icon><Check /></el-icon>保存资料</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div class="page-container"><StopDocuments /></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
|
||||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||||
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
|
||||
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
|
||||
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
|
||||
import {
|
||||
useClientColumnFilters,
|
||||
type ColumnFilterDef,
|
||||
type ColumnFilterState,
|
||||
type TextColumnFilterState
|
||||
} from '@/composables/useClientColumnFilters'
|
||||
|
||||
import { Check, Close, Document, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { legalDocumentApi, type LegalDocument } from '@/api/pl/quota-cert'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
const page = ref(1)
|
||||
const keyword = ref('')
|
||||
const loading = ref(false),
|
||||
saving = ref(false),
|
||||
visible = ref(false),
|
||||
canUpload = ref(false)
|
||||
const rows = ref<LegalDocument[]>([]),
|
||||
detail = ref<LegalDocument>()
|
||||
let listEpoch = 0
|
||||
let disposed = false
|
||||
async function load() {
|
||||
if (disposed) return
|
||||
const current = ++listEpoch
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await legalDocumentApi.list({})
|
||||
if (current !== listEpoch || disposed) return
|
||||
rows.value = result
|
||||
} finally {
|
||||
if (current === listEpoch && !disposed) loading.value = false
|
||||
}
|
||||
}
|
||||
const uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
|
||||
function setUpload(id: string, value: any) {
|
||||
if (value) uploads.set(id, value)
|
||||
else uploads.delete(id)
|
||||
}
|
||||
let session = 0
|
||||
watch(
|
||||
visible,
|
||||
(value) => {
|
||||
if (!value) session++
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
async function open(row: LegalDocument) {
|
||||
if (saving.value) return
|
||||
const current = ++session
|
||||
const result = await legalDocumentApi.get(row.id)
|
||||
if (current !== session) return
|
||||
detail.value = result
|
||||
visible.value = true
|
||||
}
|
||||
async function save() {
|
||||
if (!detail.value || !canUpload.value || saving.value) return
|
||||
if ([...uploads.values()].some((upload) => upload.hasUploading())) {
|
||||
ElMessage.warning('请等待附件上传完成')
|
||||
return
|
||||
}
|
||||
const current = session,
|
||||
targetId = detail.value.id
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await legalDocumentApi.save(
|
||||
targetId,
|
||||
detail.value.products.map((p) => ({ id: p.id, legalUseCertUrl: p.legalUseCertUrl }))
|
||||
)
|
||||
if (current !== session || detail.value?.id !== targetId) return
|
||||
detail.value = result
|
||||
ElMessage.success('合法使用证明已保存')
|
||||
visible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
const user = useUserStore()
|
||||
let authEpoch = 0
|
||||
async function init() {
|
||||
const current = ++authEpoch
|
||||
const p = await fetchGetMenuCodes()
|
||||
if (current !== authEpoch || disposed) return
|
||||
canUpload.value =
|
||||
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:plan-cert:upload')
|
||||
await load()
|
||||
}
|
||||
watch(
|
||||
() => JSON.stringify([user.accessToken, user.getUserInfo]),
|
||||
() => {
|
||||
authEpoch++
|
||||
listEpoch++
|
||||
rows.value = []
|
||||
page.value = 1
|
||||
loading.value = false
|
||||
canUpload.value = false
|
||||
session++
|
||||
visible.value = false
|
||||
detail.value = undefined
|
||||
if (user.accessToken) void init()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
onMounted(init)
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
listEpoch++
|
||||
authEpoch++
|
||||
})
|
||||
|
||||
const productNames = (row: LegalDocument) => row.products.map((p) => p.productName).join('、')
|
||||
const pendingCount = (row: LegalDocument) =>
|
||||
row.products.filter((p) => p.documentStatus === 'pending').length
|
||||
const listColumnDefs: ColumnFilterDef[] = [
|
||||
{ prop: 'planNo', label: '计划编号', type: 'text' },
|
||||
{ prop: 'productNames', label: '易制爆产品', type: 'text', getValue: productNames },
|
||||
{ prop: 'pendingCount', label: '待补明细', type: 'number', getValue: pendingCount },
|
||||
{
|
||||
prop: 'documentStatus',
|
||||
label: '上传情况',
|
||||
type: 'enum',
|
||||
enumOptions: [
|
||||
{ value: 'pending', label: '待上传' },
|
||||
{ value: 'uploaded', label: '已上传' }
|
||||
]
|
||||
}
|
||||
]
|
||||
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
|
||||
'pl-legal-use-document-columns-v1',
|
||||
listColumnDefs
|
||||
)
|
||||
const visibleDefs = computed(() =>
|
||||
listColumnDefs.filter((c) => visibleColumns.value.includes(c.prop))
|
||||
)
|
||||
const appliedKeyword = ref('')
|
||||
const searchedRows = computed(() =>
|
||||
rows.value.filter((row) => String(row.planNo).includes(appliedKeyword.value))
|
||||
)
|
||||
const {
|
||||
filters,
|
||||
filteredRows: columnFilteredRows,
|
||||
collectUniqueOptions: collectColumnOptions,
|
||||
hasActiveFilters: hasColumnFilters,
|
||||
setFilterState: setColumnFilter,
|
||||
clearAllFilters,
|
||||
clearFilter,
|
||||
isFilterActive: isColumnFilterActive
|
||||
} = useClientColumnFilters(
|
||||
searchedRows,
|
||||
listColumnDefs.filter((column) => column.prop !== 'documentStatus')
|
||||
)
|
||||
const selectedStatuses = computed(
|
||||
() => (filters.documentStatus as TextColumnFilterState | undefined)?.selected || []
|
||||
)
|
||||
const filteredRows = computed(() =>
|
||||
columnFilteredRows.value.filter(
|
||||
(row: LegalDocument) =>
|
||||
!selectedStatuses.value.length ||
|
||||
row.products.some((product) => selectedStatuses.value.includes(product.documentStatus))
|
||||
)
|
||||
)
|
||||
const hasActiveFilters = computed(
|
||||
() => hasColumnFilters.value || selectedStatuses.value.length > 0
|
||||
)
|
||||
function setFilterState(prop: string, state: ColumnFilterState | null) {
|
||||
if (prop !== 'documentStatus') return setColumnFilter(prop, state)
|
||||
const selected =
|
||||
(state as TextColumnFilterState | null)?.selected?.filter((value) =>
|
||||
['pending', 'uploaded'].includes(value)
|
||||
) || []
|
||||
if (selected.length) filters.documentStatus = { text: '', selected: [...selected] }
|
||||
else clearFilter(prop)
|
||||
}
|
||||
function isFilterActive(prop: string) {
|
||||
return prop === 'documentStatus'
|
||||
? selectedStatuses.value.length > 0
|
||||
: isColumnFilterActive(prop)
|
||||
}
|
||||
function collectUniqueOptions(prop: string) {
|
||||
if (prop !== 'documentStatus') return collectColumnOptions(prop)
|
||||
return [
|
||||
{ value: 'pending', label: '待上传' },
|
||||
{ value: 'uploaded', label: '已上传' }
|
||||
].map((option) => ({
|
||||
...option,
|
||||
count: searchedRows.value.filter((row) =>
|
||||
row.products.some((product) => product.documentStatus === option.value)
|
||||
).length
|
||||
}))
|
||||
}
|
||||
const pageRows = computed(() => filteredRows.value.slice((page.value - 1) * 20, page.value * 20))
|
||||
function search() {
|
||||
appliedKeyword.value = keyword.value.trim()
|
||||
page.value = 1
|
||||
}
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
appliedKeyword.value = ''
|
||||
clearAllFilters()
|
||||
page.value = 1
|
||||
}
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
page.value = 1
|
||||
},
|
||||
{ deep: true, flush: 'sync' }
|
||||
)
|
||||
watch(visibleColumns, (next, prev) => {
|
||||
prev.filter((p) => !next.includes(p)).forEach(clearFilter)
|
||||
})
|
||||
watch(
|
||||
() => filteredRows.value.length,
|
||||
(total) => {
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(total / 20)))
|
||||
}
|
||||
)
|
||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||
const pagerRef = ref<HTMLElement | null>(null)
|
||||
const tableRef = ref()
|
||||
const { tableHeight } = useListTableViewportHeight({
|
||||
tableEl: tableWrapRef,
|
||||
pagerEl: pagerRef,
|
||||
tableRef
|
||||
})
|
||||
import StopDocuments from './StopDocuments.vue'
|
||||
</script>
|
||||
Reference in new issue
Block a user