feat(pl): 合同用证改为客户用证并修复列表筛选。

证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-15 14:46:46 +08:00
1 parent d5f3a79ea5
commit ea46761f40
29 files changed
+1777 -295

No files matched your search

+1 -1
View File
@@ -111,7 +111,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'
+28 -2
View File
@@ -61,6 +61,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
verifiedTime?: string | null
openingConfirmed: boolean
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
singleUseConsumed?: boolean
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
@@ -132,8 +133,10 @@ export interface LegalDocument {
}
const base = '/transport/quota-certs'
export const quotaCertApi = {
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) =>
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
list: (
kind: CertType,
params: { keyword?: string; status?: string; customerId?: string } = {}
) => request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
get: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
save: (kind: CertType, body: CertBody, id?: string) =>
@@ -504,6 +507,13 @@ export interface StatisticsPage<T> {
export interface ContractStatisticsSummary extends ContractOption {
certCount: number
}
export interface CustomerStatisticsSummary {
id: string
customerId: string
customerNo: string
customerName: string
certCount: number
}
export interface CertificateStatisticsSummary {
id: string
certId: string
@@ -522,6 +532,14 @@ export interface CertificateStatisticsSummary {
buyPartyName?: string | null
validUseText?: string | null
}
export interface CustomerStatistics extends CustomerStatisticsSummary {
certificates: (CertificateStatisticsSummary & {
usagePolicy?: QuotaCert['usagePolicy']
singleUseConsumed?: boolean
openingConfirmed?: boolean
createTime?: string
})[]
}
export interface StatisticsProduct {
id: string
contractProductId: string
@@ -579,6 +597,8 @@ export interface StatisticsQuery {
contractNo?: string
contractName?: string
contractTypeIn?: string
customerNo?: string
customerName?: string
certCountOp?: string
certCount?: string
certCountMin?: string
@@ -622,12 +642,18 @@ export const quotaStatisticsApi = {
request
.get(`${base}/statistics/contracts`, { params })
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
customers: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/customers`, { params })
.then(pickBxData<StatisticsPage<CustomerStatisticsSummary>>),
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>),
customer: (id: string) =>
request.get(`${base}/customers/${id}/statistics`).then(pickBxData<CustomerStatistics>),
certificate: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
}
+1 -1
View File
@@ -133,7 +133,7 @@
mode: 'list',
title: '运输证件管理',
search: '证件编号 / 品名',
tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'],
tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
filters: [],
caption: '正在加载运输证件管理'
}
+1 -1
View File
@@ -80,7 +80,7 @@ export const plRoutes: AppRouteRecord[] = [
name: 'PlContractQuota',
path: 'contract-quota',
component: '/pl/contract-quota/index',
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
meta: { title: '客户用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERTIFICATE_USAGE',
+8 -2
View File
@@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/m/pl/plan-cert/archive-detail.vue'),
meta: { title: '证件建档', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/customer/:id',
name: 'MpPlPlanCertCustomer',
component: () => import('@views/m/pl/plan-cert/customer-detail.vue'),
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/contract/:id',
name: 'MpPlPlanCertContract',
component: () => import('@views/m/pl/plan-cert/contract-detail.vue'),
meta: { title: '合同用证', isHideTab: true, isMpWebview: true }
redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } },
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/usage/:kind/:id',
@@ -504,13 +504,12 @@
/>
</el-tab-pane>
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
<ContractQuotaPanel
<el-tab-pane v-if="canViewQuotaCert" label="客户证件与额度" name="quota" lazy>
<CustomerCertQuotaPanel
v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)"
:key="detailContract.id"
:contract-id="String(detailContract.id)"
:key="String(detailContract.customerId || detailContract.id)"
:customer-id="detailContract.customerId"
:active="detailActiveTab === 'quota'"
embedded
/>
</el-tab-pane>
@@ -1060,7 +1059,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 CustomerCertQuotaPanel from '@/views/pl/contract-quota/CustomerCertQuotaPanel.vue';
import StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import {
@@ -0,0 +1,180 @@
<template>
<div class="mp-customer-cert-quota">
<p v-if="!customerId" class="mp-wv-empty">合同未关联客户,无法展示证件</p>
<p v-else-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="load">重试</button>
</p>
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
<template v-else>
<p class="mp-customer-cert-quota__tip">
展示该客户全部购买证、运输证;证件关联客户,不再按合同绑定。
</p>
<van-empty v-if="!rows.length" description="该客户暂无购买证或运输证" />
<article
v-for="row in rows"
:key="row.certType + row.id"
class="mp-wv-section"
role="button"
tabindex="0"
@click="openDetail(row)"
@keydown.enter.prevent="openDetail(row)"
>
<div class="mp-wv-card__head">
<div>
<h3>{{ row.certNo || '—' }}</h3>
<p>{{ row.productNameOnCert || '未填写证面品名' }}</p>
<p>有效期:{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</p>
</div>
</div>
<div class="mp-cert-tags">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
<span class="status-tag" :class="`status-tag--${statusTone(row.status)}`">{{
certStatusLabel(row.status)
}}</span>
<span v-if="row.singleUseConsumed" class="status-tag status-tag--danger"
>已使用不可再用</span
>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"
><span>有效次数</span><span>{{ row.validUseText || '—' }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>核定</span><span>{{ qtyText(row.totalQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>预占</span><span>{{ qtyText(row.reservedQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>已用</span><span>{{ qtyText(row.usedQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>余量</span><span>{{ qtyText(row.availableQty, row.quantityUnit) }}</span></div
>
</div>
</article>
</template>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
type CustomerCertRow = QuotaCert & { certType: CertType }
const props = withDefaults(
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
{ active: true }
)
const router = useRouter()
const dictStore = useDictStore()
const loading = ref(false)
const error = ref('')
const rows = ref<CustomerCertRow[]>([])
let loadEpoch = 0
function unitLabel(unit?: string | null) {
const label = getProductUnitLabel(dictStore.getLabel, unit)
return !label || label === '—' ? '' : label
}
function qtyText(value: unknown, unit?: string | null) {
const text = formatCertQty(value)
const name = unitLabel(unit)
return name ? `${text} ${name}` : text
}
function certStatusLabel(status?: string | null) {
return (
{
draft: '待核对',
active: '证件已启用',
suspended: '证件已停用',
closed: '证件已核销',
void: '证件已作废'
}[status || ''] ||
status ||
'—'
)
}
function statusTone(status?: string | null) {
if (status === 'active') return 'success'
if (status === 'suspended') return 'warning'
if (status === 'closed' || status === 'void') return 'danger'
return 'info'
}
async function load() {
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
const epoch = ++loadEpoch
if (!customerId) {
rows.value = []
error.value = ''
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase', { customerId }),
quotaCertApi.list('transport', { customerId })
])
if (epoch !== loadEpoch) return
rows.value = [
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
} catch (e: any) {
if (epoch !== loadEpoch) return
rows.value = []
error.value = e?.message || '加载客户证件失败'
} finally {
if (epoch === loadEpoch) loading.value = false
}
}
function openDetail(row: CustomerCertRow) {
void router.push(`/m/pl/plan-cert/archive/${row.certType}/${row.id}`)
}
watch(
() => [props.customerId, props.active],
([, active]) => {
if (active) void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.mp-customer-cert-quota__tip {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: var(--mp-text-muted, #64748b);
}
.mp-wv-section {
cursor: pointer;
}
.mp-cert-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 12px;
}
</style>
+46 -9
View File
@@ -342,6 +342,19 @@
</div>
<div v-show="activeTab === 'quota'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card">
<h3>客户证件与额度</h3>
<CustomerCertQuotaTab
v-if="canViewQuotaCert && activeTab === 'quota'"
:key="String(contract.customerId || contract.id)"
:customer-id="contract.customerId"
:active="activeTab === 'quota'"
/>
<p v-else class="mp-wv-card__meta">无证件查看权限</p>
</section>
</div>
<div v-show="activeTab === 'plans'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card">
<h3>运输计划</h3>
@@ -933,6 +946,8 @@
} from '@/utils/mp-webview'
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { fetchGetMenuCodes } from '@/api/auth'
import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue'
import '@/styles/mp-webview.scss'
import '../customer/customer.scss'
@@ -970,6 +985,7 @@
type DetailTabKey =
| 'contract'
| 'plans'
| 'quota'
| 'payment'
| 'invoice'
| 'approval'
@@ -979,6 +995,7 @@
const acting = ref(false)
const errorText = ref('')
const activeTab = ref<DetailTabKey>('contract')
const canViewQuotaCert = ref(false)
const detailBody = ref<HTMLElement>()
const partiesExpanded = ref(false)
const approverPickerOpen = ref(false)
@@ -1056,14 +1073,34 @@
}
}
const detailTabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'contract', label: '合同信息' },
{ key: 'plans', label: '运输计划' },
{ key: 'payment', label: '回款记录' },
{ key: 'invoice', label: '开票记录' },
{ key: 'approval', label: '审批流程' },
{ key: 'operation', label: '操作记录' }
]
const detailTabs = computed(() => {
const tabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'contract', label: '合同信息' },
{ key: 'plans', label: '运输计划' }
]
if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' })
tabs.push(
{ key: 'payment', label: '回款记录' },
{ key: 'invoice', label: '开票记录' },
{ key: 'approval', label: '审批流程' },
{ key: 'operation', label: '操作记录' }
)
return tabs
})
async function refreshQuotaCertPermission() {
try {
const permissions = await fetchGetMenuCodes()
const codes = permissions?.menuCodes || []
canViewQuotaCert.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
} catch {
canViewQuotaCert.value = false
}
if (!canViewQuotaCert.value && activeTab.value === 'quota') {
activeTab.value = 'contract'
}
}
const unreceivedAmount = computed(() => {
const amount = Number(contract.value?.amount) || 0
@@ -1573,7 +1610,7 @@
}
loading.value = true
try {
const res = await getContract(id)
const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()])
if (!isCurrentLoad(requestSeq, id)) return
const data = ((res as any)?.data ?? res) as ContractVO
if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看')
@@ -1,144 +1,7 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p class="mp-contract-quota-tip">
新规则已生效:安排选证改为客户+证用品类。本页合同用证仅供历史查看与旧规则计划,请勿再作为新计划的用证操作入口。
</p>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
</p>
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article
v-for="row in rows"
:key="row.id"
class="mp-wv-card mp-wv-flat-card"
@click="open(row.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.contractNo || '未编号合同' }}</div>
<div class="mp-wv-card__sub">{{ row.contractName || '—' }}</div>
</div>
<span class="status-tag status-tag--info">{{ contractTypeLabel(row.contractType) }}</span>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ row.certCount }}</span>
</div>
</div>
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)">查看</button>
</div>
</article>
</van-list>
<van-empty
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
description="暂无合同用证数据"
/>
</main>
<CustomerTab :keyword="keyword" />
</template>
<script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type ContractStatisticsSummary } from '@/api/pl/quota-cert'
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
const props = defineProps<{ keyword: string }>()
const router = useRouter()
const rows = ref<ContractStatisticsSummary[]>([])
const loading = ref(true)
const finished = ref(false)
const error = ref('')
const allowed = ref(false)
const authReady = ref(false)
const page = ref(1)
let epoch = 0
let checking = true
function open(id: string) {
void router.push(`/m/pl/plan-cert/contract/${id}`)
}
async function init() {
const current = epoch
try {
const permissions = await fetchGetMenuCodes()
if (current !== epoch) return
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
checking = false
if (!allowed.value) {
finished.value = true
loading.value = false
return
}
await load()
} catch {
if (current !== epoch) return
checking = false
authReady.value = true
finished.value = true
loading.value = false
error.value = '权限加载失败,请重新打开页面'
}
}
function reload() {
if (!allowed.value) {
void init()
return
}
epoch++
page.value = 1
rows.value = []
loading.value = true
finished.value = false
error.value = ''
void load()
}
async function load() {
if (checking || !allowed.value) return
const current = epoch
loading.value = true
try {
const data = await quotaStatisticsApi.contracts({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
page.value += 1
} catch {
if (current === epoch) {
error.value = '统计加载失败,请重试'
finished.value = true
}
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => props.keyword,
() => {
if (!checking && allowed.value) reload()
}
)
void init()
import CustomerTab from './CustomerTab.vue'
defineProps<{ keyword: string }>()
</script>
<style scoped>
.mp-contract-quota-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,143 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p class="mp-customer-quota-tip">按客户展示购买证、运输证;证件关联客户,不再按合同绑定。</p>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
</p>
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article
v-for="row in rows"
:key="row.id"
class="mp-wv-card mp-wv-flat-card"
@click="open(row.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.customerName || '未命名客户' }}</div>
<div class="mp-wv-card__sub">{{ row.customerNo || '—' }}</div>
</div>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ row.certCount }}</span>
</div>
</div>
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)"
>查看</button
>
</div>
</article>
</van-list>
<van-empty
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
description="暂无客户用证数据"
/>
</main>
</template>
<script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatisticsSummary } from '@/api/pl/quota-cert'
const props = defineProps<{ keyword: string }>()
const router = useRouter()
const rows = ref<CustomerStatisticsSummary[]>([])
const loading = ref(true)
const finished = ref(false)
const error = ref('')
const allowed = ref(false)
const authReady = ref(false)
const page = ref(1)
let epoch = 0
let checking = true
function open(id: string) {
void router.push(`/m/pl/plan-cert/customer/${id}`)
}
async function init() {
const current = epoch
try {
const permissions = await fetchGetMenuCodes()
if (current !== epoch) return
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
checking = false
if (!allowed.value) {
finished.value = true
loading.value = false
return
}
await load()
} catch {
if (current !== epoch) return
checking = false
authReady.value = true
finished.value = true
loading.value = false
error.value = '权限加载失败,请重新打开页面'
}
}
function reload() {
if (!allowed.value) {
void init()
return
}
epoch++
page.value = 1
rows.value = []
loading.value = true
finished.value = false
error.value = ''
void load()
}
async function load() {
if (checking || !allowed.value) return
const current = epoch
loading.value = true
try {
const data = await quotaStatisticsApi.customers({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
page.value += 1
} catch {
if (current === epoch) {
error.value = '统计加载失败,请重试'
finished.value = true
}
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => props.keyword,
() => {
if (!checking && allowed.value) reload()
}
)
void init()
</script>
<style scoped>
.mp-customer-quota-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -1201,19 +1201,20 @@
return
}
const certRecord = (cert || {}) as Record<string, any>
let name =
String(certRecord.customerName || '').trim() ||
systemCustomer.value.customerName ||
customerOptions.value.find((item) => item.id === id)?.customerName ||
''
let name = ''
try {
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
const data = detail?.data || detail
name = String(data?.customerName || '').trim()
} catch {
name = ''
}
// 系统客户以 CRM 为准;禁止用证面收货单位等缓存名顶替。
if (!name) {
try {
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
const data = detail?.data || detail
name = String(data?.customerName || '').trim() || id
} catch {
name = id
}
name =
String(certRecord.customerName || '').trim() ||
customerOptions.value.find((item) => item.id === id)?.customerName ||
id
}
systemCustomer.value = { id, customerName: name }
form.value.customerId = id
@@ -0,0 +1,83 @@
<template>
<MpWebviewShell @ready="ready = true">
<MpBusinessHeader title="客户用证" back-label="返回运输证件管理" @back="router.back()" />
<main v-if="ready" class="mp-h5-list-content mp-h5-detail-page mp-plan-cert-list">
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="retry">重试</button>
</p>
<div v-else-if="!authReady || loading" class="mp-cert-page-loading"><van-loading /></div>
<div v-else-if="!allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<template v-else-if="detail">
<section class="mp-wv-section">
<div class="mp-cert-contract-head">
<div class="mp-cert-contract-head__main">
<h3>{{ detail.customerName || '未命名客户' }}</h3>
<p>{{ detail.customerNo || '—' }}</p>
</div>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ detail.certCount ?? certificates.length }}</span>
</div>
</div>
</section>
<CustomerCertQuotaTab :customer-id="customerId" :active="true" />
</template>
</main>
</MpWebviewShell>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatistics } from '@/api/pl/quota-cert'
import CustomerCertQuotaTab from '@/views/m/crm/contract/CustomerCertQuotaTab.vue'
const route = useRoute()
const router = useRouter()
const ready = ref(false)
const authReady = ref(false)
const allowed = ref(false)
const loading = ref(true)
const error = ref('')
const detail = ref<CustomerStatistics | null>(null)
const customerId = computed(() => String(route.params.id || '').trim())
const certificates = computed(() => detail.value?.certificates || [])
async function load() {
const id = customerId.value
if (!id) {
error.value = '缺少客户 ID'
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const permissions = await fetchGetMenuCodes()
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
if (!allowed.value) {
loading.value = false
return
}
detail.value = await quotaStatisticsApi.customer(id)
} catch (e: any) {
detail.value = null
error.value = e?.message || '加载失败'
} finally {
loading.value = false
}
}
function retry() {
void load()
}
watch(customerId, () => void load(), { immediate: true })
</script>
@@ -44,7 +44,16 @@ export function certFaceGroups(
: display(cert, field[0]),
wide: longFaceKeys.has(field[0]) || field[0] === 'vehicleNumbersJson'
}))
if (index === 0) fields.unshift({ label: '状态', value: display(cert, 'status') })
if (index === 0) {
fields.unshift(
{ label: '状态', value: display(cert, 'status') },
{
label: '系统客户',
value: String(cert.customerName || '').trim() || '—',
wide: true
}
)
}
return { title: group.title, fields }
})
groups[groups.length - 1].fields.push({
+1 -1
View File
@@ -183,7 +183,7 @@
const committed = ref('')
const CERT_TABS = [
{ name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' },
{ name: 'contract', title: '合同用证', perm: 'mini:pl:contract-quota:list' },
{ name: 'contract', title: '客户用证', perm: 'mini:pl:contract-quota:list' },
{ name: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' },
{ name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' },
{ name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' }
@@ -1,8 +1,8 @@
<template><StatisticsPanel mode="contract" v-bind="props" /></template>
<template><CustomerQuotaPanel v-bind="props" /></template>
<script setup lang="ts">
import StatisticsPanel from '../quota-statistics/StatisticsPanel.vue'
import CustomerQuotaPanel from './CustomerQuotaPanel.vue'
const props = withDefaults(
defineProps<{ contractId?: string; embedded?: boolean; active?: boolean }>(),
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
{ active: true }
)
</script>
@@ -0,0 +1,297 @@
<template>
<div class="dialog-list-panel customer-cert-quota-panel">
<div class="list-toolbar">
<div class="list-toolbar-row contract-quota-toolbar">
<span class="contract-quota-toolbar__tip"
>展示该客户全部购买证、运输证(证件关联客户,不再按合同绑定)。额度按单张证件核算,多个合同共用共享余额。</span
>
<el-button :loading="loading" @click="load"
><el-icon><Refresh /></el-icon>刷新</el-button
>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3"
><el-button @click="load"><el-icon><Refresh /></el-icon>重试</el-button></el-alert
>
<div v-loading="loading">
<el-empty
v-if="!customerId"
description="合同未关联客户,无法展示证件"
/>
<el-empty
v-else-if="!loading && !error && !rows.length"
description="该客户暂无购买证或运输证"
/>
<ListTable
v-else-if="rows.length"
table-key="crm-customer-cert-quota"
:data="rows"
class="list-table"
stripe
empty-text="该客户暂无购买证或运输证"
>
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column label="证种" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag size="small" :type="certKindTagType(row.certType)">{{
certKindLabel(row.certType)
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="证号" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.certNo || '—' }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="productNameOnCert"
label="证面品名"
min-width="140"
:show-overflow-tooltip="true"
/>
<el-table-column label="有效次数" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<span>{{ row.validUseText || '—' }}</span>
<el-tag
v-if="row.singleUseConsumed"
class="ml-1"
type="danger"
size="small"
>已使用不可再用</el-tag
>
</template>
</el-table-column>
<el-table-column label="核定" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.totalQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="预占" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.reservedQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="已用" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.usedQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="余量" min-width="110" align="right" header-align="center">
<template #default="{ row }">{{ qtyText(row.availableQty, row.quantityUnit) }}</template>
</el-table-column>
<el-table-column label="有效期" min-width="200" align="center" header-align="center">
<template #default="{ row }"
>{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</template
>
</el-table-column>
<el-table-column label="状态" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)">{{
certStatusLabel(row.status)
}}</el-tag>
</template>
</el-table-column>
</ListTable>
</div>
<el-dialog
v-model="detailVisible"
class="dialog-form-layout"
title="证件详情"
width="720px"
align-center
destroy-on-close
>
<div v-loading="detailLoading" class="customer-cert-detail">
<template v-if="detail">
<div class="customer-cert-detail__tags">
<el-tag size="small" :type="certKindTagType(detail.certType)">{{
certKindLabel(detail.certType)
}}</el-tag>
<el-tag size="small" :type="statusTagType(detail.status)">{{
certStatusLabel(detail.status)
}}</el-tag>
<el-tag v-if="detail.singleUseConsumed" size="small" type="danger"
>已使用不可再用</el-tag
>
</div>
<dl class="customer-cert-detail__kv">
<div v-for="item in detailFields" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Refresh } from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
type CustomerCertRow = QuotaCert & { certType: CertType }
const props = withDefaults(
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
{ active: true }
)
const dictStore = useDictStore()
const loading = ref(false)
const error = ref('')
const rows = ref<CustomerCertRow[]>([])
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<CustomerCertRow | null>(null)
let loadEpoch = 0
const detailFields = computed(() => {
const row = detail.value
if (!row) return []
return [
{ label: '证号', value: row.certNo || '—' },
{ label: '证面品名', value: row.productNameOnCert || '—' },
{ label: '有效次数', value: row.validUseText || '—' },
{ label: '核定', value: qtyText(row.totalQty, row.quantityUnit) },
{ label: '预占', value: qtyText(row.reservedQty, row.quantityUnit) },
{ label: '已用', value: qtyText(row.usedQty, row.quantityUnit) },
{ label: '余量', value: qtyText(row.availableQty, row.quantityUnit) },
{ label: '有效期起', value: row.validFrom || '—' },
{ label: '有效期止', value: row.validTo || '—' },
{ label: '状态', value: certStatusLabel(row.status) }
]
})
function unitLabel(unit?: string | null) {
const label = getProductUnitLabel(dictStore.getLabel, unit)
return !label || label === '—' ? '' : label
}
function qtyText(value: unknown, unit?: string | null) {
const text = formatCertQty(value)
const name = unitLabel(unit)
return name ? `${text} ${name}` : text
}
function certStatusLabel(status?: string | null) {
return (
{
draft: '待核对',
active: '证件已启用',
suspended: '证件已停用',
closed: '证件已核销',
void: '证件已作废'
}[status || ''] ||
status ||
'—'
)
}
function statusTagType(status?: string | null) {
if (status === 'active') return 'success'
if (status === 'suspended') return 'warning'
if (status === 'closed' || status === 'void') return 'danger'
return 'info'
}
async function load() {
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
const epoch = ++loadEpoch
if (!customerId) {
rows.value = []
error.value = ''
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase', { customerId }),
quotaCertApi.list('transport', { customerId })
])
if (epoch !== loadEpoch) return
rows.value = [
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
} catch (e: any) {
if (epoch !== loadEpoch) return
rows.value = []
error.value = e?.message || '加载客户证件失败'
} finally {
if (epoch === loadEpoch) loading.value = false
}
}
async function openDetail(row: CustomerCertRow) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
try {
const data = await quotaCertApi.get(row.certType, row.id)
detail.value = { ...data, certType: row.certType }
} catch (e: any) {
detailVisible.value = false
ElMessage.error(e?.message || '加载证件详情失败')
} finally {
detailLoading.value = false
}
}
watch(
() => [props.customerId, props.active],
([, active]) => {
if (active) void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.contract-quota-toolbar {
width: 100%;
justify-content: space-between;
gap: 12px;
}
.contract-quota-toolbar__tip {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
line-height: 1.5;
}
.customer-cert-detail__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.customer-cert-detail__kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin: 0;
div {
display: grid;
gap: 4px;
}
dt {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #0f172a);
}
}
.ml-1 {
margin-left: 4px;
}
</style>
@@ -0,0 +1,296 @@
<template>
<section :class="embedded ? 'dialog-list-panel' : 'panel'">
<div v-if="!embedded" class="panel-top"><h2 class="title-bar-heading">客户用证</h2></div>
<div v-if="!customerId" class="list-toolbar">
<div class="list-toolbar-row cert-list-toolbar-row">
<ListQuickSearchInput
v-model="keyword"
placeholder="客户编号 / 名称"
@search="search"
@clear="search"
/>
<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>
<div v-if="customerId" class="list-toolbar">
<div class="list-toolbar-row contract-quota-toolbar">
<span class="contract-quota-toolbar__tip"
>展示该客户全部购买证、运输证;额度按单张证件核算,多个合同共用共享余额。</span
>
</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
>
<div v-if="customerId" v-loading="loading">
<CustomerCertQuotaPanel
v-if="fixedCustomerId"
:customer-id="fixedCustomerId"
:active="active && !loading"
/>
<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-customer-quota-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 statistics-expansion"
:style="expansionStyle"
>
<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
>
<CustomerCertQuotaPanel
v-else-if="details[row.id]?.ready"
:customer-id="row.id"
:active="true"
/>
</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
:prop="column.prop"
:label="column.label"
:type="column.type"
:enable-sort="false"
:options="column.enumOptions || []"
:model-value="filters[column.prop] || null"
:is-active="isFilterActive(column.prop)"
@update:model-value="(v) => setFilterState(column.prop, v)"
/>
</div>
</template>
<template #default="{ row }">
<span>{{ 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>
</section>
</template>
<script setup lang="ts">
import { Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import { quotaStatisticsApi } 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 CustomerCertQuotaPanel from './CustomerCertQuotaPanel.vue'
const props = withDefaults(
defineProps<{ customerId?: string; embedded?: boolean; active?: boolean }>(),
{ active: true }
)
const user = useUserStore()
const allowed = ref(false)
const loading = ref(false)
const error = ref('')
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const rows = ref<Record<string, any>[]>([])
const fixedCustomerId = ref('')
const details = reactive<Record<string, { loading: boolean; error?: boolean; ready?: boolean }>>(
{}
)
let epoch = 0
const text = (prop: string, label: string): ColumnFilterDef => ({
prop,
label,
type: 'text',
queryKey: prop
})
const number = (prop: string, label: string): ColumnFilterDef => ({
prop,
label,
type: 'number',
queryKey: prop
})
const columns: ColumnFilterDef[] = [
text('customerNo', '客户编号'),
text('customerName', '客户名称'),
number('certCount', '关联证件数')
]
const {
filters,
hasActiveFilters,
isFilterActive,
setFilterState,
clearAllFilters,
toQueryParams,
watchVisibleColumns
} = useServerColumnFilters(columns, { onChange: search })
const { visibleColumns, checkAll, isIndeterminate, visibleDefs, resetToDefault } =
usePersistedListColumns('pl-customer-quota-columns-v1', columns)
watchVisibleColumns(visibleColumns)
const tableWrapRef = ref<HTMLElement>()
const pagerRef = ref<HTMLElement>()
const tableRef = ref()
const { tableHeight, expansionStyle } = useListTableViewportHeight({
wrapRef: tableWrapRef,
pagerRef,
tableRef
})
async function init() {
try {
const p = await fetchGetMenuCodes()
allowed.value =
!!p?.all || !!p?.isSuperAdmin || (p?.menuCodes || []).includes('pl:quota-cert:list')
if (allowed.value) await load()
else error.value = '无证件查看权限'
} catch {
error.value = '权限加载失败'
}
}
function search() {
page.value = 1
void load()
}
function reset() {
keyword.value = ''
clearAllFilters()
search()
}
async function load() {
if (!props.active) return
const current = ++epoch
loading.value = true
error.value = ''
try {
if (props.customerId) {
const data = await quotaStatisticsApi.customer(String(props.customerId))
if (current !== epoch) return
fixedCustomerId.value = data?.id || String(props.customerId)
rows.value = []
total.value = 0
} else {
fixedCustomerId.value = ''
const data = await quotaStatisticsApi.customers({
page: page.value,
pageSize: 20,
keyword: keyword.value || undefined,
...toQueryParams()
})
if (current !== epoch) return
rows.value = data?.list || []
total.value = data?.total || 0
}
} catch (e: any) {
if (current !== epoch) return
error.value = e?.message || '加载失败'
rows.value = []
total.value = 0
fixedCustomerId.value = ''
} finally {
if (current === epoch) loading.value = false
}
}
function expand(row: { id: string }, expanded: unknown[]) {
const open = Array.isArray(expanded)
? expanded.some((item: any) => item?.id === row.id)
: Boolean(expanded)
if (open) void loadDetail(row)
}
async function loadDetail(row: { id: string }) {
details[row.id] = { loading: true, error: false, ready: false }
try {
await quotaStatisticsApi.customer(row.id)
details[row.id] = { loading: false, ready: true }
} catch {
details[row.id] = { loading: false, error: true }
}
}
watch(
() => [props.customerId, props.active, JSON.stringify([user.accessToken, user.getUserInfo])],
() => {
if (props.active) void init()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.contract-quota-toolbar {
width: 100%;
}
.contract-quota-toolbar__tip {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
line-height: 1.5;
}
.statistics-expansion {
padding: 8px 12px 16px;
}
</style>
+35 -22
View File
@@ -666,7 +666,7 @@
type ContractOption,
type ScopeProduct
} from '@/api/pl/quota-cert'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import CertContractBinder from './CertContractBinder.vue'
import {
@@ -761,28 +761,32 @@
async function ensureCustomerOption(id?: string | null, nameHint = '') {
if (!id) return
if (customerOptions.value.some((c) => c.id === id)) return
if (nameHint) {
customerOptions.value = [{ id, customerName: nameHint }, ...customerOptions.value]
return
}
let name = ''
try {
const result = await fetchCustomerOptions({ keyword: '', limit: 200 })
const list = Array.isArray(result)
? result
: (result as any)?.list || (result as any)?.data || []
const hit = (list || []).find((row: any) => String(row.id) === id)
if (hit) {
customerOptions.value = [
{ id, customerName: String(hit.customerName || hit.name || id) },
...customerOptions.value
]
} else {
customerOptions.value = [{ id, customerName: id }, ...customerOptions.value]
}
const detail = (await fetchCustomerDetail({ id })) as Record<string, any>
const data = detail?.data || detail
name = String(data?.customerName || data?.name || '').trim()
} catch {
customerOptions.value = [{ id, customerName: id }, ...customerOptions.value]
name = ''
}
if (!name) {
try {
const result = await fetchCustomerOptions({ keyword: nameHint || '', limit: 200 })
const list = Array.isArray(result)
? result
: (result as any)?.list || (result as any)?.data || []
const hit = (list || []).find((row: any) => String(row.id) === id)
name = String(hit?.customerName || hit?.name || '').trim()
} catch {
name = ''
}
}
// 系统客户名称以 CRM 为准;nameHint 仅作 CRM 不可用时的兜底(应为接口返回的 customerName)。
const label = name || String(nameHint || '').trim() || id
customerOptions.value = [
{ id, customerName: label },
...customerOptions.value.filter((c) => c.id !== id)
]
}
async function loadCategories() {
@@ -1048,8 +1052,17 @@
value: statusLabels[detail.value.status],
tagType: tagType(detail.value.status)
}
const systemCustomerField: ViewField = {
label: '系统客户',
value: String(detail.value.customerName || '').trim() || '—',
span: 'full'
}
const groups = certFormGroups(kind.value).map((group, index) =>
detailGroup(group.title, group.fields, index === 0 ? [statusField] : [])
detailGroup(
group.title,
group.fields,
index === 0 ? [statusField, systemCustomerField] : []
)
)
groups[groups.length - 1].fields.push({
label: '系统备注',
@@ -1424,7 +1437,7 @@
applyCertForm(targetKind, cert)
await ensureCustomerOption(
form.value.customerId,
formKind.value === 'purchase' ? form.value.buyerName : form.value.consigneeName
String((cert as QuotaCert).customerName || '').trim()
)
if (targetKind === 'transport') purchases.value = purchaseOptions()
} catch (error) {