feat(pl): 增加合同与证件用量视图以追踪共享额度及段产品使用。

This commit is contained in:
wuzilong committed 2026-11-01 21:53:36 +08:00
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;
+263
View File
@@ -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 })
})
+1 -1
View File
@@ -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'
+107
View File
@@ -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>)
}
+27 -9
View File
@@ -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 -1
View File
@@ -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()
+1 -1
View File
@@ -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
}
}
}
}