feat(pl): 实现合同产品用证与额度结转以支持购买证和运输证全流程管理。

This commit is contained in:
wuzilong committed 2026-11-01 18:56:05 +08:00
1 parent cb0344498a
commit fb866c7fa7
104 files changed
+10187 -3833

No files matched your search

@@ -24,6 +24,13 @@
>{{ dictStore.getLabel('crm_contract_status', detailContract?.status) }}</el-tag>
</div>
<div class="detail-actions">
<el-button
v-if="canViewQuotaCert && detailContract?.id"
class="btn-ghost"
@click="router.push({ name: 'PlQuotaCert', query: { contractId: String(detailContract.id) } })"
>
<el-icon><DocumentChecked /></el-icon>适用证件与产品
</el-button>
<el-button
v-if="manageActions"
class="btn-ghost"
@@ -974,6 +981,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { fetchGetMenuCodes } from '@/api/auth'
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from '../contractType'
import {
displayFrameworkMoney,
@@ -1078,6 +1086,7 @@ import {
const dictStore = useDictStore();
const router = useRouter();
const canViewQuotaCert = ref(false)
const props = withDefaults(
defineProps<{
@@ -3268,6 +3277,8 @@ const handleGeneratePdfFromDialog = async () => {
onMounted(async () => {
await dictStore.loadAllDict();
const perms = await fetchGetMenuCodes()
canViewQuotaCert.value = !!perms.all || !!perms.isSuperAdmin || (perms.menuCodes || []).includes('pl:quota-cert:list')
});
function formatPlanQty(value: unknown) {
@@ -0,0 +1,199 @@
<template>
<MpWebviewShell @ready="init">
<MpBusinessHeader
title="合法使用证明"
searchable
:keyword="keyword"
search-placeholder="输入计划编号"
@update:keyword="keyword = $event"
@search="load"
@back="goToMiniWorkbench"
/>
<main class="mp-h5-list-content">
<van-dropdown-menu
><van-dropdown-item v-model="status" :options="statusOptions" @change="load"
/></van-dropdown-menu>
<p class="form-tip">易制爆产品的合法使用证明可在安排、归档后补传,不影响额度预占。</p>
<p v-if="error" role="alert">{{ error }}</p>
<van-loading v-if="loading" />
<section v-for="row in rows" :key="row.id" class="mp-wv-section"
><h3>{{ row.planNo }}</h3
><p>{{ row.products.map((p) => p.productName).join('、') }}</p
><van-tag
:type="row.products.every((p) => p.documentStatus === 'uploaded') ? 'success' : 'warning'"
>{{ row.products.filter((p) => p.documentStatus === 'pending').length }} 项待补</van-tag
><van-button block plain type="primary" @click="open(row.id)">{{
canUpload ? '维护资料' : '查看资料'
}}</van-button></section
>
<van-empty v-if="!loading && !rows.length && !error" description="暂无符合条件的计划" />
</main>
<van-popup
:show="visible"
position="bottom"
round
teleport="body"
:close-on-click-overlay="!saving"
:style="{ height: '70vh', '--van-primary-color': 'var(--el-color-primary)' }"
class="mp-h5-sheet"
@update:show="close"
>
<van-nav-bar
:title="detail?.planNo || '合法使用证明'"
left-text="关闭"
@click-left="close(false)"
/>
<div class="mp-h5-list-content"
><section
v-for="p in detail?.products || []"
:key="`${session}:${p.id}`"
class="mp-wv-section"
><h3>{{ p.productName }} {{ p.model }}</h3>
<p v-for="context in p.contexts || []" :key="context.stopProductId"
>{{ context.stopType === 'load' ? '装货' : '送货' }}:{{
context.partyName || '未填写单位'
}}
· {{ context.address || '未填写地址' }} · 合同
{{ context.contractNo || '未关联或无查看权限' }}</p
>
<AttachmentUploadField
:ref="(el) => setUpload(p.id, el)"
v-model="p.legalUseCertUrl"
:disabled="!canUpload || saving"
folder="pl/legal-use-document" /></section
><van-button
v-if="canUpload"
block
type="primary"
:loading="saving"
:disabled="saving"
@click="save"
>保存资料</van-button
></div
>
</van-popup>
</MpWebviewShell>
</template>
<script setup lang="ts">
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { legalDocumentApi, type LegalDocument } from '@/api/pl/quota-cert'
import { useUserStore } from '@/store/modules/user'
import { goToMiniWorkbench } from '@/utils/mp-webview'
import '@/styles/mp-webview.scss'
const status = ref('')
const statusOptions = [
{ text: '全部资料', value: '' },
{ text: '待上传', value: 'pending' },
{ text: '已上传', value: 'uploaded' }
]
const keyword = ref(''),
rows = ref<LegalDocument[]>([]),
detail = ref<LegalDocument>(),
visible = ref(false),
canUpload = ref(false),
loading = ref(false),
saving = ref(false),
error = ref(''),
session = ref(0)
const user = useUserStore(),
uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
let shellReady = false
let epoch = 0,
listVersion = 0
function setUpload(id: string, el: any) {
if (el) uploads.set(id, el)
else uploads.delete(id)
}
async function init() {
shellReady = true
const current = epoch
const p = await fetchGetMenuCodes()
if (current !== epoch) return
canUpload.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:plan-cert:upload')
await load()
}
async function load() {
const current = epoch,
version = ++listVersion
loading.value = true
error.value = ''
try {
const value = await legalDocumentApi.list({
keyword: keyword.value,
status: status.value || undefined
})
if (current === epoch && version === listVersion) rows.value = value
} catch {
if (current === epoch && version === listVersion) error.value = '资料加载失败,请重新搜索'
} finally {
if (current === epoch && version === listVersion) loading.value = false
}
}
async function open(id: string) {
if (saving.value) return
const current = ++session.value,
auth = epoch
const value = await legalDocumentApi.get(id)
if (current !== session.value || auth !== epoch) return
detail.value = value
visible.value = true
}
function close(value: boolean) {
if (saving.value) return
visible.value = value
if (!value) {
session.value++
detail.value = undefined
}
}
async function save() {
if (!detail.value || saving.value || !canUpload.value) return
if ([...uploads.values()].some((u) => u.hasUploading())) {
ElMessage.warning('请等待附件上传完成')
return
}
const current = session.value,
auth = epoch,
id = detail.value.id
saving.value = true
try {
await legalDocumentApi.save(
id,
detail.value.products.map((p) => ({ id: p.id, legalUseCertUrl: p.legalUseCertUrl }))
)
if (current !== session.value || auth !== epoch) return
visible.value = false
detail.value = undefined
session.value++
ElMessage.success('合法使用证明已保存')
await load()
} finally {
if (auth === epoch) saving.value = false
}
}
watch(
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
epoch++
session.value++
rows.value = []
detail.value = undefined
visible.value = false
saving.value = false
canUpload.value = false
uploads.clear()
error.value = ''
loading.value = false
if (shellReady && user.accessToken) void init()
},
{ flush: 'sync' }
)
onBeforeUnmount(() => {
epoch++
session.value++
})
</script>
@@ -271,10 +271,6 @@
label: '送货时间',
value: formatMpPlanYmdList(props.plan.deliveryTimes)
},
hazardCertReady: {
label: '证书齐备',
value: isHazardCertReady(props.plan) ? '已齐备' : certGapLabel.value
},
licensePlate: {
label: '车牌号',
value: formatPlVehiclePlateDisplay(props.plan.licensePlate, props.plan.trailerNo) || '—'
+2 -252
View File
@@ -1,254 +1,4 @@
<template>
<MpWebviewShell @ready="init">
<main class="cert-module cert-detail-page mp-h5-detail-page">
<MpBusinessHeader title="危化品证详情" back-label="返回危化品证列表" @back="back" />
<CertDetail
v-if="shellReady && userStore.accessToken"
:key="'detail-' + recordEpoch"
ref="detailRef"
:plan-id="id"
:can-upload="canUpload"
:can-ready="canReady"
:can-price="canPrice"
:busy="acting"
:permission-error="permissionError"
:permissions-loaded="permissionsLoaded"
@view="openDetail"
@upload="openUpload"
@ready="setReady"
@retry-permissions="loadPermissions"
/>
<CertUpload
v-if="shellReady && userStore.accessToken"
:key="'upload-' + recordEpoch"
ref="uploadRef"
v-model="uploadOpen"
:plan-id="id"
:permitted="canUpload"
@success="saved"
/>
</main>
</MpWebviewShell>
</template>
<template><PlanQuotaPage /></template>
<script setup lang="ts">
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
import '@/styles/mp-webview.scss'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import { planApi, type Plan } from '@/api/pl'
import {
HAZARD_CERT_READY_CONFIRM_HINT,
isHazardCertReady,
planHasHazardProducts
} from '@/composables/usePlToxicProductCertCheck'
import { canEdit } from './model'
import CertDetail from './CertDetail.vue'
import CertUpload from './CertUpload.vue'
import './cert.scss'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const id = computed(() => String(route.params.id || ''))
const detailRef = ref<InstanceType<typeof CertDetail>>()
const uploadRef = ref<InstanceType<typeof CertUpload>>()
const shellReady = ref(false)
const recordEpoch = ref(0)
let disposed = false
let recordVersion = 0
let operationVersion = 0
let permissionVersion = 0
let queryVersion = 0
const uploadOpen = ref(false)
const acting = ref(false)
const canUpload = ref(false)
const canReady = ref(false)
const canPrice = ref(false)
const permissionError = ref(false)
const permissionsLoaded = ref(false)
function back() {
void router.replace('/m/pl/plan-cert')
}
function openDetail(planId: string) {
void router.push(`/m/pl/plan-cert/${planId}`)
}
function openUpload(plan: Plan) {
if (
!shellReady.value ||
disposed ||
plan.id !== id.value ||
!canUpload.value ||
!canEdit(plan) ||
acting.value
) {
ElMessage.warning('无上传权限或该计划不可编辑')
return
}
uploadOpen.value = true
}
async function saved(planId: string) {
if (disposed || planId !== id.value) return
await detailRef.value?.reload()
}
function invalidateRecord() {
recordEpoch.value++
recordVersion++
operationVersion++
queryVersion++
// 父路由同步失效,早于 Vue 更新子组件 props / 卸载 keyed 实例。
uploadRef.value?.invalidateRecord()
detailRef.value?.invalidateRecord()
uploadOpen.value = false
acting.value = false
}
watch(
id,
() => {
invalidateRecord()
if (shellReady.value) void openQueryContract()
},
{ flush: 'sync' }
)
watch(
() => JSON.stringify([userStore.accessToken, userStore.info]),
() => {
invalidateRecord()
permissionVersion++
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionsLoaded.value = false
permissionError.value = false
if (shellReady.value && !disposed && userStore.accessToken) {
void loadPermissions()
void openQueryContract()
}
},
{ flush: 'sync' }
)
onBeforeUnmount(() => {
disposed = true
permissionVersion++
invalidateRecord()
})
async function setReady(plan: Plan, ready: boolean) {
if (
!shellReady.value ||
disposed ||
plan.id !== id.value ||
!canReady.value ||
acting.value ||
!canEdit(plan)
)
return
const targetId = id.value
const version = ++operationVersion
const current = () => !disposed && version === operationVersion && targetId === id.value
acting.value = true
try {
const result = await planApi.get(targetId)
if (!current() || !canReady.value) return
const fresh = (result.data ?? result) as Plan
if (!fresh || String(fresh.id || '') !== targetId) throw new Error('计划不存在或已失效')
if (!canEdit(fresh) || !planHasHazardProducts(fresh.products)) {
ElMessage.warning('该计划当前不可操作')
await detailRef.value?.reload()
return
}
if (isHazardCertReady(fresh) === ready) {
ElMessage.info(ready ? '证书已齐备' : '证书已取消齐备')
await detailRef.value?.reload()
return
}
const message = ready
? `${HAZARD_CERT_READY_CONFIRM_HINT}确认后可安排运输,之后仍可补充附件。`
: '取消后将无法安排运输。'
try {
await showCrmConfirmDialog({
kind: ready ? 'default' : 'danger',
title: ready ? '证书齐备确认' : '取消齐备',
message,
confirmButtonText: ready ? '确认齐备' : '确认取消'
})
} catch {
return
}
if (!current() || !canReady.value) return
await planApi.setHazardCertReady(targetId, ready)
if (!current() || !canReady.value) return
ElMessage.success(ready ? '证书已标记齐备' : '已取消证书齐备')
await detailRef.value?.reload()
} catch {
if (current()) ElMessage.error('操作失败,请重试')
} finally {
if (current()) acting.value = false
}
}
async function loadPermissions() {
if (disposed || !userStore.accessToken) return
const version = ++permissionVersion
const current = () => !disposed && version === permissionVersion
operationVersion++
acting.value = false
uploadRef.value?.invalidateRecord()
uploadOpen.value = false
permissionsLoaded.value = false
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionError.value = false
try {
const permission = await fetchGetMenuCodes()
if (!current()) return
const all = !!permission?.all || !!permission?.isSuperAdmin
canUpload.value = all || (permission?.menuCodes || []).includes('pl:plan-cert:upload')
canReady.value = all || (permission?.menuCodes || []).includes('pl:plan-cert:ready')
canPrice.value =
all || (permission?.menuCodes || []).includes('pl:contract:view-product-price')
} catch {
if (current()) permissionError.value = true
} finally {
if (current()) permissionsLoaded.value = true
}
}
async function openQueryContract() {
if (!shellReady.value || disposed) return
const version = ++queryVersion
const targetId = id.value
const record = recordVersion
const contractId = String(route.query.contractId || '')
if (!contractId) return
await nextTick()
if (disposed || record !== recordVersion || version !== queryVersion || targetId !== id.value)
return
await detailRef.value?.openContract(contractId)
}
async function init() {
if (shellReady.value || disposed) return
shellReady.value = true
const version = recordVersion
await loadPermissions()
if (disposed || version !== recordVersion) return
await openQueryContract()
}
watch(
() => route.query.contractId,
() => void openQueryContract()
)
import PlanQuotaPage from './index.vue'
</script>
+21 -638
View File
@@ -1,650 +1,33 @@
<template>
<MpWebviewShell @ready="init">
<main class="cert-module cert-page mp-plan-cert-list mp-cert-list-touch-scope">
<MpBusinessHeader
:keyword="keyword"
title="危化品证上传"
searchable
search-placeholder="计划编号 / 供方 / 需方"
search-label="搜索计划"
back-label="返回工作台"
@back="goToMiniWorkbench"
@search="search"
@update:keyword="onKeywordUpdate"
>
<template #before-search>
<van-tabs
:active="tab"
class="mp-customer-view-tabs"
shrink
:ellipsis="false"
aria-label="计划分类"
@change="changeTab"
>
<van-tab
v-for="item in PL_PLAN_CERT_TABS"
:key="item.name"
:name="item.name"
:title="item.label"
/>
</van-tabs>
</template>
<template #after-search>
<van-dropdown-menu class="mp-customer-dd">
<van-dropdown-item
v-model="dateKind"
:options="dateDropdownOptions"
@change="setDate"
/>
<van-dropdown-item
v-model="readyKind"
:options="readyDropdownOptions"
@change="setReadyFilter"
/>
<van-dropdown-item
ref="moreDropdown"
:title="extraFilterCount > 0 ? '更多·已筛' : '更多'"
@open="openMoreFilters"
/>
</van-dropdown-menu>
</template>
</MpBusinessHeader>
<div class="cert-body">
<p v-if="sessionChanged" class="cert-hint" role="alert">
登录信息已变更,请重新加载当前会话的数据。
<button class="cert-btn" @click="reloadSession">重新加载</button>
</p>
<p v-if="permissionError" class="cert-hint" role="alert"
>操作权限读取失败,暂不可上传或确认齐备。<button
class="cert-link"
@click="loadPermissions"
>重试权限</button
></p
>
<p v-else-if="permissionsLoaded && !canUpload && !canReady" class="cert-hint"
>当前仅有查看权限</p
>
<p v-if="error" class="cert-error" role="alert">{{ error }}</p>
<button v-if="error" class="cert-btn" @click="retryLoad">重试加载</button>
<div v-if="!loading && !error && !visibleCards.length" class="cert-empty"
>暂无符合条件的计划</div
>
<PlanCard
v-for="item in visibleCards"
:key="item.plan.id"
:plan="planDetails.cardPlan(item.plan)"
:rows="item.rows"
:can-upload="canUpload"
:can-ready="canReady"
:busy="acting"
:expanded="expandedIds.has(item.plan.id)"
:details-ready="!!planDetails.records[item.plan.id]?.plan"
:detail-error="planDetails.records[item.plan.id]?.error || ''"
@retry="planDetails.ensure(item.plan.id)"
@update:expanded="toggleExpanded(item.plan.id, $event)"
@view="openDetail"
@upload="openUpload"
@ready="setReady"
@contract="(id) => openContract(item.plan.id, id)"
/>
<MpListPageSkeleton
v-if="loading"
variant="body"
layout="customer"
:card-count="1"
:caption="failedMore ? '正在加载更多' : '正在加载计划'"
:aria-label="failedMore ? '正在加载更多' : '正在加载计划'"
/>
<p v-else-if="!error && plans.length" class="cert-total mp-h5-load-hint" role="status">
<span>共 {{ total }} 条计划 · 已加载 {{ plans.length }} 条</span>
<span v-if="plans.length >= total"> · 已加载全部</span>
</p>
<div
v-if="plans.length < total && !error"
ref="loadSentinel"
class="mp-h5-load-sentinel"
aria-hidden="true"
/>
</div>
</main>
<CertFilters
:key="sessionGeneration"
v-model="filtersOpen"
:filters="filters"
:sort="sort"
:context="query()"
@apply="applyFilters"
/>
<CertUpload
ref="uploadRef"
v-model="uploadOpen"
:plan-id="uploadId"
:permitted="canUpload"
@success="saved"
/>
</MpWebviewShell>
<MpWebviewShell @ready="ready = true"
><MpBusinessHeader
title="计划用证"
back-label="返回工作台"
@back="goToMiniWorkbench" /><PlanQuotaList
v-if="ready"
:key="session"
mobile
:plan-id="planId"
/></MpWebviewShell>
</template>
<script setup lang="ts">
import { useListReturnState } from '../../crm/useListReturnState'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import MpListPageSkeleton from '@/components/mp/MpListPageSkeleton.vue'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
import '@/styles/mp-webview.scss'
import '../../crm/customer/customer.scss'
import { planApi, type Plan, type PlanListResponse } from '@/api/pl'
import { fetchGetMenuCodes } from '@/api/auth'
import PlanQuotaList from '@/views/pl/plan-quota/index.vue'
import { useUserStore } from '@/store/modules/user'
import { goToMiniWorkbench } from '@/utils/mp-webview'
import { PL_PLAN_CERT_TABS, PL_PLAN_DEFAULT_FOLLOW_STATUSES } from '@/utils/pl-display'
import { plPlanCertColumnFilterDefs } from '@/views/pl/plan-cert/columnFilterDefs'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { flattenPlanArchivePairRows } from '@/utils/pl-plan-list-display-rows'
import {
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
selectedContractStopKinds,
selectedKeywordsFromFilter
} from '@/utils/pl-plan-pair-list-query'
import {
planHasHazardProducts,
HAZARD_CERT_READY_CONFIRM_HINT
} from '@/composables/usePlToxicProductCertCheck'
import { buildQuery, canEdit, readyTargets, dateShortcut } from './model'
import CertFilters from './CertFilters.vue'
import CertUpload from './CertUpload.vue'
import PlanCard from './PlanCard.vue'
import { useMobilePlanListDetails } from '../plan/mobileListDetails'
import './cert.scss'
const userStore = useUserStore()
const menuStore = useMenuStore()
const sessionScope = () => JSON.stringify([userStore.accessToken, userStore.getUserInfo])
async function refreshSidebarBadges() {
try {
await refreshMenuBadges(menuStore.menuList)
} catch {
/* badge refresh is best-effort */
}
}
let disposed = false
let actionVersion = 0
let permissionVersion = 0
const tab = ref('cert_incomplete')
const keyword = ref('')
const appliedKeyword = ref('')
const dates = ref<string[]>([])
const dateKind = ref('all')
const dateOptions = [
{ value: 'all', label: '全部日期' },
{ value: 'today', label: '今天' },
{ value: 'tomorrow', label: '明天' },
{ value: 'yesterday', label: '昨天' },
{ value: 'week', label: '本周' }
]
const readyOptions = [
{ value: 'all', label: '全部齐备状态' },
{ value: '0', label: '未齐备' },
{ value: '1', label: '已齐备' }
]
const { filters, toQueryParams } = useServerColumnFilters(plPlanCertColumnFilterDefs, {
initialFilters: { followStatus: { text: '', selected: [...PL_PLAN_DEFAULT_FOLLOW_STATUSES] } }
})
const sort = reactive({ sortField: '', sortOrder: 'desc' })
const readyKind = ref('all')
const dateDropdownOptions = computed(() =>
dateOptions.map((item) => ({ text: item.label, value: item.value }))
)
const readyDropdownOptions = computed(() =>
readyOptions.map((item) => ({ text: item.label, value: item.value }))
)
const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
const loadSentinel = ref<HTMLElement | null>(null)
const extraFilterCount = computed(() => {
let count = Object.keys(filters).filter(
(key) => !['followStatus', 'planDate', 'hazardCertReady'].includes(key)
).length
const selectedFollow = (filters.followStatus as any)?.selected || []
if (
selectedFollow.length !== PL_PLAN_DEFAULT_FOLLOW_STATUSES.length ||
selectedFollow.some(
(value: string) => !PL_PLAN_DEFAULT_FOLLOW_STATUSES.includes(value as any)
)
)
count++
if (sort.sortField) count++
return count
})
const filtersOpen = ref(false)
const uploadOpen = ref(false)
const uploadId = ref('')
const uploadRef = ref<InstanceType<typeof CertUpload> | null>(null)
let uploadScope = ''
const sessionChanged = ref(false)
const sessionGeneration = ref(0)
const canUpload = ref(false)
const canReady = ref(false)
const canPrice = ref(false)
const permissionError = ref(false)
const permissionsLoaded = ref(false)
const plans = ref<Plan[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(true)
const error = ref('')
const failedMore = ref(false)
const acting = ref(false)
let requestVersion = 0
const expandedIds = ref(new Set<string>())
const hasMore = computed(() => plans.value.length < total.value)
const visibleCards = computed(() => {
const rows = flattenPlanArchivePairRows(plans.value, {
loadCustomerKeywords: selectedKeywordsFromFilter(filters.loadCustomer as any),
deliveryCustomerKeywords: selectedKeywordsFromFilter(filters.deliveryCustomer as any),
productKeywords: selectedKeywordsFromFilter(filters.productName as any),
kinds: selectedContractStopKinds(filters.productContract as any)
})
return plans.value
.map((plan) => ({ plan, rows: rows.filter((row) => row.plan.id === plan.id) }))
.filter((item) => item.rows.length)
})
function query() {
const q = buildQuery(
tab.value,
{ ...toQueryParams(), ...(sort.sortField ? sort : {}) },
appliedKeyword.value,
dates.value
)
applyMergedCustomerQueryParams(q)
applyContractSideQueryParams(q, filters.productContract as any)
return q
}
function requestKey() {
return JSON.stringify([keyword.value, query(), sessionScope()])
}
const planDetails = useMobilePlanListDetails({
scope: requestKey,
load: (id) => planApi.get(id, true)
})
watch(expandedIds, (ids) => {
ids.forEach((id) => void planDetails.ensure(id))
})
async function load(more = false) {
if (disposed || (more && loading.value)) return false
const v = ++requestVersion
const key = requestKey()
const params = { ...query() }
const current = () => !disposed && v === requestVersion && key === requestKey()
const nextPage = more ? page.value + 1 : 1
loading.value = true
error.value = ''
failedMore.value = more
if (!more) {
planDetails.reset()
page.value = 1
plans.value = []
expandedIds.value = new Set()
}
try {
const res = await planApi.list({ ...params, page: nextPage, pageSize: 20 })
if (!current()) return false
const data = (res.data ?? res) as PlanListResponse
plans.value = more
? [...plans.value, ...(data.list || [])].filter(
(p, i, all) => all.findIndex((other) => other.id === p.id) === i
)
: data.list || []
total.value = Number(data.total || 0)
page.value = nextPage
return true
} catch {
if (current()) error.value = '计划加载失败,请重试'
return false
} finally {
if (current()) loading.value = false
}
}
const filterState = computed({
get: () => filters,
set: (value) => {
Object.keys(filters).forEach((key) => delete filters[key])
Object.assign(filters, value)
}
})
const sortState = computed({ get: () => sort, set: (value) => Object.assign(sort, value) })
function retryLoad() {
return listReturnState.retry(!failedMore.value)
}
const listReturnState = useListReturnState({
retryFailedRestore: true,
fields: { keyword, appliedKeyword, tab, dates, dateKind, readyKind, filterState, sortState },
page,
expandedIds,
hasMore,
queryKey: requestKey,
fetchPage: (reset) => load(!reset),
restoreExpanded: async (ids) => {
const key = requestKey()
const version = requestVersion
const results = await Promise.all(
ids.filter((id) => plans.value.some((plan) => plan.id === id)).map(planDetails.ensure)
)
if (disposed || key !== requestKey() || version !== requestVersion) return false
if (results.some((ok) => !ok)) {
error.value = '展开信息恢复失败,请重试'
return false
}
return true
},
isDetailPath: (path) => /^\/m\/pl\/plan-cert\/[^/]+$/.test(path)
})
function toggleExpanded(id: string, expanded: boolean) {
const next = new Set(expandedIds.value)
if (expanded) next.add(id)
else next.delete(id)
expandedIds.value = next
}
function search() {
if (disposed) return
appliedKeyword.value = keyword.value
void load()
}
const scheduleKeywordSearch = useDebounceFn(() => search(), 300)
function onKeywordUpdate(value: string) {
const cleared = Boolean(keyword.value.trim()) && !value.trim()
keyword.value = value
if (cleared) {
search()
return
}
void scheduleKeywordSearch()
}
useIntersectionObserver(
loadSentinel,
([entry]) => {
if (
!entry?.isIntersecting ||
loading.value ||
error.value ||
listReturnState.restoring.value ||
plans.value.length >= total.value
)
return
void load(true)
},
{ rootMargin: '160px 0px' }
)
function changeTab(value: string) {
tab.value = value
delete filters.planType
if (value === 'cert_incomplete') {
delete filters.hazardCertReady
readyKind.value = 'all'
}
search()
}
function openMoreFilters() {
moreDropdown.value?.toggle?.(false)
filtersOpen.value = true
}
function setDate(kind: string) {
dateKind.value = kind
dates.value = dateShortcut(kind)
delete filters.planDate
search()
}
function setReadyFilter(value: string) {
if (value === '1' && tab.value === 'cert_incomplete') tab.value = 'all'
readyKind.value = value
if (value === 'all') delete filters.hazardCertReady
else filters.hazardCertReady = { text: '', selected: [value] } as any
search()
}
function applyFilters(next: Record<string, any>, nextSort: typeof sort, resetQuick = false) {
Object.keys(filters).forEach((k) => delete filters[k])
Object.assign(filters, next)
Object.assign(sort, nextSort)
if (resetQuick) {
dateKind.value = 'all'
dates.value = []
}
if (filters.planDate) {
dateKind.value = 'custom'
dates.value = []
} else if (dateKind.value === 'custom') {
dateKind.value = 'all'
}
const readyValues = (filters.hazardCertReady as any)?.selected || []
if (readyValues.map(String).includes('1') && tab.value === 'cert_incomplete') tab.value = 'all'
if (readyValues.length === 1 && ['0', '1'].includes(String(readyValues[0]))) {
readyKind.value = String(readyValues[0])
} else {
readyKind.value = 'all'
}
search()
}
function openDetail(id: string) {
void router.push(`/m/pl/plan-cert/${id}`)
}
function openContract(planId: string, contractId: string) {
void router.push({ path: `/m/pl/plan-cert/${planId}`, query: { contractId } })
}
function openUpload(p: Plan) {
if (!canUpload.value || !canEdit(p) || acting.value) {
ElMessage.warning('无上传权限或该计划不可编辑')
return
}
uploadScope = sessionScope()
uploadId.value = p.id
uploadOpen.value = true
}
async function saved(id: string) {
if (disposed || !canUpload.value || id !== uploadId.value || uploadScope !== sessionScope())
return
const scope = uploadScope
const generation = sessionGeneration.value
const loaded = await load()
await refreshSidebarBadges()
if (loaded && !disposed && generation === sessionGeneration.value && scope === sessionScope())
openDetail(id)
}
import '@/styles/mp-webview.scss'
const route = useRoute(),
user = useUserStore(),
ready = ref(false),
session = ref(0)
const planId = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
watch(
canReady,
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
actionVersion++
acting.value = false
ready.value = ready.value && !!user.accessToken
session.value++
},
{ flush: 'sync' }
)
async function setReady(row: Plan, ready: boolean) {
if (disposed || !canReady.value || acting.value) return
const id = row.id
const v = ++actionVersion
const scope = sessionScope()
const current = () =>
!disposed && v === actionVersion && scope === sessionScope() && canReady.value
acting.value = true
try {
const res = await planApi.get(id)
if (!current()) return
const fresh = (res.data ?? res) as Plan
if (!fresh || String(fresh.id || '') !== id) {
ElMessage.warning('计划信息不匹配,请重新打开')
return
}
if (!readyTargets([fresh], ready, (p) => planHasHazardProducts(p.products)).length) {
ElMessage.warning('该计划当前不可操作')
return
}
try {
await showCrmConfirmDialog({
kind: ready ? 'default' : 'danger',
title: ready ? '证书齐备确认' : '取消齐备',
message: ready
? `${HAZARD_CERT_READY_CONFIRM_HINT}确认后可安排运输,之后仍可补充附件。`
: '取消后将无法安排运输。',
confirmButtonText: ready ? '确认齐备' : '确认取消'
})
} catch {
return
}
if (!current()) return
await planApi.setHazardCertReady(id, ready)
if (!current()) return
ElMessage({ type: 'success', message: ready ? '已标记证书齐备' : '已取消齐备' })
await load()
if (current()) await refreshSidebarBadges()
} catch {
if (current()) ElMessage.warning('操作失败,请重试')
} finally {
if (!disposed && v === actionVersion) acting.value = false
}
}
async function loadPermissions() {
if (disposed) return
const v = ++permissionVersion
const scope = sessionScope()
const current = () => !disposed && v === permissionVersion && scope === sessionScope()
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionError.value = false
try {
const p = await fetchGetMenuCodes()
if (!current()) return
const all = !!p?.all || !!p?.isSuperAdmin
canUpload.value = all || (p?.menuCodes || []).includes('pl:plan-cert:upload')
canReady.value = all || (p?.menuCodes || []).includes('pl:plan-cert:ready')
canPrice.value = all || (p?.menuCodes || []).includes('pl:contract:view-product-price')
} catch {
if (current()) permissionError.value = true
} finally {
if (current()) permissionsLoaded.value = true
}
}
watch(
sessionScope,
() => {
requestVersion++
actionVersion++
permissionVersion++
sessionGeneration.value++
// Props update on the next render; invalidate pending child saves in this same tick.
uploadRef.value?.invalidateRecord()
uploadOpen.value = false
uploadId.value = ''
uploadScope = ''
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionsLoaded.value = false
permissionError.value = false
plans.value = []
expandedIds.value = new Set()
total.value = 0
page.value = 1
loading.value = false
acting.value = false
error.value = ''
failedMore.value = false
keyword.value = ''
appliedKeyword.value = ''
tab.value = 'cert_incomplete'
dates.value = []
dateKind.value = 'all'
readyKind.value = 'all'
Object.keys(filters).forEach((key) => delete filters[key])
filters.followStatus = { text: '', selected: [...PL_PLAN_DEFAULT_FOLLOW_STATUSES] }
Object.assign(sort, { sortField: '', sortOrder: 'desc' })
filtersOpen.value = false
moreDropdown.value?.toggle?.(false)
sessionChanged.value = true
},
{ flush: 'sync' }
)
async function reloadSession() {
if (disposed) return
sessionChanged.value = false
await Promise.allSettled([loadPermissions(), load()])
}
async function init() {
if (sessionChanged.value) return reloadSession()
await Promise.allSettled([loadPermissions(), listReturnState.restore()])
}
const router = useRouter()
onBeforeUnmount(() => {
disposed = true
actionVersion++
permissionVersion++
requestVersion++
})
</script>
<style scoped lang="scss">
.mp-plan-list,
.mp-plan-cert-list {
:deep(.mp-wv-header__back),
:deep(.van-dropdown-menu__item),
:deep(.van-dropdown-menu__title),
:deep(.mp-wv-flat-card__toggle),
:deep(.mp-wv-flat-card__check),
:deep(.cert-link),
:deep(.cert-btn),
:deep(.mp-wv-btn),
:deep(.mp-wv-speed-item) {
min-width: 44px;
min-height: 44px;
height: auto;
}
:deep(.van-dropdown-menu__title) {
display: flex;
align-items: center;
}
:deep(.mp-wv-flat-card__check) {
flex: 0 0 44px;
justify-content: center;
}
:deep(.mp-wv-card-actions) {
flex-wrap: wrap;
}
:deep(.mp-wv-flat-card .mp-wv-card-actions .mp-wv-btn) {
min-height: 44px;
height: auto;
flex: 1 0 auto;
white-space: nowrap;
}
:deep(.mp-wv-flat-card__row > span:first-child),
:deep(.mp-wv-flat-card__line > span:first-child),
:deep(.mp-wv-card__kv-row > span:first-child) {
flex-shrink: 0;
white-space: nowrap;
}
}
.mp-plan-cert-list .cert-body {
padding-bottom: calc(148px + env(safe-area-inset-bottom, 0px));
}
.mp-plan-cert-list .cert-batch {
gap: 8px;
}
</style>
<style lang="scss">
html
body
:is(.mp-plan-cert-list, .mp-cert-filter-popup).mp-cert-list-touch-scope
:is(.mp-wv-btn, .cert-btn, .filter-chip, .van-button, .el-button, .cert-link):not(
.mp-wv-speed-main
):not(.van-stepper__minus):not(.van-stepper__plus) {
min-width: 44px !important;
min-height: 44px !important;
height: auto !important;
line-height: 42px !important;
}
.mp-plan-cert-list .cert-card-attachments {
min-width: 0;
padding-top: 12px;
}
</style>
@@ -1,51 +1,8 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildQuery, canEdit, readyTargets, certPayload, canSave, dateShortcut } from './model'
import { certPayload, canSave, dateShortcut } from './model'
import * as model from './model'
test('five tabs preserve hazard scope and enforce their constraints', () => {
for (const tab of ['cert_incomplete', 'all', 'inbound', 'outbound', 'direct']) {
const q = buildQuery(tab, {}, '')
assert.equal(q.hasHazardProduct, 1)
assert.equal(q.followStatus, undefined, 'default status is supplied by the list filters')
if (tab === 'cert_incomplete') {
assert.equal(q.certUploadStatus, 'incomplete')
assert.equal(q.hazardCertReady, 0)
} else if (tab !== 'all') assert.equal(q.planType, tab)
else assert.equal(q.planType, undefined)
}
})
test('tab wins conflicts while explicit cancelled status is retained', () => {
const q = buildQuery(
'cert_incomplete',
{ hazardCertReadyIn: '1', followStatusIn: 'cancelled' },
' x '
)
assert.equal(q.hazardCertReady, 0)
assert.equal(q.hazardCertReadyIn, undefined)
assert.equal(q.followStatus, 'cancelled')
assert.equal(q.q, 'x')
assert.equal(buildQuery('direct', { planTypeIn: 'outbound' }, '').planTypeIn, undefined)
assert.equal(buildQuery('all', { hazardCertReadyIn: '1' }, '').hazardCertReady, 1)
})
test('closed plans and wrong ready states are skipped; hazard predicate is required', () => {
const rows = [
{ id: 'a', hazardCertReady: 0 },
{ id: 'b', hazardCertReady: 1 },
{ id: 'c', followStatus: 'cancelled' },
{ id: 'd', archiveStatus: 'archived' },
{ id: 'e' }
]
assert.equal(canEdit(rows[2]), false)
assert.deepEqual(
readyTargets(rows, true, (p) => p.id !== 'e').map((p) => p.id),
['a']
)
assert.deepEqual(
readyTargets(rows, false, () => true).map((p) => p.id),
['b']
)
})
test('certificate payload contains only id and trimmed URLs; empty slots are allowed', () => {
assert.deepEqual(certPayload([{ id: 'p', productName: 'secret', purchaseCertUrl: ' a,b ' }]), [
{ id: 'p', purchaseCertUrl: 'a,b', transportCertUrl: '', legalUseCertUrl: '' }
@@ -141,11 +98,6 @@ test('contract reads enrich ids and saved product aliases without changing sourc
assert.equal(failed.warnings.length, 3)
})
test('clearing follow status does not silently restore the initial defaults', () => {
assert.equal(buildQuery('all', {}, '').followStatus, undefined)
assert.equal(buildQuery('all', { followStatusIn: 'cancelled' }, '').followStatus, 'cancelled')
})
test('explicit segment metadata wins over root product including empty alias and brand', () => {
const root = [
{
+4 -50
View File
@@ -249,33 +249,9 @@
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
<h3>危化品证书信息</h3>
<div class="mp-wv-cert-summary">
<span>{{ certSummaryText }}</span>
<span class="status-tag" :class="`status-tag--${certReadyTagType}`">{{
certReadyLabel
}}</span>
</div>
<article
v-for="product in hazardProducts"
:key="product.id"
class="plan-cert-product"
>
<h4>{{ product.standardProductName || product.productName }}</h4>
<div
v-for="slot in getRequiredCertSlots(product)"
:key="slot.key"
class="plan-cert-slot"
>
<p class="plan-subcard__label">{{ slot.label }}</p>
<AttachmentUploadField
v-if="getProductCertUrl(product, slot.key)"
:model-value="getProductCertUrl(product, slot.key)"
disabled
/>
<p v-else class="plan-empty-hint">未上传</p>
</div>
</article>
<p v-if="!hazardProducts.length" class="plan-empty-hint">本计划无易制毒/易制爆产品</p>
<p>购买证、运输证按合同产品与共享额度校验;合法使用证明可后补。</p>
<van-button block plain type="primary" @click="router.push(`/m/pl/plan-cert/${plan.id}`)">查看用证配置与占用</van-button>
<van-button block plain type="primary" @click="router.push('/m/pl/legal-use-document')">维护合法使用证明</van-button>
</section>
<section id="plan-sec-freight" class="mp-wv-section mp-h5-anchor">
@@ -459,12 +435,7 @@
getPlVehicleTypeLabel,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import {
isHazardCertReady,
isHazardProduct,
getRequiredCertSlots,
getProductCertUrl
} from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { planRequiresSiteLink } from '@/utils/pl-internal-customer'
import {
@@ -681,23 +652,6 @@
return qty ? `#${idx + 1} ${name} ${qty}` : `#${idx + 1} ${name}`
}
const hazardProducts = computed(() =>
(plan.value?.products || []).filter((p) => isHazardProduct(p))
)
const certReady = computed(() => isHazardCertReady(plan.value))
const certReadyLabel = computed(() => {
if (!hazardProducts.value.length) return '无需证书'
return certReady.value ? '已齐备' : '待确认'
})
const certReadyTagType = computed(() => {
if (!hazardProducts.value.length) return 'info'
return certReady.value ? 'success' : 'warning'
})
const certSummaryText = computed(() => {
if (!hazardProducts.value.length) return '本计划无易制毒/易制爆产品'
return `${hazardProducts.value.length} 个危化产品`
})
const feedbackLines = computed(() => {
const lines: { title: string; text: string }[] = []
for (const link of siteLinks.value) {
@@ -0,0 +1,190 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
<div class="filter-block"
><el-form label-position="top" @submit.prevent="load">
<div class="filter-grid"
><el-form-item label="计划编号"
><el-input v-model="keyword" placeholder="输入计划编号" clearable /></el-form-item
><el-form-item label="上传情况"
><el-select v-model="status" clearable
><el-option value="pending" label="待上传" /><el-option
value="uploaded"
label="已上传" /></el-select></el-form-item
></div>
<div class="filter-actions-row"
><span class="filter-hint">合法使用证明可在运输安排或归档后补传。</span
><div class="filter-actions-end"
><el-button @click="reset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
><el-button type="primary" :loading="loading" @click="load"
><el-icon><Search /></el-icon>搜索</el-button
></div
></div
>
</el-form></div
>
<div class="list-body list-wrap"
><el-table
v-loading="loading"
:data="rows"
row-key="id"
stripe
class="list-table"
@row-dblclick="open"
>
<el-table-column label="计划编号" min-width="180"
><template #default="{ row }"
><el-link type="primary" @click="open(row)">{{ row.planNo }}</el-link></template
></el-table-column
>
<el-table-column label="易制爆产品" min-width="220"
><template #default="{ row }">{{
row.products.map((p: any) => p.productName).join('、')
}}</template></el-table-column
>
<el-table-column label="待补明细" min-width="120"
><template #default="{ row }"
><el-tag
size="small"
:type="
row.products.some((p: any) => p.documentStatus === 'pending')
? 'warning'
: 'success'
"
>{{ row.products.filter((p: any) => p.documentStatus === 'pending').length }} /
{{ row.products.length }}</el-tag
></template
></el-table-column
>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ row }"
><el-button @click="open(row)"
><el-icon><Document /></el-icon>{{ canUpload ? '维护资料' : '查看资料' }}</el-button
></template
></el-table-column
>
</el-table></div
>
</section>
<el-dialog
v-model="visible"
:title="`合法使用证明 · ${detail?.planNo || ''}`"
width="900px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<section v-for="p in detail?.products || []" :key="p.id" class="view-section">
<h3>{{ p.productName }} {{ p.model || '' }}</h3>
<p v-for="context in p.contexts || []" :key="context.stopProductId"
>{{ context.stopType === 'load' ? '装货' : '送货' }}:{{
context.partyName || '未填写单位'
}}
· {{ context.address || '未填写地址' }} · 合同
{{ context.contractNo || '未关联或无查看权限' }}</p
>
<el-tag size="small" :type="p.legalUseCertUrl ? 'success' : 'warning'">{{
p.legalUseCertUrl ? '已上传' : '待上传'
}}</el-tag>
<AttachmentUploadField
:ref="(el) => setUpload(p.id, el)"
v-model="p.legalUseCertUrl"
folder="pl/legal-use-document"
:disabled="!canUpload || saving"
/>
</section>
<template #footer
><el-button :disabled="saving" @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button v-if="canUpload" type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存资料</el-button
></template
>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Document, RefreshLeft, Search } from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { legalDocumentApi, type LegalDocument } from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
const keyword = ref(''),
status = ref('')
const loading = ref(false),
saving = ref(false),
visible = ref(false),
canUpload = ref(false)
const rows = ref<LegalDocument[]>([]),
detail = ref<LegalDocument>()
async function load() {
loading.value = true
try {
rows.value = await legalDocumentApi.list({
keyword: keyword.value,
status: status.value || undefined
})
} finally {
loading.value = false
}
}
function reset() {
keyword.value = ''
status.value = ''
load()
}
const uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
function setUpload(id: string, value: any) {
if (value) uploads.set(id, value)
else uploads.delete(id)
}
let session = 0
watch(
visible,
(value) => {
if (!value) session++
},
{ flush: 'sync' }
)
async function open(row: LegalDocument) {
if (saving.value) return
const current = ++session
const result = await legalDocumentApi.get(row.id)
if (current !== session) return
detail.value = result
visible.value = true
}
async function save() {
if (!detail.value || !canUpload.value || saving.value) return
if ([...uploads.values()].some((upload) => upload.hasUploading())) {
ElMessage.warning('请等待附件上传完成')
return
}
const current = session,
targetId = detail.value.id
saving.value = true
try {
const result = await legalDocumentApi.save(
targetId,
detail.value.products.map((p) => ({ id: p.id, legalUseCertUrl: p.legalUseCertUrl }))
)
if (current !== session || detail.value?.id !== targetId) return
detail.value = result
ElMessage.success('合法使用证明已保存')
visible.value = false
await load()
} finally {
saving.value = false
}
}
onMounted(async () => {
const p = await fetchGetMenuCodes()
canUpload.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:plan-cert:upload')
await load()
})
</script>
File diff suppressed because it is too large. Load diff
+158
View File
@@ -0,0 +1,158 @@
<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 class="filter-block"
><el-form label-position="top" @submit.prevent="load"
><el-form-item label="计划编号"
><el-input v-model="keyword" clearable placeholder="输入计划编号" /></el-form-item
><el-form-item label="用证状态"
><el-select v-model="status" clearable
><el-option
v-for="(label, value) in stateLabels"
:key="value"
:label="label"
:value="value" /></el-select></el-form-item
><el-button type="primary" :loading="loading" @click="load"
><el-icon><Search /></el-icon>搜索</el-button
></el-form
></div
>
<p class="form-tip">按当前来源、合同产品范围和证件余额校验;实际安排时原子预占。</p>
<div v-if="mobile" v-loading="loading"
><section v-for="row in rows" :key="row.id" class="mp-wv-section"
><h3>{{ row.planNo }}</h3
><p>{{ row.licensePlate || '未安排车辆' }} · {{ row.planDate || '未定日期' }}</p
><el-tag
:type="
row.quotaStatus === 'exception'
? 'danger'
: ['reserved', 'settled'].includes(row.quotaStatus)
? 'success'
: 'info'
"
>{{ stateLabels[row.quotaStatus] }}</el-tag
><el-button @click="open(row.id)"
><el-icon><Document /></el-icon>用证配置与占用</el-button
></section
></div
>
<div v-else class="list-body list-wrap"
><el-table v-loading="loading" :data="rows" stripe class="list-table"
><el-table-column label="计划编号" min-width="190"
><template #default="{ row }"
><el-link type="primary" @click="open(row.id)">{{ row.planNo }}</el-link></template
></el-table-column
><el-table-column prop="planDate" label="计划日期" min-width="140" /><el-table-column
prop="licensePlate"
label="车号"
min-width="160"
/><el-table-column label="用证校验" min-width="160"
><template #default="{ row }"
><el-tag
:type="
row.quotaStatus === 'exception'
? 'danger'
: row.quotaStatus === 'reserved' || row.quotaStatus === 'settled'
? 'success'
: 'info'
"
>{{ stateLabels[row.quotaStatus] }}</el-tag
></template
></el-table-column
><el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ row }"
><el-button @click="open(row.id)"
><el-icon><Document /></el-icon>用证配置与占用</el-button
></template
></el-table-column
></el-table
></div
>
<div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="prev, pager, next"
@current-change="load"
/></div>
</section>
<PlPlanQuotaDialog ref="dialog" />
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { Document, Search } from '@element-plus/icons-vue'
import { type Plan } from '@/api/pl'
import request from '@/utils/http/bx'
const stateLabels: Record<string, string> = {
not_required: '无需两证',
needs_configuration: '待配置',
needs_validation: '待校验条件与余额',
reserved: '已持有预占',
settled: '已核销',
exception: '结转异常'
}
const status = ref('')
import { pickBxData } from '@/utils/http/bx'
import PlPlanQuotaDialog from '@/components/business/pl-plan-quota-dialog/index.vue'
const props = defineProps<{ mobile?: boolean; planId?: string }>()
const keyword = ref(''),
page = ref(1),
total = ref(0),
loading = ref(false),
rows = ref<(Plan & { quotaStatus: string })[]>([])
const dialog = ref<InstanceType<typeof PlPlanQuotaDialog>>()
let epoch = 0
async function load() {
const current = ++epoch
loading.value = true
try {
const value = pickBxData<{ list: (Plan & { quotaStatus: string })[]; total: number }>(
await request.get('/transport/quota-certs/plans', {
params: {
page: page.value,
pageSize: 20,
keyword: keyword.value,
status: status.value || undefined
}
})
)
if (current !== epoch) return
rows.value = value?.list || []
total.value = value?.total || 0
} finally {
if (current === epoch) loading.value = false
}
}
function open(id: string) {
const current = epoch
void dialog.value?.open(id).then(() => {
if (current === epoch) return load()
})
}
const user = useUserStore()
watch(
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
epoch++
rows.value = []
total.value = 0
page.value = 1
loading.value = false
dialog.value?.invalidateSession()
if (user.accessToken) void load()
},
{ flush: 'sync' }
)
onMounted(async () => {
const pending = load(),
current = epoch
await pending
if (current === epoch && props.planId) open(props.planId)
})
onBeforeUnmount(() => {
epoch++
})
</script>
@@ -0,0 +1,321 @@
<template>
<el-dialog
v-model="visible"
title="合同适用证件"
width="1040px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
>
<div v-loading="loading">
<el-alert
title="关联只定义适用范围。多个合同产品、车辆共用证件余额,正式安排时校验并预占。"
type="info"
:closable="false"
/>
<section class="view-section">
<h3>1. 选择山峡合同</h3>
<el-select
v-model="contractId"
filterable
remote
:remote-method="searchContracts"
:loading="searching"
:disabled="saving"
placeholder="输入合同编号或名称"
@change="loadContract"
>
<el-option
v-for="c in contracts"
:key="c.id"
:value="c.id"
:label="`${c.contractNo} · ${c.contractName}`"
/>
</el-select>
</section>
<template v-if="contractId && loadedContractId === contractId">
<section class="view-section">
<h3>2. 勾选合同产品行</h3>
<el-checkbox-group v-model="selectedProducts" :disabled="!canEdit || saving">
<el-checkbox v-for="p in products" :key="p.id" :value="p.id">{{
productLabel(p)
}}</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!products.length" description="该合同暂无有效产品明细" :image-size="64" />
</section>
<section class="view-section">
<h3>3. 分别选择证件及各自适用产品</h3>
<div class="form-grid-2">
<div v-for="kind in kinds" :key="kind.value">
<el-form label-position="top">
<el-form-item :label="kind.label">
<el-select
:model-value="
bindings.filter((b) => b.certType === kind.value).map((b) => b.certId)
"
multiple
filterable
:disabled="!canEdit || saving"
placeholder="可选择多张证件"
@update:model-value="(ids) => setCertificates(kind.value, ids)"
>
<el-option
v-for="c in certificates[kind.value]"
:key="c.id"
:value="c.id"
:label="`${c.certNo} · 可用 ${c.availableQty} ${c.quantityUnit}`"
:disabled="
c.status === 'void' &&
!bindings.some((b) => b.certType === kind.value && b.certId === c.id)
"
/>
</el-select>
</el-form-item>
</el-form>
<div
v-for="b in bindings.filter((b) => b.certType === kind.value)"
:key="b.certId"
class="view-card"
>
<h4>{{ certLabel(b) }}</h4>
<el-tag size="small" :type="b.linkStatus === 'inactive' ? 'info' : 'success'">{{
b.linkStatus === 'inactive' ? '已停用' : '可用范围'
}}</el-tag>
<el-checkbox-group
:model-value="b.products.map((p) => p.contractProductId)"
:disabled="!canEdit || saving"
@update:model-value="(ids) => setProducts(b, ids)"
>
<el-checkbox v-for="p in availableProducts(b)" :key="p.id" :value="p.id">{{
productLabel(p)
}}</el-checkbox>
</el-checkbox-group>
<div v-for="p in b.products" :key="p.contractProductId">
<el-switch
v-model="p.scopeStatus"
active-value="active"
inactive-value="inactive"
:active-text="`${products.find((row) => row.id === p.contractProductId)?.productName || p.contractProductId}:产品范围启用`"
inactive-text="产品范围停用"
:disabled="!canEdit || saving"
/>
</div>
<el-switch
v-model="b.linkStatus"
active-value="active"
inactive-value="inactive"
active-text="允许新增使用"
:disabled="!canEdit || saving"
/>
<p v-if="kind.value === 'transport'" class="form-tip">{{
transportDetails(b.certId)
}}</p>
</div>
</div>
</div>
</section>
<section class="view-section">
<h3>4. 核对产品与证件关联</h3>
<el-table
:data="
products.filter((p) =>
bindings.some((b) => b.products.some((s) => s.contractProductId === p.id))
)
"
stripe
class="list-table"
row-key="id"
>
<el-table-column label="合同产品行" min-width="200"
><template #default="{ row }">{{ productLabel(row) }}</template></el-table-column
>
<el-table-column
v-for="kind in kinds"
:key="kind.value"
:label="kind.label"
min-width="220"
>
<template #default="{ row }"
><el-tag
v-for="b in bindings.filter(
(b) =>
b.certType === kind.value &&
b.products.some((p) => p.contractProductId === row.id)
)"
:key="b.certId"
size="small"
:type="bindingProductEnabled(b, row.id) ? 'success' : 'info'"
>{{ certLabel(b)
}}{{ bindingProductEnabled(b, row.id) ? '' : '(停用)' }}</el-tag
></template
>
</el-table-column>
</el-table>
</section>
</template>
</div>
<template #footer>
<el-button @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
>
<el-button
v-if="canEdit"
type="primary"
:loading="saving"
:disabled="!contractId || loadedContractId !== contractId || loading"
@click="save"
><el-icon><Check /></el-icon>保存关联</el-button
>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import {
quotaCertApi,
type CertType,
type CertBinding,
type QuotaCert,
type ContractOption,
type ContractProduct
} from '@/api/pl/quota-cert'
import { bindingPayload, bindingProductEnabled } from './model'
const props = defineProps<{ canEdit: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const visible = ref(false)
const loading = ref(false)
const searching = ref(false)
const saving = ref(false)
const contractId = ref('')
const loadedContractId = ref('')
const contracts = ref<ContractOption[]>([])
const products = ref<ContractProduct[]>([])
const selectedProducts = ref<string[]>([])
const bindings = ref<CertBinding[]>([])
const original = ref<CertBinding[]>([])
const certificates = reactive<Record<CertType, QuotaCert[]>>({ purchase: [], transport: [] })
const kinds: { value: CertType; label: string }[] = [
{ value: 'purchase', label: '购买证' },
{ value: 'transport', label: '运输证' }
]
const productLabel = (p: ContractProduct) =>
`${p.productName}${p.model ? ' / ' + p.model : ''} · ${p.quantity ?? '—'} ${p.unit || ''} · ${p.id.slice(-6)}`
const certLabel = (b: CertBinding) =>
certificates[b.certType].find((c) => c.id === b.certId)?.certNo || b.certId
const availableProducts = (b: CertBinding) =>
products.value.filter(
(p) =>
selectedProducts.value.includes(p.id) ||
b.products.some((s) => s.contractProductId === p.id)
)
function transportDetails(id: string) {
const c = certificates.transport.find((c) => c.id === id)
return `车号:${c?.vehicleNumbersJson?.join('、') || '未填写'};引用:${c?.purchaseRefs?.map((r) => r.referenceNo).join('、') || '未填写'}`
}
async function searchContracts(keyword = '') {
searching.value = true
try {
contracts.value = await quotaCertApi.contracts(keyword)
} finally {
searching.value = false
}
}
async function open(id = '') {
if (saving.value) return
visible.value = true
contractId.value = id
loadedContractId.value = ''
bindings.value = []
original.value = []
products.value = []
loading.value = true
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase'),
quotaCertApi.list('transport'),
searchContracts()
])
certificates.purchase = purchase
certificates.transport = transport
if (id) await loadContract()
} finally {
loading.value = false
}
}
let loadVersion = 0
async function loadContract() {
const version = ++loadVersion
const id = contractId.value
loadedContractId.value = ''
loading.value = true
try {
const [rows, scope] = await Promise.all([
quotaCertApi.products(id),
quotaCertApi.bindings(id)
])
if (version !== loadVersion) return
products.value = rows
bindings.value = bindingPayload(scope)
original.value = JSON.parse(JSON.stringify(bindings.value))
selectedProducts.value = [
...new Set(scope.flatMap((b) => b.products.map((p) => p.contractProductId)))
]
loadedContractId.value = id
} finally {
if (version === loadVersion) loading.value = false
}
}
function setCertificates(kind: CertType, ids: string[]) {
bindings.value = [
...bindings.value.filter((b) => b.certType !== kind),
...ids.map((id) => {
const old =
bindings.value.find((b) => b.certType === kind && b.certId === id) ||
original.value.find((b) => b.certType === kind && b.certId === id)
return (
old || {
certType: kind,
certId: id,
linkStatus: 'active' as const,
products: selectedProducts.value.map((p) => ({
contractProductId: p,
scopeStatus: 'active' as const
}))
}
)
})
]
}
function setProducts(binding: CertBinding, values: (string | number)[]) {
const ids = values.map(String)
const old = original.value.find(
(b) => b.certType === binding.certType && b.certId === binding.certId
)
binding.products = ids.map(
(id) =>
binding.products.find((p) => p.contractProductId === id) ||
old?.products.find((p) => p.contractProductId === id) || {
contractProductId: id,
scopeStatus: 'active'
}
)
}
async function save() {
if (!props.canEdit) return
const targetContract = contractId.value
const version = loadVersion
saving.value = true
try {
const result = await quotaCertApi.saveBindings(targetContract, bindingPayload(bindings.value))
if (targetContract !== contractId.value || version !== loadVersion) return
bindings.value = bindingPayload(result)
original.value = JSON.parse(JSON.stringify(bindings.value))
ElMessage.success('合同及产品关联已保存')
emit('saved')
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
@@ -0,0 +1,149 @@
<template>
<el-dialog
v-model="visible"
:title="`额度流水 · ${cert?.certNo || ''}`"
width="980px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<template v-if="cert">
<p
>核定 {{ cert.totalQty }} {{ cert.quantityUnit }};预占 {{ cert.reservedQty }};已使用
{{ cert.usedQty }}</p
>
<el-table :data="logs" stripe
><el-table-column prop="createTime" label="时间" min-width="170" /><el-table-column
prop="eventType"
label="业务事件"
min-width="140"
><template #default="{ row }"
><el-tag type="info" size="small">{{
eventLabels[row.eventType] || row.eventType
}}</el-tag></template
></el-table-column
><el-table-column prop="totalDelta" label="总量变化" min-width="120" /><el-table-column
prop="reservedDelta"
label="预占变化"
min-width="120" /><el-table-column
prop="usedDelta"
label="已用变化"
min-width="120" /><el-table-column prop="reason" label="原因" min-width="220"
/></el-table>
<section v-if="canEdit" class="view-section"
><h3>更正证面核定总量</h3
><p class="form-tip"
>新总量不得低于已用与预占之和。更正会留下流水并更新证面版本,后续用证需重新核对。</p
>
<el-form label-position="top" :disabled="saving"
><div class="form-grid-2"
><el-form-item :label="`更正后总量(${cert.quantityUnit})`"
><el-input v-model="body.totalQty" /></el-form-item
><el-form-item label="更正理由"><el-input v-model="body.reason" /></el-form-item
><el-form-item label="依据说明(与凭据至少一项)" class="form-col-span-2"
><el-input v-model="body.sourceNote" type="textarea" /></el-form-item
><el-form-item label="更正凭据" class="form-col-span-2"
><AttachmentUploadField
ref="upload"
:key="epoch"
v-model="body.evidenceUrls"
:disabled="saving"
folder="pl/quota-adjustment" /></el-form-item></div
></el-form>
</section>
</template>
<template #footer
><el-button :disabled="saving" @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button v-if="canEdit" type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存总量更正</el-button
></template
>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import {
quotaAdjustmentApi,
quotaCertApi,
type QuotaCert,
type CertType
} from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
defineProps<{ canEdit: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const visible = ref(false),
saving = ref(false),
cert = ref<QuotaCert>(),
logs = ref<Record<string, any>[]>([]),
kind = ref<CertType>('purchase')
const upload = ref<InstanceType<typeof AttachmentUploadField>>()
const eventLabels: Record<string, string> = {
opening: '确认期初',
reserve: '安排预占',
resize: '调整预占',
release: '释放预占',
settle: '实际核销',
adjust: '实际量更正',
total_adjust: '核定总量更正',
verify: '信息复核'
}
const body = reactive({ totalQty: '', reason: '', sourceNote: '', evidenceUrls: '' })
let epoch = 0,
attempt = { signature: '', id: '' }
watch(
visible,
(v) => {
if (!v) epoch++
},
{ flush: 'sync' }
)
async function open(type: CertType, row: QuotaCert) {
if (saving.value) return
const current = ++epoch
const values = await quotaAdjustmentApi.logs(type, row.id)
if (current !== epoch) return
kind.value = type
cert.value = row
logs.value = values
Object.assign(body, {
totalQty: String(row.totalQty),
reason: '',
sourceNote: '',
evidenceUrls: ''
})
visible.value = true
}
async function save() {
if (!cert.value || saving.value) return
if (
!/^\d{1,12}(\.\d{1,6})?$/.test(body.totalQty) ||
!body.reason.trim() ||
(!body.sourceNote.trim() && !body.evidenceUrls)
) {
ElMessage.warning('请填写有效总量、更正理由与依据')
return
}
if (upload.value?.hasUploading()) {
ElMessage.warning('请等待凭据上传完成')
return
}
const id = cert.value.id,
type = kind.value,
signature = JSON.stringify({ id, type, ...body })
if (signature !== attempt.signature) attempt = { signature, id: crypto.randomUUID() }
saving.value = true
try {
await quotaAdjustmentApi.total(type, id, { ...body, operationId: attempt.id })
cert.value = await quotaCertApi.get(type, id)
logs.value = await quotaAdjustmentApi.logs(type, id)
ElMessage.success('总量更正已保存')
emit('saved')
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
+621
View File
@@ -0,0 +1,621 @@
<template>
<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="filter-block">
<el-form label-position="top" @submit.prevent="load">
<div class="filter-grid">
<el-form-item label="证件种类"
><el-select v-model="kind" @change="load"
><el-option label="购买证" value="purchase" /><el-option
label="运输证"
value="transport" /></el-select
></el-form-item>
<el-form-item label="证件编号"
><el-input v-model="keyword" clearable placeholder="输入证件编号"
/></el-form-item>
<el-form-item label="状态"
><el-select v-model="status" clearable
><el-option
v-for="(label, value) in statusLabels"
:key="value"
:label="label"
:value="value" /></el-select
></el-form-item>
</div>
<div class="filter-actions-row"
><span class="filter-hint">先建档核对,再关联合同及产品;同一证件共用一个额度池。</span
><div class="filter-actions-end"
><el-button @click="reset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
><el-button type="primary" :loading="loading" @click="load"
><el-icon><Search /></el-icon>搜索</el-button
></div
></div
>
</el-form>
</div>
<div class="list-toolbar"
><div class="list-toolbar-row">
<span class="list-toolbar-selected">共 {{ rows.length }} 张</span>
<el-button v-if="canEdit" type="primary" @click="edit()"
><el-icon><Plus /></el-icon>新增证件</el-button
>
<el-button @click="scopeDialog?.open()"
><el-icon><Connection /></el-icon>关联合同产品</el-button
>
</div></div
>
<div class="list-body list-wrap">
<el-table
v-loading="loading"
:data="pageRows"
stripe
row-key="id"
class="list-table"
@row-dblclick="view"
>
<el-table-column label="证件编号" min-width="190"
><template #default="{ row }"
><el-link type="primary" @click="view(row)">{{ row.certNo }}</el-link></template
></el-table-column
>
<el-table-column prop="productNameOnCert" label="证面品名" min-width="150" />
<el-table-column label="状态" min-width="100"
><template #default="{ row }"
><el-tag size="small" :type="tagType(row.status)">{{
statusLabels[row.status as keyof typeof statusLabels]
}}</el-tag></template
></el-table-column
>
<el-table-column
v-for="column in qtyColumns"
:key="column.key"
:label="column.label"
min-width="130"
><template #default="{ row }"
>{{ row[column.key] }} {{ row.quantityUnit }}</template
></el-table-column
>
<el-table-column prop="validTo" label="有效期至" min-width="130" />
<el-table-column label="操作" min-width="320" fixed="right"
><template #default="{ row }">
<el-button v-if="canEdit" size="small" @click="edit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button
v-if="canEdit && row.status !== 'void'"
size="small"
@click="openConfirm(row)"
><el-icon><Check /></el-icon
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
>
<el-button size="small" @click="showContracts(row)"
><el-icon><Connection /></el-icon>关联合同</el-button
>
</template></el-table-column
>
</el-table>
<div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="page"
:page-size="20"
:total="rows.length"
layout="total, prev, pager, next"
/></div>
</div>
</section>
<el-dialog
v-model="formVisible"
:title="`${editingId ? '编辑' : '新增'}${kind === 'purchase' ? '购买证' : '运输证'}`"
width="960px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<el-form
:disabled="saving"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
>
<section class="view-section">
<el-form-item label="建档方式"
><el-radio-group v-model="form.entryMode" :disabled="!!editingId"
><el-radio value="manual">手工录入(可后补图片)</el-radio
><el-radio value="image_parse">图片解析后核对</el-radio></el-radio-group
></el-form-item
>
<el-form-item label="证件附件"
><AttachmentUploadField
ref="certAttachments"
:key="editSession"
:disabled="saving"
v-model="form.attachmentUrls"
folder="pl/quota-cert"
accept="image/*,.pdf"
@update:model-value="edited.add('attachmentUrls')"
/></el-form-item>
<template v-if="form.entryMode === 'image_parse' || !!form.attachmentUrls">
<el-select v-model="recognitionImage" placeholder="选择一张已上传的证件图片"
><el-option
v-for="url in (form.attachmentUrls || '').split(',').filter(Boolean)"
:key="url"
:value="url"
:label="url.split('/').pop()"
/></el-select>
<el-button :loading="recognizing" :disabled="!recognitionImage" @click="recognize"
><el-icon><Document /></el-icon>解析为候选字段</el-button
>
<p class="form-tip"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
</template>
</section>
<section class="view-section"
><h3>证面信息</h3
><div class="form-grid-2">
<el-form-item
v-for="field in fields"
:key="field[0]"
:label="field[1]"
:prop="field[0]"
>
<el-date-picker
v-if="isDate(field[0])"
v-model="form[field[0]]"
type="date"
value-format="YYYY-MM-DD"
@change="edited.add(field[0])"
/>
<el-input
v-else
v-model="form[field[0]]"
:placeholder="
field[0] === 'validUseText' ? '例如:一次有效、多次有效' : `请输入${field[1]}`
"
@input="edited.add(field[0])"
/>
</el-form-item> </div
></section>
<section v-if="kind === 'transport'" class="view-section">
<el-form-item label="车牌号(多车共用额度)"
><el-select
v-model="form.vehicleNumbersJson"
multiple
filterable
allow-create
default-first-option
@change="edited.add('vehicleNumbersJson')"
/></el-form-item>
<h3>证面引用</h3>
<el-table :data="form.purchaseRefs" row-key="id" stripe>
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-select v-model="row.referenceType" @change="edited.add('purchaseRefs')"
><el-option
v-for="(label, value) in refLabels"
:key="value"
:value="value"
:label="label" /></el-select></template
></el-table-column>
<el-table-column label="引用证号" min-width="160"
><template #default="{ row }"
><el-input
v-model="row.referenceNo"
@input="edited.add('purchaseRefs')" /></template
></el-table-column>
<el-table-column label="对应购买证档案" min-width="200"
><template #default="{ row }"
><el-select
v-model="row.purchaseCertId"
filterable
clearable
@change="edited.add('purchaseRefs')"
><el-option
v-for="c in purchases"
:key="c.id"
:value="c.id"
:label="c.certNo" /></el-select></template
></el-table-column>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ $index }"
><el-button @click="removeReference($index)"
><el-icon><Delete /></el-icon>移除引用</el-button
></template
></el-table-column
>
</el-table>
<el-button @click="addReference"
><el-icon><Plus /></el-icon>添加引用</el-button
>
</section>
<el-form-item v-if="editingId" label="停止新增使用">
<el-select v-model="form.status" clearable placeholder="保持当前状态">
<el-option label="停用(可复核恢复)" value="suspended" />
<el-option label="作废(不可恢复)" value="void" />
</el-select>
</el-form-item>
<el-form-item label="系统备注"
><el-input v-model="form.remark" type="textarea"
/></el-form-item>
</el-form>
<template #footer
><el-button :disabled="saving" @click="formVisible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存草稿/修改</el-button
></template
>
</el-dialog>
<el-dialog
v-model="detailVisible"
title="证件详情"
width="920px"
align-center
class="dialog-form-layout"
>
<template v-if="detail">
<el-button @click="historyDialog?.open(kind, detail)"
><el-icon><Document /></el-icon>额度流水与总量更正</el-button
>
<div class="view-hero"
><h2>{{ detail.certNo }}</h2
><el-tag :type="tagType(detail.status)">{{ statusLabels[detail.status] }}</el-tag></div
>
<section class="view-section"
><div class="view-kv-grid"
><dl v-for="field in fields" :key="field[0]" class="view-kv"
><dt>{{ field[1] }}</dt
><dd>{{ detail[field[0] as keyof QuotaCert] ?? '—' }}</dd></dl
></div
></section
>
<section class="view-section"
><h3>共享额度</h3
><p v-for="column in qtyColumns" :key="column.key"
>{{ column.label }}:{{ detail[column.key as keyof QuotaCert] }}
{{ detail.quantityUnit }}</p
><AttachmentUploadField :model-value="detail.attachmentUrls || ''" disabled
/></section>
<section v-if="kind === 'transport'" class="view-section">
<h3>车号及证面引用</h3>
<p>车牌号:{{ detail.vehicleNumbersJson?.join('、') || '未填写' }}</p>
<el-table :data="detail.purchaseRefs || []" stripe row-key="id">
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-tag size="small" type="info">{{
refLabels[row.referenceType as keyof typeof refLabels]
}}</el-tag></template
></el-table-column
>
<el-table-column prop="referenceNo" label="证面引用号" min-width="180" />
<el-table-column label="对应购买证档案" min-width="180"
><template #default="{ row }">{{
purchases.find((p) => p.id === row.purchaseCertId)?.certNo ||
(row.purchaseCertId ? '无查看权限或已失效' : '未关联')
}}</template></el-table-column
>
</el-table>
</section>
<section class="view-section"
><h3>关联的山峡合同</h3
><el-empty v-if="!linkedContracts.length" description="尚未关联合同" :image-size="64" /><p
v-for="c in linkedContracts"
:key="c.id"
><el-link type="primary" @click="scopeDialog?.open(c.id)"
>{{ c.contractNo }} · {{ c.contractName }} — 查看适用产品</el-link
></p
><el-button v-if="canEdit" @click="scopeDialog?.open()"
><el-icon><Connection /></el-icon>关联另一合同</el-button
></section
>
</template>
<template #footer
><el-button @click="detailVisible = false"
><el-icon><Close /></el-icon>关闭</el-button
></template
>
</el-dialog>
<el-dialog
v-model="confirmVisible"
:title="confirmRow?.openingConfirmed ? '复核证件' : '期初核对'"
width="640px"
align-center
class="dialog-form-layout"
>
<el-form label-position="top">
<template v-if="!confirmRow?.openingConfirmed"
><el-form-item label="历史已用量(证面单位)"
><el-input v-model="opening.usedQty" /></el-form-item
><el-form-item label="一次有效证是否已有使用记录"
><el-switch v-model="opening.singleUseConsumed" /></el-form-item
><el-form-item label="历史来源说明"
><el-input v-model="opening.sourceNote" type="textarea" /></el-form-item
><el-form-item label="核对凭据"
><AttachmentUploadField
v-model="opening.evidenceUrls"
folder="pl/quota-cert" /></el-form-item
></template>
<el-form-item label="核对说明(必填)"
><el-input v-model="opening.reason" type="textarea"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="confirmVisible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :loading="saving" @click="confirm"
><el-icon><Check /></el-icon>确认启用</el-button
></template
>
</el-dialog>
<ContractScopeDialog ref="scopeDialog" :can-edit="canEdit" @saved="refreshDetails" />
</div>
</template>
<script setup lang="ts">
import QuotaHistoryDialog from './QuotaHistoryDialog.vue'
const historyDialog = ref<InstanceType<typeof QuotaHistoryDialog>>()
import {
Check,
Close,
Connection,
Delete,
Document,
Edit,
Plus,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import {
quotaCertApi,
type CertType,
type QuotaCert,
type ContractOption
} from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ContractScopeDialog from './ContractScopeDialog.vue'
import {
commonFields,
purchaseFields,
transportFields,
newCert,
certPayload,
mergeCandidates
} from './model'
const route = useRoute()
const kind = ref<CertType>('purchase')
const keyword = ref(''),
status = ref(''),
page = ref(1)
const rows = ref<QuotaCert[]>([]),
purchases = ref<QuotaCert[]>([])
const loading = ref(false),
saving = ref(false),
recognizing = ref(false),
canEdit = ref(false)
const formVisible = ref(false),
detailVisible = ref(false),
confirmVisible = ref(false)
const form = ref<Record<string, any>>(newCert('purchase'))
const formRef = ref(),
editingId = ref(''),
recognitionImage = ref('')
const certAttachments = ref<InstanceType<typeof AttachmentUploadField>>()
const edited = reactive(new Set<string>())
const detail = ref<QuotaCert>(),
linkedContracts = ref<ContractOption[]>([]),
confirmRow = ref<QuotaCert>()
const scopeDialog = ref<InstanceType<typeof ContractScopeDialog>>()
const opening = reactive({
operationId: '',
usedQty: '0',
singleUseConsumed: false,
reason: '',
sourceNote: '',
evidenceUrls: ''
})
const fields = computed(() => [
...commonFields,
...(kind.value === 'purchase' ? purchaseFields : transportFields)
])
const pageRows = computed(() => rows.value.slice((page.value - 1) * 20, page.value * 20))
const statusLabels = { draft: '待核对', active: '已启用', suspended: '已停用', void: '已作废' }
const refLabels = {
purchase_permit: '购买许可证',
purchase_filing: '购买备案证明',
export_permit: '出口许可证',
unknown: '待确认'
}
const qtyColumns = [
{ key: 'totalQty', label: '核定量' },
{ key: 'reservedQty', label: '已预占' },
{ key: 'usedQty', label: '已使用' },
{ key: 'availableQty', label: '可用余额' }
]
const tagType = (s: string) =>
s === 'active' ? 'success' : s === 'void' ? 'danger' : s === 'draft' ? 'warning' : 'info'
const isDate = (key: string) => ['issueDate', 'validFrom', 'validTo'].includes(key)
const rules = computed(() =>
Object.fromEntries(
[
'certNo',
'productNameOnCert',
'totalQty',
'quantityUnit',
'validUseText',
...(kind.value === 'purchase' ? ['buyerName', 'sellerName'] : ['shipperName'])
].map((k) => [k, [{ required: true, message: '请填写此项', trigger: 'blur' }]])
)
)
async function load() {
loading.value = true
try {
rows.value = await quotaCertApi.list(kind.value, {
keyword: keyword.value,
status: status.value || undefined
})
page.value = 1
} finally {
loading.value = false
}
}
function reset() {
keyword.value = ''
status.value = ''
load()
}
let editSession = 0
watch(
formVisible,
(visible) => {
if (!visible) {
editSession++
recognizing.value = false
}
},
{ flush: 'sync' }
)
watch(kind, () => {
editSession++
formVisible.value = false
})
async function edit(row?: QuotaCert) {
if (saving.value) return
const session = ++editSession
const targetKind = kind.value
const value = row
? certPayload(targetKind, await quotaCertApi.get(targetKind, row.id))
: newCert(targetKind)
const options = targetKind === 'transport' ? await quotaCertApi.list('purchase') : []
if (session !== editSession || kind.value !== targetKind) return
editingId.value = row?.id || ''
edited.clear()
recognitionImage.value = ''
form.value = value
purchases.value = options
recognizing.value = false
formVisible.value = true
}
async function save() {
if (saving.value) return
if (certAttachments.value?.hasUploading()) {
ElMessage.warning('请等待附件上传完成')
return
}
const session = editSession
saving.value = true
if (!(await formRef.value.validate().catch(() => false)) || session !== editSession) {
saving.value = false
return
}
try {
await quotaCertApi.save(
kind.value,
certPayload(kind.value, form.value),
editingId.value || undefined
)
formVisible.value = false
ElMessage.success('证件已保存')
await load()
} finally {
saving.value = false
}
}
async function recognize() {
if (recognizing.value || saving.value) return
const session = editSession,
targetKind = kind.value,
targetImage = recognitionImage.value
recognizing.value = true
try {
const result = await quotaCertApi.recognize(targetKind, targetImage)
if (
session !== editSession ||
!formVisible.value ||
kind.value !== targetKind ||
recognitionImage.value !== targetImage ||
!(form.value.attachmentUrls || '').split(',').includes(targetImage)
)
return
mergeCandidates(form.value as any, result.candidates, edited)
ElMessage.success(
Object.keys(result.invalidFields).length
? '部分字段未识别,请手工核对补全'
: '候选已填入,请核对后保存'
)
} finally {
if (session === editSession) recognizing.value = false
}
}
function addReference() {
form.value.purchaseRefs.push({
referenceType: 'unknown',
referenceNo: '',
purchaseCertId: null
})
edited.add('purchaseRefs')
}
function removeReference(index: number) {
form.value.purchaseRefs.splice(index, 1)
edited.add('purchaseRefs')
}
async function view(row: QuotaCert) {
if (kind.value === 'transport') purchases.value = await quotaCertApi.list('purchase')
const [cert, links] = await Promise.all([
quotaCertApi.get(kind.value, row.id),
quotaCertApi.certificateContracts(kind.value, row.id)
])
detail.value = cert
linkedContracts.value = links
detailVisible.value = true
}
const showContracts = view
async function refreshDetails() {
if (detail.value && detailVisible.value) await view(detail.value)
}
function openConfirm(row: QuotaCert) {
confirmRow.value = row
Object.assign(opening, {
operationId: crypto.randomUUID(),
usedQty: '0',
singleUseConsumed: false,
reason: '',
sourceNote: '',
evidenceUrls: ''
})
confirmVisible.value = true
}
async function confirm() {
if (!confirmRow.value || !opening.reason.trim()) {
ElMessage.warning('请填写核对说明')
return
}
saving.value = true
try {
if (confirmRow.value.openingConfirmed)
await quotaCertApi.verify(kind.value, confirmRow.value.id, {
operationId: opening.operationId,
reason: opening.reason
})
else await quotaCertApi.confirm(kind.value, confirmRow.value.id, opening)
confirmVisible.value = false
await load()
} finally {
saving.value = false
}
}
onMounted(async () => {
const p = await fetchGetMenuCodes()
canEdit.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit')
await load()
if (typeof route.query.contractId === 'string') scopeDialog.value?.open(route.query.contractId)
})
</script>
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
bindingPayload,
mergeCandidates,
newCert,
certPayload,
bindingProductEnabled
} from './model'
test('绑定保存保留主子ID及明确适用产品,剥离只读字段', () => {
assert.deepEqual(
bindingPayload([
{
id: 'l',
certType: 'purchase',
certId: 'g',
scopeRevision: 2,
products: [{ id: 's', contractProductId: 'p', scopeStatus: 'inactive' }]
}
]),
[
{
id: 'l',
certType: 'purchase',
certId: 'g',
linkStatus: 'active',
products: [{ id: 's', contractProductId: 'p', scopeStatus: 'inactive' }]
}
]
)
})
test('候选不覆盖手填、清空、零数值,新增只填未修改的空字段', () => {
const form = newCert('purchase')
form.totalQty = '0'
form.buyerName = ''
form.sellerName = '手填'
mergeCandidates(
form,
{ buyerName: '候选买方', sellerName: '候选卖方', totalQty: 30, certNo: 'G1' },
new Set(['buyerName'])
)
assert.equal(form.buyerName, '')
assert.equal(form.sellerName, '手填')
assert.equal(form.totalQty, '0')
assert.equal(form.certNo, 'G1')
})
test('编辑剥离余额和状态active,保留手工证和运输引用ID', () => {
const form = {
...newCert('transport'),
id: 'g',
status: 'active',
reservedQty: 20,
purchaseRefs: [
{ id: 'r', referenceNo: 'G1', referenceType: 'purchase_permit', purchaseCertId: 'g1' }
]
}
const payload = certPayload('transport', form)
assert.equal('id' in payload, false)
assert.equal('status' in payload, false)
assert.equal('reservedQty' in payload, false)
assert.equal(payload.purchaseRefs?.[0].id, 'r')
})
test('停用产品范围和停用主关联均不展示为可用', () => {
const binding = {
certType: 'purchase' as const,
certId: 'g',
linkStatus: 'active' as const,
products: [{ id: 's', contractProductId: 'p', scopeStatus: 'inactive' as const }]
}
assert.equal(bindingProductEnabled(binding, 'p'), false)
assert.equal(
bindingProductEnabled(
{ ...binding, products: [{ contractProductId: 'p', scopeStatus: 'active' }] },
'p'
),
true
)
assert.equal(bindingProductEnabled({ ...binding, linkStatus: 'inactive' }, 'p'), false)
})
+113
View File
@@ -0,0 +1,113 @@
import type { CertBinding, CertBody, CertType } from '@/api/pl/quota-cert'
export const commonFields = [
['certNo', '证件编号'],
['issueDate', '发证日期', 'date'],
['productNameOnCert', '证面品名'],
['totalQty', '核定数量'],
['quantityUnit', '数量单位'],
['validFrom', '有效期起', 'date'],
['validTo', '有效期止', 'date'],
['validUseText', '有效次数原文']
] as const
export const purchaseFields = [
['buyerName', '购买单位'],
['buyerAddress', '购买单位地址'],
['buyerLegalRepresentative', '购买方法定代表人'],
['buyerPhone', '购买方电话'],
['sellerName', '销售单位'],
['sellerAddress', '销售单位地址'],
['sellerLegalRepresentative', '销售方法定代表人'],
['sellerPhone', '销售方电话'],
['purchasePurpose', '购买用途']
] as const
export const transportFields = [
['shipperName', '托运单位'],
['shipperAddress', '托运单位地址'],
['shipperLegalRepresentative', '托运方法定代表人'],
['shipperPhone', '托运方电话'],
['packagingText', '包装原文'],
['carrierName', '承运单位'],
['carrierAddressPhone', '承运地址及电话'],
['transportMode', '运输方式'],
['routeText', '运输路线原文'],
['consigneeName', '收货单位'],
['applicantName', '申请单位']
] as const
export function newCert(kind: CertType): CertBody {
const body: Record<string, unknown> = {
entryMode: 'manual',
attachmentUrls: '',
remark: '',
orderNum: null
}
for (const field of [
...commonFields,
...(kind === 'purchase' ? purchaseFields : transportFields)
]) {
body[field[0]] =
field[0].endsWith('Date') || field[0].startsWith('validF') || field[0] === 'validTo'
? null
: ''
}
if (kind === 'transport') Object.assign(body, { vehicleNumbersJson: [], purchaseRefs: [] })
return body as unknown as CertBody
}
export function certPayload(kind: CertType, form: Record<string, any>): CertBody {
const result = newCert(kind) as unknown as Record<string, any>
for (const key of Object.keys(result)) if (key in form) result[key] = form[key]
if (['draft', 'suspended', 'void'].includes(form.status)) result.status = form.status
if (kind === 'transport')
result.purchaseRefs = (form.purchaseRefs || []).map((r: Record<string, unknown>) =>
Object.fromEntries(
['id', 'referenceType', 'referenceNo', 'purchaseCertId', 'remark', 'orderNum']
.filter((k) => k in r)
.map((k) => [k, r[k]])
)
)
return result as CertBody
}
export function mergeCandidates(
form: CertBody,
candidates: Partial<CertBody>,
edited: Set<string>
) {
const target = form as unknown as Record<string, unknown>
for (const [key, value] of Object.entries(candidates)) {
const current = target[key]
if (
!edited.has(key) &&
(current == null || current === '' || (Array.isArray(current) && current.length === 0))
) {
target[key] = value
}
}
}
export function bindingPayload(bindings: CertBinding[]): CertBinding[] {
return bindings.map((b) => ({
...(b.id ? { id: b.id } : {}),
certType: b.certType,
certId: b.certId,
linkStatus: b.linkStatus || 'active',
...(b.remark !== undefined ? { remark: b.remark } : {}),
...(b.orderNum !== undefined ? { orderNum: b.orderNum } : {}),
products: b.products.map((p) => ({
...(p.id ? { id: p.id } : {}),
contractProductId: p.contractProductId,
scopeStatus: p.scopeStatus || 'active',
...(p.remark !== undefined ? { remark: p.remark } : {}),
...(p.orderNum !== undefined ? { orderNum: p.orderNum } : {})
}))
}))
}
export function bindingProductEnabled(binding: CertBinding, productId: string): boolean {
return (
binding.linkStatus !== 'inactive' &&
binding.products.some((p) => p.contractProductId === productId && p.scopeStatus !== 'inactive')
)
}