feat(pl): 增加合同与证件用量视图以追踪共享额度及段产品使用。
This commit is contained in:
1 parent
ce6f102dae
commit
0d1d264bc7
18 files changed
+1017
-16
No files matched your search
@@ -0,0 +1,36 @@
|
||||
-- 工单 #4:Web 用证管理五个视图;MySQL 5.7。保留菜单身份与授权,不恢复删除项。
|
||||
SET NAMES utf8mb4;
|
||||
START TRANSACTION;
|
||||
INSERT INTO sys_menu (
|
||||
id,menu_name,parent_id,menu_type,path,component,perms,is_frame,visible,status,
|
||||
tenant_no,order_num,remark,create_by,create_dept_id,create_time,update_time,del_flag
|
||||
)
|
||||
SELECT target.id,target.name,COALESCE(folder.id,parent.id),'C',target.path,target.component,'pl:quota-cert:list',1,'1','1',
|
||||
parent.tenant_no,target.sort_no,'工单#4:按合同与证件统计;不自动授权','system','',NOW(),NOW(),0
|
||||
FROM sys_menu parent
|
||||
JOIN sys_menu root ON root.id=parent.parent_id AND root.tenant_no=parent.tenant_no AND root.del_flag=0
|
||||
LEFT JOIN sys_menu folder ON folder.id='PL_GROUP_CERT' AND folder.parent_id=parent.id
|
||||
AND folder.tenant_no=parent.tenant_no AND folder.del_flag=0
|
||||
CROSS JOIN (
|
||||
SELECT 'PL_CONTRACT_QUOTA' id,'合同用证' name,'contract-quota' path,'pl/contract-quota/index' component,2 sort_no
|
||||
UNION ALL SELECT 'PL_CERTIFICATE_USAGE','证件用量','certificate-usage','pl/certificate-usage/index',3
|
||||
) target
|
||||
WHERE parent.id='PL_MENU_ROOT' AND parent.del_flag=0 AND parent.tenant_no='shanxia2.0'
|
||||
AND root.path='/web' AND root.parent_id='0'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.id=target.id
|
||||
OR (existing.tenant_no=parent.tenant_no AND existing.path=target.path));
|
||||
|
||||
UPDATE sys_menu leaf
|
||||
JOIN sys_menu parent ON parent.id='PL_MENU_ROOT' AND parent.tenant_no=leaf.tenant_no AND parent.del_flag=0
|
||||
JOIN sys_menu root ON root.id=parent.parent_id AND root.tenant_no=parent.tenant_no AND root.del_flag=0
|
||||
LEFT JOIN sys_menu folder ON folder.id=leaf.parent_id AND folder.id='PL_GROUP_CERT'
|
||||
AND folder.parent_id=parent.id AND folder.tenant_no=leaf.tenant_no AND folder.del_flag=0
|
||||
SET leaf.menu_name=CASE leaf.id WHEN 'PL_QUOTA_CERT' THEN '证件建档'
|
||||
WHEN 'PL00000000000000000000000000015' THEN '计划段次用证' ELSE '合法使用证明' END,
|
||||
leaf.order_num=CASE leaf.id WHEN 'PL_QUOTA_CERT' THEN 1 WHEN 'PL00000000000000000000000000015' THEN 4 ELSE 5 END,
|
||||
leaf.update_time=NOW()
|
||||
WHERE leaf.tenant_no='shanxia2.0' AND leaf.del_flag=0
|
||||
AND root.path='/web' AND root.parent_id='0'
|
||||
AND (leaf.parent_id=parent.id OR folder.id IS NOT NULL)
|
||||
AND leaf.id IN ('PL_QUOTA_CERT','PL00000000000000000000000000015','PL_LEGAL_USE_DOCUMENT');
|
||||
COMMIT;
|
||||
@@ -0,0 +1,263 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createStatisticsLoader } from '../src/views/pl/quota-statistics/loader'
|
||||
test('fixed contract skips global list and retains each decimal balance', async () => {
|
||||
const calls: string[] = []
|
||||
const loader = createStatisticsLoader({
|
||||
contracts: async () => {
|
||||
calls.push('global')
|
||||
},
|
||||
contract: async (id: string) => {
|
||||
calls.push(id)
|
||||
return { certificates: [{ availableQty: '0.10000001' }, { availableQty: '7.2' }] }
|
||||
}
|
||||
} as any)
|
||||
const result = await loader.load({ mode: 'contract', contractId: 'c1', allowed: true })
|
||||
assert.deepEqual(calls, ['c1'])
|
||||
assert.deepEqual(
|
||||
result.certificates.map((c: any) => c.availableQty),
|
||||
['0.10000001', '7.2']
|
||||
)
|
||||
})
|
||||
test('permission denied never requests statistics', async () => {
|
||||
const loader = createStatisticsLoader(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => () => {
|
||||
throw Error('unexpected request')
|
||||
}
|
||||
}
|
||||
) as any
|
||||
)
|
||||
assert.equal(await loader.load({ mode: 'contract', allowed: false }), undefined)
|
||||
})
|
||||
test('context invalidation discards pending response', async () => {
|
||||
let resolve!: (v: any) => void
|
||||
const loader = createStatisticsLoader({
|
||||
contract: () =>
|
||||
new Promise((r) => {
|
||||
resolve = r
|
||||
})
|
||||
} as any)
|
||||
const pending = loader.load({ mode: 'contract', contractId: 'old', allowed: true })
|
||||
loader.invalidate()
|
||||
resolve({ id: 'old' })
|
||||
assert.equal(await pending, undefined)
|
||||
})
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from '@vue/compiler-sfc'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { plRoutes } from '../src/router/modules/pl'
|
||||
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 calls: any[] = [],
|
||||
permissions: any[] = [],
|
||||
unmount: Function[] = []
|
||||
const props = vue.reactive({
|
||||
mode: 'contract',
|
||||
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(
|
||||
['contracts', 'contract', 'certificates', 'certificate'].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
|
||||
},
|
||||
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
|
||||
usePersistedListColumns,
|
||||
useServerColumnFilters,
|
||||
createStatisticsLoader,
|
||||
quotaStatisticsApi: api,
|
||||
onBeforeUnmount: (fn: Function) => unmount.push(fn)
|
||||
}
|
||||
const src = ts.createSourceFile(
|
||||
'panel.ts',
|
||||
parse(
|
||||
readFileSync(
|
||||
new URL('../src/views/pl/quota-statistics/StatisticsPanel.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, fixedDetail, details, load, loadDetail, allowed, canEdit }`
|
||||
)(...Object.values(env))
|
||||
)
|
||||
return {
|
||||
state,
|
||||
calls,
|
||||
permissions,
|
||||
props,
|
||||
user,
|
||||
stop: () => {
|
||||
unmount.forEach((fn) => fn())
|
||||
scope.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
test('five leaves retain IDs and individual permissions', () => {
|
||||
const group = plRoutes[0].children!.find((r) => r.id === 'PL_GROUP_CERT')!
|
||||
assert.deepEqual(
|
||||
group.children!.map((r) => r.path),
|
||||
['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'legal-use-document']
|
||||
)
|
||||
assert.equal(group.children![0].id, 'PL_QUOTA_CERT')
|
||||
assert.equal(group.children![3].id, 'PL00000000000000000000000000015')
|
||||
assert.deepEqual(
|
||||
group.children!.map((r) => r.meta!.menuCode),
|
||||
[
|
||||
'pl:quota-cert:list',
|
||||
'pl:quota-cert:list',
|
||||
'pl:quota-cert:list',
|
||||
'pl:plan-cert:list',
|
||||
'pl:legal-use-document:list'
|
||||
]
|
||||
)
|
||||
})
|
||||
test('actual fixed panel loads only current contract and drops old response', async () => {
|
||||
const h = harness('one')
|
||||
h.permissions[0].resolve({ menuCodes: ['pl:quota-cert:list'] })
|
||||
await flush()
|
||||
assert.equal(h.calls[0].method, 'contract')
|
||||
assert.deepEqual(h.calls[0].args, ['one'])
|
||||
h.props.contractId = 'two'
|
||||
h.permissions[1].resolve({ all: true })
|
||||
await flush()
|
||||
h.calls[1].resolve({ id: 'two', certificates: [] })
|
||||
await flush()
|
||||
h.calls[0].resolve({ id: 'one', certificates: [] })
|
||||
await flush()
|
||||
assert.equal(h.state.fixedDetail.value.id, 'two')
|
||||
h.stop()
|
||||
})
|
||||
for (const change of ['tenant', 'logout', 'unmount'])
|
||||
test(`actual panel invalidates data on ${change}`, async () => {
|
||||
const h = harness()
|
||||
h.permissions[0].resolve({ all: true })
|
||||
await flush()
|
||||
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
|
||||
if (change === 'logout') h.user.accessToken = ''
|
||||
if (change === 'unmount') h.stop()
|
||||
h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 })
|
||||
await flush()
|
||||
assert.deepEqual(h.state.rows.value, [])
|
||||
assert.equal(h.state.allowed.value, false)
|
||||
h.stop()
|
||||
})
|
||||
test('proof-only permission cannot request quota', async () => {
|
||||
const h = harness()
|
||||
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] })
|
||||
await flush()
|
||||
assert.equal(h.calls.length, 0)
|
||||
h.stop()
|
||||
})
|
||||
test('expand failure clears stale data and retry works', async () => {
|
||||
const h = harness()
|
||||
h.permissions[0].resolve({ all: true })
|
||||
await flush()
|
||||
h.calls[0].resolve({ list: [{ id: 'c' }], total: 1 })
|
||||
await flush()
|
||||
const first = h.state.loadDetail({ id: 'c' })
|
||||
h.calls[1].resolve({ id: 'c', certificates: [] })
|
||||
await first
|
||||
assert.equal(h.state.details.c.data.id, 'c')
|
||||
const failure = h.state.loadDetail({ id: 'c' })
|
||||
h.calls[2].reject(Error('failed'))
|
||||
await failure
|
||||
assert.equal(h.state.details.c.data, undefined)
|
||||
assert.equal(h.state.details.c.error, true)
|
||||
const retry = h.state.loadDetail({ id: 'c' })
|
||||
h.calls[3].resolve({ id: 'c', certificates: [] })
|
||||
await retry
|
||||
assert.equal(h.state.details.c.data.id, 'c')
|
||||
h.stop()
|
||||
})
|
||||
test('statistics API maps each view to the server endpoint and preserves response decimals', async () => {
|
||||
const calls: any[] = []
|
||||
const raw = { list: [{ availableQty: '1.00000001' }], total: 1 }
|
||||
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 quotaStatisticsApi`)(
|
||||
{
|
||||
get: (url: string, options: any) => {
|
||||
calls.push({ url, options })
|
||||
return Promise.resolve(raw)
|
||||
}
|
||||
},
|
||||
(v: any) => v
|
||||
)
|
||||
assert.equal(
|
||||
(await api.contracts({ page: 2, keyword: '合同' })).list[0].availableQty,
|
||||
'1.00000001'
|
||||
)
|
||||
await api.certificates({ certType: 'transport', pageSize: 20 })
|
||||
await api.contract('c')
|
||||
await api.certificate('purchase', 'p')
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.url),
|
||||
[
|
||||
'/transport/quota-certs/statistics/contracts',
|
||||
'/transport/quota-certs/statistics/certificates',
|
||||
'/transport/quota-certs/contracts/c/statistics',
|
||||
'/transport/quota-certs/purchase/p/statistics'
|
||||
]
|
||||
)
|
||||
assert.deepEqual(calls[1].options.params, { certType: 'transport', pageSize: 20 })
|
||||
})
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
|
||||
const panelTitleBarClass = 'title-bar-heading'
|
||||
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证)$/]
|
||||
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|合同用证|证件用量|计划段次用证|合同证件与额度)$/]
|
||||
const panelTitleHeadingSelector =
|
||||
'.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2'
|
||||
|
||||
|
||||
@@ -44,11 +44,13 @@ export interface CertBody {
|
||||
routeText?: string | null
|
||||
consigneeName?: string | null
|
||||
applicantName?: string | null
|
||||
contractBindings?: CertificateContractBinding[]
|
||||
purchaseRefs?: PurchaseReference[]
|
||||
}
|
||||
export interface QuotaCert extends Omit<CertBody, 'status'> {
|
||||
id: string
|
||||
status: 'draft' | 'active' | 'suspended' | 'void'
|
||||
bindingsEditable?: boolean
|
||||
revision: number
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
@@ -268,3 +270,108 @@ export const quotaAdjustmentApi = {
|
||||
total: (kind: CertType, id: string, body: CertTotalAdjustment) =>
|
||||
request.post(`${base}/${kind}/${id}/total-adjust`, body)
|
||||
}
|
||||
|
||||
export type QuotaQuantity = string | number
|
||||
export interface CertificateContractBinding {
|
||||
id?: string
|
||||
contractId: string
|
||||
linkStatus?: 'active' | 'inactive'
|
||||
products: ScopeProduct[]
|
||||
scopeRevision?: number
|
||||
remark?: string | null
|
||||
orderNum?: number | null
|
||||
}
|
||||
export interface StatisticsPage<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
export interface ContractStatisticsSummary extends ContractOption {
|
||||
certCount: number
|
||||
}
|
||||
export interface CertificateStatisticsSummary {
|
||||
id: string
|
||||
certId: string
|
||||
certType: CertType
|
||||
certNo: string
|
||||
productNameOnCert: string
|
||||
quantityUnit: string
|
||||
totalQty: QuotaQuantity
|
||||
reservedQty: QuotaQuantity
|
||||
usedQty: QuotaQuantity
|
||||
availableQty: QuotaQuantity
|
||||
status: string
|
||||
validFrom: string | null
|
||||
validTo: string | null
|
||||
}
|
||||
export interface StatisticsProduct {
|
||||
id: string
|
||||
contractProductId: string
|
||||
productId: string
|
||||
productName: string
|
||||
model: string | null
|
||||
unit: string | null
|
||||
bindingId: string | null
|
||||
scopeId: string | null
|
||||
scopeStatus: string | null
|
||||
linkStatus: string | null
|
||||
reservedQty: QuotaQuantity
|
||||
usedQty: QuotaQuantity
|
||||
}
|
||||
export interface StatisticsBinding {
|
||||
bindingId: string | null
|
||||
linkStatus: string | null
|
||||
scopeRevision: number | null
|
||||
contractReservedQty: QuotaQuantity
|
||||
contractUsedQty: QuotaQuantity
|
||||
products: StatisticsProduct[]
|
||||
}
|
||||
export interface ContractStatistics extends ContractOption {
|
||||
certificates: (CertificateStatisticsSummary & StatisticsBinding & { productNames: string[] })[]
|
||||
}
|
||||
export interface StatisticsUsageItem {
|
||||
id: string
|
||||
usageId: string
|
||||
certType: CertType
|
||||
certId: string
|
||||
planId: string
|
||||
planNo: string
|
||||
stopType: string
|
||||
stopId: string
|
||||
stopProductId: string
|
||||
planProductId: string
|
||||
contractId: string
|
||||
contractProductId: string
|
||||
productName: string
|
||||
quantityUnit: string
|
||||
reservedQty: QuotaQuantity
|
||||
usedQty: QuotaQuantity
|
||||
itemStatus: string
|
||||
}
|
||||
export interface CertificateStatistics extends CertificateStatisticsSummary {
|
||||
contracts: (ContractOption & StatisticsBinding)[]
|
||||
usageItems: StatisticsUsageItem[]
|
||||
openingUsedQty: QuotaQuantity
|
||||
unattributedUsedQty: QuotaQuantity
|
||||
}
|
||||
export interface StatisticsQuery {
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
certType?: CertType
|
||||
}
|
||||
export const quotaStatisticsApi = {
|
||||
contracts: (params: StatisticsQuery) =>
|
||||
request
|
||||
.get(`${base}/statistics/contracts`, { params })
|
||||
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
|
||||
certificates: (params: StatisticsQuery) =>
|
||||
request
|
||||
.get(`${base}/statistics/certificates`, { params })
|
||||
.then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>),
|
||||
contract: (id: string) =>
|
||||
request.get(`${base}/contracts/${id}/statistics`).then(pickBxData<ContractStatistics>),
|
||||
certificate: (kind: CertType, id: string) =>
|
||||
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
|
||||
}
|
||||
@@ -61,26 +61,44 @@ export const plRoutes: AppRouteRecord[] = [
|
||||
path: 'cert-upload',
|
||||
meta: { title: '计划用证', keepAlive: false },
|
||||
children: [
|
||||
{
|
||||
id: 'PL00000000000000000000000000015',
|
||||
name: 'PlPlanCert',
|
||||
path: 'plan-cert',
|
||||
component: '/pl/plan-cert/index',
|
||||
meta: { title: '计划用证', keepAlive: false, menuCode: 'pl:plan-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL_QUOTA_CERT',
|
||||
name: 'PlQuotaCert',
|
||||
path: 'quota-cert',
|
||||
component: '/pl/quota-cert/index',
|
||||
meta: { title: '购买证与运输证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
meta: { title: '证件建档', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL_CONTRACT_QUOTA',
|
||||
name: 'PlContractQuota',
|
||||
path: 'contract-quota',
|
||||
component: '/pl/contract-quota/index',
|
||||
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL_CERTIFICATE_USAGE',
|
||||
name: 'PlCertificateUsage',
|
||||
path: 'certificate-usage',
|
||||
component: '/pl/certificate-usage/index',
|
||||
meta: { title: '证件用量', keepAlive: false, menuCode: 'pl:quota-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL00000000000000000000000000015',
|
||||
name: 'PlPlanCert',
|
||||
path: 'plan-cert',
|
||||
component: '/pl/plan-cert/index',
|
||||
meta: { title: '计划段次用证', keepAlive: false, menuCode: 'pl:plan-cert:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL_LEGAL_USE_DOCUMENT',
|
||||
name: 'PlLegalUseDocument',
|
||||
path: 'legal-use-document',
|
||||
component: '/pl/legal-use-document/index',
|
||||
meta: { title: '合法使用证明', keepAlive: false, menuCode: 'pl:legal-use-document:list' }
|
||||
meta: {
|
||||
title: '合法使用证明',
|
||||
keepAlive: false,
|
||||
menuCode: 'pl:legal-use-document:list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -511,6 +511,16 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
|
||||
<ContractQuotaPanel
|
||||
v-if="detailActiveTab === 'quota' && detailContract?.id"
|
||||
:key="detailContract.id"
|
||||
:contract-id="String(detailContract.id)"
|
||||
:active="detailActiveTab === 'quota'"
|
||||
embedded
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 回款记录 -->
|
||||
<el-tab-pane label="回款记录" name="payment">
|
||||
<div class="panel dialog-list-panel">
|
||||
@@ -1045,6 +1055,7 @@ import UploadDialog from './UploadDialog.vue';
|
||||
import ContractFormDialog from './ContractFormDialog.vue';
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types';
|
||||
import ContractQuotaPanel from '@/views/pl/contract-quota/ContractQuotaPanel.vue';
|
||||
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
|
||||
import {
|
||||
parseSettlementClauseDrafts,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><StatisticsPanel mode="certificate" :active="active" /></template>
|
||||
<script setup lang="ts">
|
||||
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
|
||||
defineProps<{ active?: boolean }>()
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="page-container"><CertificateUsagePanel /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import CertificateUsagePanel from './CertificateUsagePanel.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><StatisticsPanel mode="contract" v-bind="props" /></template>
|
||||
<script setup lang="ts">
|
||||
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
|
||||
const props = defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>()
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="page-container"><ContractQuotaPanel /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import ContractQuotaPanel from './ContractQuotaPanel.vue'
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="mobile ? 'mp-h5-list-content' : 'page-container'">
|
||||
<section class="panel">
|
||||
<div v-if="!mobile" class="panel-top"><h2 class="title-bar-heading">计划用证</h2></div>
|
||||
<div v-if="!mobile" class="panel-top"><h2 class="title-bar-heading">计划段次用证</h2></div>
|
||||
<div v-if="mobile" class="filter-block"
|
||||
><el-form label-position="top" @submit.prevent="load"
|
||||
><el-form-item label="计划编号"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
remote
|
||||
:remote-method="searchContracts"
|
||||
:loading="searching"
|
||||
:disabled="saving"
|
||||
:disabled="saving || !!fixedContractId"
|
||||
placeholder="输入合同编号或名称"
|
||||
@change="loadContract"
|
||||
>
|
||||
@@ -174,6 +174,7 @@
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import {
|
||||
quotaCertApi,
|
||||
quotaStatisticsApi,
|
||||
type CertType,
|
||||
type CertBinding,
|
||||
type QuotaCert,
|
||||
@@ -181,7 +182,7 @@
|
||||
type ContractProduct
|
||||
} from '@/api/pl/quota-cert'
|
||||
import { bindingPayload, bindingProductEnabled } from './model'
|
||||
const props = defineProps<{ canEdit: boolean }>()
|
||||
const props = defineProps<{ canEdit: boolean; fixedContractId?: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -213,17 +214,34 @@
|
||||
const c = certificates.transport.find((c) => c.id === id)
|
||||
return `车号:${c?.vehicleNumbersJson?.join('、') || '未填写'};引用:${c?.purchaseRefs?.map((r) => r.referenceNo).join('、') || '未填写'}`
|
||||
}
|
||||
let sessionVersion = 0
|
||||
onBeforeUnmount(() => {
|
||||
sessionVersion++
|
||||
loadVersion++
|
||||
})
|
||||
async function searchContracts(keyword = '') {
|
||||
const version = sessionVersion
|
||||
searching.value = true
|
||||
try {
|
||||
contracts.value = await quotaCertApi.contracts(keyword)
|
||||
const result = await quotaCertApi.contracts(keyword)
|
||||
if (version === sessionVersion) contracts.value = result
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
async function loadFixedContract(id: string) {
|
||||
const version = sessionVersion
|
||||
const value = await quotaStatisticsApi.contract(id)
|
||||
if (version !== sessionVersion) return
|
||||
contracts.value = [
|
||||
{ id: value.id, contractNo: value.contractNo, contractName: value.contractName }
|
||||
]
|
||||
}
|
||||
async function open(id = '') {
|
||||
if (saving.value) return
|
||||
const session = ++sessionVersion
|
||||
visible.value = true
|
||||
id = props.fixedContractId || id
|
||||
contractId.value = id
|
||||
loadedContractId.value = ''
|
||||
bindings.value = []
|
||||
@@ -234,8 +252,9 @@
|
||||
const [purchase, transport] = await Promise.all([
|
||||
quotaCertApi.list('purchase'),
|
||||
quotaCertApi.list('transport'),
|
||||
searchContracts()
|
||||
props.fixedContractId ? loadFixedContract(id) : searchContracts()
|
||||
])
|
||||
if (session !== sessionVersion) return
|
||||
certificates.purchase = purchase
|
||||
certificates.transport = transport
|
||||
if (id) await loadContract()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<QuotaHistoryDialog ref="historyDialog" :can-edit="canEdit" @saved="load" />
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top"><h2 class="title-bar-heading">购买证与运输证</h2></div>
|
||||
<div class="panel-top"><h2 class="title-bar-heading">{{ mobile ? '购买证与运输证' : '证件建档' }}</h2></div>
|
||||
<div class="list-toolbar"
|
||||
><div class="list-toolbar-row"
|
||||
><el-select v-model="kind" aria-label="证件种类" style="width: 130px"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="view-section">
|
||||
<template v-if="mode === 'contract'">
|
||||
<el-empty v-if="!contract?.certificates.length" description="暂无关联证件" :image-size="64" />
|
||||
<section
|
||||
v-for="cert in contract?.certificates"
|
||||
:key="`${cert.certType}:${cert.certId}`"
|
||||
class="view-section"
|
||||
>
|
||||
<h3>{{ cert.certNo }} · {{ cert.productNameOnCert }}</h3>
|
||||
<el-tag size="small">{{ cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag>
|
||||
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
|
||||
cert.linkStatus === 'active' ? '有效关联' : '历史 / 停用关联'
|
||||
}}</el-tag>
|
||||
<el-tag size="small" :type="cert.status === 'active' ? 'success' : 'info'">{{
|
||||
statusLabel(cert.status)
|
||||
}}</el-tag>
|
||||
<p class="form-tip">有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}</p>
|
||||
<el-descriptions :column="3">
|
||||
<el-descriptions-item label="全证核定"
|
||||
>{{ cert.totalQty }} {{ cert.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="全证预占"
|
||||
>{{ cert.reservedQty }} {{ cert.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="全证已用"
|
||||
>{{ cert.usedQty }} {{ cert.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="全证共享余额"
|
||||
>{{ cert.availableQty }} {{ cert.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="本合同预占"
|
||||
>{{ cert.contractReservedQty }} {{ cert.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="本合同已用"
|
||||
>{{ cert.contractUsedQty }} {{ cert.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
</el-descriptions>
|
||||
<StatisticsProducts :products="cert.products" :quantity-unit="cert.quantityUnit" />
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="certificate">
|
||||
<el-descriptions :column="2">
|
||||
<el-descriptions-item label="期初已用(独立记录)"
|
||||
>{{ certificate.openingUsedQty }} {{ certificate.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="未归属合同已用"
|
||||
>{{ certificate.unattributedUsedQty }}
|
||||
{{ certificate.quantityUnit }}</el-descriptions-item
|
||||
>
|
||||
</el-descriptions>
|
||||
<el-empty v-if="!certificate.contracts.length" description="暂无关联合同" :image-size="64" />
|
||||
<section v-for="item in certificate.contracts" :key="item.id" class="view-section">
|
||||
<h3>{{ item.contractNo }} · {{ item.contractName }}</h3>
|
||||
<el-tag size="small" :type="item.linkStatus === 'active' ? 'success' : 'info'">{{
|
||||
item.linkStatus === 'active' ? '有效关联' : '历史 / 停用关联'
|
||||
}}</el-tag>
|
||||
<p
|
||||
>本合同预占 {{ item.contractReservedQty }} / 已用 {{ item.contractUsedQty }}
|
||||
{{ certificate.quantityUnit }}</p
|
||||
>
|
||||
<StatisticsProducts :products="item.products" :quantity-unit="certificate.quantityUnit" />
|
||||
</section>
|
||||
<h3>计划段产品用量追踪</h3>
|
||||
<el-table :data="certificate.usageItems" stripe class="list-table" row-key="id">
|
||||
<el-table-column prop="planNo" label="计划编号" min-width="180" />
|
||||
<el-table-column label="段次" min-width="150"
|
||||
><template #default="{ row }"
|
||||
><el-tag size="small">{{ row.stopType === 'load' ? '装货' : '卸货' }}</el-tag>
|
||||
{{ row.stopId }}</template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="productName" label="产品" min-width="160" />
|
||||
<el-table-column prop="stopProductId" label="段产品标识" min-width="180" />
|
||||
<el-table-column prop="contractProductId" label="合同产品标识" min-width="180" />
|
||||
<el-table-column prop="quantityUnit" label="单位" min-width="90" />
|
||||
<el-table-column prop="reservedQty" label="预占" min-width="120" />
|
||||
<el-table-column prop="usedQty" label="已用" min-width="120" />
|
||||
<el-table-column label="状态" min-width="120"
|
||||
><template #default="{ row }"
|
||||
><el-tag size="small">{{ statusLabel(row.itemStatus) }}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ContractStatistics, CertificateStatistics } from '@/api/pl/quota-cert'
|
||||
import StatisticsProducts from './StatisticsProducts.vue'
|
||||
import { statusLabel } from './labels'
|
||||
const props = defineProps<{
|
||||
mode: 'contract' | 'certificate'
|
||||
detail: ContractStatistics | CertificateStatistics
|
||||
}>()
|
||||
const contract = computed(() =>
|
||||
props.mode === 'contract' ? (props.detail as ContractStatistics) : undefined
|
||||
)
|
||||
const certificate = computed(() =>
|
||||
props.mode === 'certificate' ? (props.detail as CertificateStatistics) : undefined
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
|
||||
<div v-if="!embedded" class="panel-top"
|
||||
><h2 class="title-bar-heading">{{ mode === 'contract' ? '合同用证' : '证件用量' }}</h2></div
|
||||
>
|
||||
<div v-if="!contractId" class="list-toolbar"
|
||||
><div class="list-toolbar-row">
|
||||
<ListQuickSearchInput
|
||||
v-model="keyword"
|
||||
:placeholder="mode === 'contract' ? '合同编号 / 名称' : '证件编号 / 品名'"
|
||||
@search="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-select
|
||||
v-if="mode === 'certificate'"
|
||||
v-model="certType"
|
||||
aria-label="证件类型"
|
||||
@change="search"
|
||||
style="width: 150px"
|
||||
><el-option label="购买证" value="purchase" /><el-option label="运输证" value="transport"
|
||||
/></el-select>
|
||||
<el-button v-if="keyword || 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>
|
||||
<p class="form-tip"
|
||||
>额度按单张证件核算;共享余额为全证余额,多个合同共用,不代表本合同独享额度。</p
|
||||
>
|
||||
<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
|
||||
>
|
||||
<div v-if="contractId" v-loading="loading">
|
||||
<template v-if="fixedDetail">
|
||||
<el-button v-if="canEdit" @click="edit(contractId)"
|
||||
><el-icon><Edit /></el-icon>维护合同适用证件</el-button
|
||||
>
|
||||
<StatisticsDetail mode="contract" :detail="fixedDetail" />
|
||||
</template>
|
||||
<el-empty
|
||||
v-else-if="!loading && !error"
|
||||
:description="allowed ? '暂无合同用证数据' : '无证件查看权限'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="list-body list-wrap"
|
||||
><div ref="tableWrapRef" class="table-scroll">
|
||||
<ListTable
|
||||
ref="tableRef"
|
||||
:table-key="`pl-${mode}-statistics`"
|
||||
: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 v-loading="details[row.id]?.loading" class="view-section">
|
||||
<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-button v-if="mode === 'contract' && canEdit" @click="edit(row.id)"
|
||||
><el-icon><Edit /></el-icon>维护合同适用证件</el-button
|
||||
>
|
||||
<StatisticsDetail :mode="mode" :detail="details[row.id].data!" />
|
||||
</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="column.type === 'text' ? 170 : 130"
|
||||
>
|
||||
<template #header
|
||||
><div class="col-header-with-filter"
|
||||
><span class="col-header-with-filter__label">{{ column.label }}</span>
|
||||
<ColumnHeaderFilter
|
||||
v-if="column.prop === searchColumn"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
type="text"
|
||||
:enable-sort="false"
|
||||
:model-value="filters[column.prop] || null"
|
||||
:is-active="isFilterActive(column.prop)"
|
||||
@update:model-value="(v) => setFilterState(column.prop, v)"
|
||||
/> </div
|
||||
></template>
|
||||
<template #default="{ row }"
|
||||
><el-tag
|
||||
v-if="column.prop === 'status'"
|
||||
size="small"
|
||||
:type="row.status === 'active' ? 'success' : 'info'"
|
||||
>{{ statusLabel(row.status) }}</el-tag
|
||||
><el-tag v-else-if="column.prop === 'contractType'" size="small">{{
|
||||
row.contractType === 'purchase'
|
||||
? '采购合同'
|
||||
: row.contractType === 'sales'
|
||||
? '销售合同'
|
||||
: row.contractType || '—'
|
||||
}}</el-tag
|
||||
><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>
|
||||
<ContractScopeDialog
|
||||
v-if="allowed"
|
||||
:key="sessionKey"
|
||||
ref="scopeDialog"
|
||||
:can-edit="canEdit"
|
||||
:fixed-contract-id="editingContractId"
|
||||
@saved="load"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Edit, Refresh, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
quotaStatisticsApi,
|
||||
type ContractStatistics,
|
||||
type CertificateStatistics,
|
||||
type CertType
|
||||
} 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 ContractScopeDialog from '../quota-cert/ContractScopeDialog.vue'
|
||||
import StatisticsDetail from './StatisticsDetail.vue'
|
||||
import { createStatisticsLoader } from './loader'
|
||||
import { statusLabel } from './labels'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'contract' | 'certificate'
|
||||
contractId?: string
|
||||
embedded?: boolean
|
||||
active?: boolean
|
||||
}>(),
|
||||
{ active: true }
|
||||
)
|
||||
const user = useUserStore()
|
||||
const allowed = ref(false),
|
||||
canEdit = ref(false),
|
||||
loading = ref(false),
|
||||
error = ref(''),
|
||||
keyword = ref(''),
|
||||
page = ref(1),
|
||||
total = ref(0)
|
||||
const certType = ref<CertType>('purchase')
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
const fixedDetail = ref<ContractStatistics>()
|
||||
const details = reactive<
|
||||
Record<
|
||||
string,
|
||||
{ loading: boolean; error?: boolean; data?: ContractStatistics | CertificateStatistics }
|
||||
>
|
||||
>({})
|
||||
const editingContractId = ref(''),
|
||||
sessionKey = ref(0)
|
||||
const scopeDialog = ref<InstanceType<typeof ContractScopeDialog>>()
|
||||
const loader = createStatisticsLoader(quotaStatisticsApi)
|
||||
let epoch = 0
|
||||
const searchColumn = props.mode === 'contract' ? 'contractNo' : 'certNo'
|
||||
const columns: ColumnFilterDef[] = (
|
||||
props.mode === 'contract'
|
||||
? [
|
||||
['contractNo', '合同编号'],
|
||||
['contractName', '合同名称'],
|
||||
['contractType', '合同类型'],
|
||||
['certCount', '关联证件数']
|
||||
]
|
||||
: [
|
||||
['certNo', '证件编号'],
|
||||
['productNameOnCert', '证载品名'],
|
||||
['quantityUnit', '单位'],
|
||||
['totalQty', '全证核定'],
|
||||
['reservedQty', '全证预占'],
|
||||
['usedQty', '全证已用'],
|
||||
['availableQty', '全证共享余额'],
|
||||
['status', '状态'],
|
||||
['validFrom', '有效期起'],
|
||||
['validTo', '有效期止']
|
||||
]
|
||||
).map(([prop, label]) => ({ prop, label, type: 'text', queryKey: 'keyword' }))
|
||||
const { visibleColumns, checkAll, isIndeterminate, resetToDefault } = usePersistedListColumns(
|
||||
`pl-${props.mode}-statistics-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 === searchColumn),
|
||||
{ onChange: search }
|
||||
)
|
||||
watchVisibleColumns(visibleColumns)
|
||||
function clear() {
|
||||
loader.invalidate()
|
||||
epoch++
|
||||
rows.value = []
|
||||
fixedDetail.value = undefined
|
||||
total.value = 0
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
Object.keys(details).forEach((k) => delete details[k])
|
||||
}
|
||||
async function load() {
|
||||
clear()
|
||||
if (!allowed.value || !props.active) return
|
||||
const current = epoch
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await loader.load({
|
||||
mode: props.mode,
|
||||
contractId: props.contractId,
|
||||
allowed: allowed.value,
|
||||
query: {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: toQueryParams().keyword || keyword.value,
|
||||
...(props.mode === 'certificate' ? { certType: certType.value } : {})
|
||||
}
|
||||
})
|
||||
if (current !== epoch || !data) return
|
||||
if (props.contractId) fixedDetail.value = data
|
||||
else {
|
||||
rows.value = data.list
|
||||
total.value = data.total
|
||||
}
|
||||
} catch {
|
||||
if (current === epoch) error.value = '统计加载失败,请重试'
|
||||
} finally {
|
||||
if (current === epoch) loading.value = false
|
||||
}
|
||||
}
|
||||
async function loadDetail(row: Record<string, any>) {
|
||||
if (!allowed.value || !props.active) return
|
||||
const current = epoch
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
error: false,
|
||||
data: undefined as ContractStatistics | CertificateStatistics | undefined
|
||||
})
|
||||
details[row.id] = state
|
||||
try {
|
||||
const data = await (props.mode === 'contract'
|
||||
? quotaStatisticsApi.contract(row.id)
|
||||
: quotaStatisticsApi.certificate(row.certType, row.certId))
|
||||
if (current === epoch && details[row.id] === state) state.data = data
|
||||
} catch {
|
||||
if (current === epoch && details[row.id] === state) state.error = true
|
||||
} finally {
|
||||
if (current === epoch && details[row.id] === state) state.loading = false
|
||||
}
|
||||
}
|
||||
function expand(row: Record<string, any>, expanded: Record<string, any>[]) {
|
||||
if (expanded.some((r) => r.id === row.id) && !details[row.id]) void loadDetail(row)
|
||||
}
|
||||
function search() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
clearAllFilters()
|
||||
search()
|
||||
}
|
||||
async function edit(id: string) {
|
||||
editingContractId.value = id
|
||||
await nextTick()
|
||||
void scopeDialog.value?.open(id)
|
||||
}
|
||||
async function init() {
|
||||
clear()
|
||||
allowed.value = false
|
||||
canEdit.value = false
|
||||
sessionKey.value++
|
||||
if (!user.accessToken || !props.active) return
|
||||
const current = epoch
|
||||
try {
|
||||
const p = await fetchGetMenuCodes()
|
||||
if (current !== epoch) return
|
||||
allowed.value =
|
||||
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:list')
|
||||
canEdit.value =
|
||||
allowed.value &&
|
||||
(!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit'))
|
||||
await load()
|
||||
} catch {
|
||||
if (current === epoch) error.value = '权限加载失败,请重新打开页面'
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.contractId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
|
||||
() => {
|
||||
page.value = 1
|
||||
void init()
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
onBeforeUnmount(() => {
|
||||
clear()
|
||||
allowed.value = false
|
||||
})
|
||||
const tableWrapRef = ref<HTMLElement | null>(null),
|
||||
pagerRef = ref<HTMLElement | null>(null),
|
||||
tableRef = ref()
|
||||
const { tableHeight } = useListTableViewportHeight({
|
||||
tableEl: tableWrapRef,
|
||||
pagerEl: pagerRef,
|
||||
tableRef,
|
||||
enabled: computed(() => !props.embedded)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<el-table :data="products" stripe class="list-table" row-key="id" empty-text="暂无合同产品范围">
|
||||
<el-table-column prop="productName" label="绑定合同产品" min-width="180" />
|
||||
<el-table-column prop="model" label="规格" min-width="140" />
|
||||
<el-table-column prop="unit" label="产品单位" min-width="90" />
|
||||
<el-table-column label="额度单位" min-width="90"
|
||||
><template #default>{{ quantityUnit }}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="reservedQty" label="预占" min-width="120" />
|
||||
<el-table-column prop="usedQty" label="已用" min-width="120" />
|
||||
<el-table-column label="范围状态" min-width="130"
|
||||
><template #default="{ row }"
|
||||
><el-tag size="small" :type="row.scopeStatus === 'active' ? 'success' : 'info'">{{
|
||||
row.scopeStatus === 'active'
|
||||
? '有效'
|
||||
: row.scopeStatus === 'inactive'
|
||||
? '停用'
|
||||
: '历史解绑'
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { StatisticsProduct } from '@/api/pl/quota-cert'
|
||||
defineProps<{ products: StatisticsProduct[]; quantityUnit: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
export const statusLabel = (value: string) =>
|
||||
({
|
||||
draft: '草稿',
|
||||
active: '有效',
|
||||
suspended: '停用',
|
||||
void: '作废',
|
||||
reserved: '预占',
|
||||
settled: '已核销',
|
||||
in_transit: '运输中',
|
||||
exception: '异常',
|
||||
released: '已释放',
|
||||
consumed: '已核销',
|
||||
cancelled: '已取消'
|
||||
})[value] || value
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { quotaStatisticsApi, StatisticsQuery } from '@/api/pl/quota-cert'
|
||||
// A context owns its requests; invalidation also protects failures arriving after a switch.
|
||||
export function createStatisticsLoader(api: typeof quotaStatisticsApi) {
|
||||
let epoch = 0
|
||||
return {
|
||||
invalidate() {
|
||||
epoch++
|
||||
},
|
||||
async load(options: {
|
||||
mode: 'contract' | 'certificate'
|
||||
contractId?: string
|
||||
allowed: boolean
|
||||
query?: StatisticsQuery
|
||||
}): Promise<any> {
|
||||
const current = ++epoch
|
||||
if (!options.allowed) return
|
||||
try {
|
||||
const result = await (options.mode === 'contract'
|
||||
? options.contractId
|
||||
? api.contract(options.contractId)
|
||||
: api.contracts(options.query || {})
|
||||
: api.certificates(options.query || {}))
|
||||
return current === epoch ? result : undefined
|
||||
} catch (error) {
|
||||
if (current === epoch) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user