feat(pl): 购买证运输证改为客户品类额度并支持齐备完善。

按客户与证用品类自动选证,未配置生效时间拦住安排;齐备仅放行安排,归档前须完善挂证,并补充迁移与测试造数脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-14 23:31:01 +08:00
1 parent aad74505c0
commit cd94ebc56d
31 files changed
+2835 -39

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'
+45 -1
View File
@@ -44,6 +44,8 @@ export interface CertBody {
routeText?: string | null
consigneeName?: string | null
applicantName?: string | null
customerId?: string | null
categoryId?: string | null
contractBindings?: CertificateContractBinding[]
purchaseRefs?: PurchaseReference[]
}
@@ -61,6 +63,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
}
export interface ScopeProduct {
id?: string
@@ -393,7 +396,48 @@ export const certReadyApi = {
.get(`${base}/plans/cert-ready`, { params })
.then(pickBxData<{ total: number; list: CertReadyPlan[] }>),
mark: (id: string) =>
request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData<CertReadyPlan>)
request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData<CertReadyPlan>),
listPendingFulfill: (params: { keyword?: string; page: number; pageSize: number }) =>
request
.get(`${base}/plans/cert-ready/pending-fulfill`, { params })
.then(pickBxData<{ total: number; list: CertReadyPlan[] }>),
fulfill: (id: string) =>
request.post(`${base}/plans/${id}/fulfill-quota-certs`).then(pickBxData<CertReadyPlan>),
fulfillBatch: (planIds: string[]) =>
request
.post(`${base}/plans/fulfill-quota-certs/batch`, { planIds })
.then(pickBxData<{ results: Array<{ planId: string; ok: boolean; message?: string }> }>)
}
export const certCategoryApi = {
list: (params?: { keyword?: string; status?: string }) =>
request.get(`${base}/categories`, { params }).then(pickBxData<CertCategory[]>),
get: (id: string) => request.get(`${base}/categories/${id}`).then(pickBxData<CertCategory>),
create: (body: Record<string, unknown>) =>
request.post(`${base}/categories`, body).then(pickBxData<CertCategory>),
update: (id: string, body: Record<string, unknown>) =>
request.put(`${base}/categories/${id}`, body).then(pickBxData<CertCategory>),
remove: (id: string) =>
request.delete(`${base}/categories/${id}`).then(pickBxData<{ ok?: boolean }>)
}
export interface CertCategory {
id: string
categoryName: string
categoryCode?: string | null
status: string
products?: Array<{ id?: string; productId: string; scopeStatus?: string }>
}
export const certQuotaRuleApi = {
get: () =>
request
.get(`${base}/rule-config`)
.then(pickBxData<{ effectiveAt: string | null; configured: boolean }>),
save: (effectiveAt: string | null) =>
request
.put(`${base}/rule-config`, { effectiveAt })
.then(pickBxData<{ effectiveAt: string | null; configured: boolean }>)
}
export const planQuotaApi = {
@@ -71,6 +71,23 @@
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 待完善并关闭」挂证
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span>{{ item.certNo || item.certId }}</span>
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">装送货信息</h3>
<PlPlanStopsSplitShell
@@ -1633,11 +1650,41 @@
})
)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
}>
>([])
async function loadLinkedCerts(planId: string) {
linkedCertRows.value = []
try {
const { planQuotaApi } = await import('@/api/pl/quota-cert')
const data = await planQuotaApi.get(planId)
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
kind: String(u.certType || u.cert_type || ''),
certId: String(u.certId || u.cert_id || ''),
certNo: u.certNo || u.cert_no,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—'
}))
} catch {
linkedCertRows.value = []
}
}
async function runLoadDetail(planId: string) {
await loadDetail(planId, {
initialPlan: props.initialPlan?.id === planId ? props.initialPlan : null,
prefetchedArrange: props.prefetchedArrange
})
await loadLinkedCerts(planId)
}
watch(
+7
View File
@@ -68,6 +68,13 @@ export const plRoutes: AppRouteRecord[] = [
component: '/pl/quota-cert/index',
meta: { title: '证件建档', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERT_CATEGORY',
name: 'PlCertCategory',
path: 'cert-category',
component: '/pl/cert-category/index',
meta: { title: '证用品类', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CONTRACT_QUOTA',
name: 'PlContractQuota',
+11
View File
@@ -727,6 +727,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'system:dict:list'
}
},
{
id: 'SYSM_CERT_QUOTA_RULE',
name: 'CertQuotaRule',
path: 'cert-quota-rule',
component: '/system/cert-quota-rule/index',
meta: {
title: '运输证件规则',
keepAlive: false,
menuCode: 'system:tenant:list'
}
},
{
id: '106',
name: 'Menu',
@@ -0,0 +1,190 @@
<!-- 证用品类 — P01 标准列表;与 CRM 分类独立 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">证用品类</h2>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="keyword"
placeholder="品类名称"
@search="search"
@clear="search"
/>
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>新增品类
</el-button>
<el-button :loading="loading" @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<ListTable
v-loading="loading"
table-key="pl-cert-category"
class="list-table"
:data="rows"
stripe
row-key="id"
>
<el-table-column prop="categoryName" label="品类名称" min-width="160" />
<el-table-column prop="categoryCode" label="编码" min-width="120">
<template #default="{ row }">{{ row.categoryCode || '—' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.status === '1' ? 'success' : 'info'">
{{ row.status === '1' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</ListTable>
</div>
</section>
<el-dialog
v-model="visible"
class="dialog-form-layout"
:title="editingId ? '编辑证用品类' : '新增证用品类'"
width="640px"
align-center
destroy-on-close
>
<el-form label-position="top">
<div class="form-grid-2">
<el-form-item label="品类名称" required>
<el-input v-model="form.categoryName" maxlength="128" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.categoryCode" maxlength="64" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" class="w-full">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
</div>
<el-form-item label="关联系统产品(产品 ID,逗号分隔,保存后按差量写入)">
<el-input
v-model="productIdsText"
type="textarea"
:rows="3"
placeholder="例如:uuid1,uuid2(上线后可改产品选择器)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = 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>
</div>
</template>
<script setup lang="ts">
import { certCategoryApi, type CertCategory } from '@/api/pl/quota-cert'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
const keyword = ref('')
const loading = ref(false)
const saving = ref(false)
const rows = ref<CertCategory[]>([])
const visible = ref(false)
const editingId = ref('')
const form = reactive({ categoryName: '', categoryCode: '', status: '1' })
const productIdsText = ref('')
async function load() {
loading.value = true
try {
rows.value = (await certCategoryApi.list({ keyword: keyword.value || undefined })) || []
} catch (error: any) {
ElMessage.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
function search() {
void load()
}
async function openEdit(row?: CertCategory) {
editingId.value = row?.id || ''
form.categoryName = row?.categoryName || ''
form.categoryCode = row?.categoryCode || ''
form.status = row?.status || '1'
productIdsText.value = ''
if (row?.id) {
const detail = await certCategoryApi.get(row.id)
productIdsText.value = (detail.products || [])
.filter((p) => p.scopeStatus !== 'inactive')
.map((p) => p.productId)
.join(',')
}
visible.value = true
}
async function save() {
if (!form.categoryName.trim()) {
ElMessage.warning('请填写品类名称')
return
}
const productIds = productIdsText.value
.split(/[,,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
const body = {
categoryName: form.categoryName.trim(),
categoryCode: form.categoryCode || null,
status: form.status,
products: productIds.map((productId) => ({ productId, scopeStatus: 'active' }))
}
saving.value = true
try {
if (editingId.value) await certCategoryApi.update(editingId.value, body)
else await certCategoryApi.create(body)
ElMessage.success('已保存')
visible.value = false
await load()
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function remove(row: CertCategory) {
try {
await ElMessageBox.confirm(`确认删除品类「${row.categoryName}」?`, '删除', { type: 'warning' })
} catch {
return
}
try {
await certCategoryApi.remove(row.id)
ElMessage.success('已删除')
await load()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
onMounted(() => {
void load()
})
</script>
+99 -16
View File
@@ -4,12 +4,25 @@
<div class="panel-top"><h2 class="title-bar-heading">证书齐备</h2></div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-radio-group v-model="listMode" class="mr-3" @change="onModeChange">
<el-radio-button value="mark">标记齐备</el-radio-button>
<el-radio-button value="fulfill">待完善并关闭</el-radio-button>
</el-radio-group>
<ListQuickSearchInput
v-model="keyword"
placeholder="计划编号"
@search="search"
@clear="search"
/>
<el-button
v-if="listMode === 'fulfill'"
type="primary"
:disabled="!selectedIds.length"
:loading="batching"
@click="fulfillBatch"
>
<el-icon><CircleCheck /></el-icon>批量完善并关闭
</el-button>
<el-button v-if="keyword || hasActiveFilters" @click="reset">
<el-icon><RefreshLeft /></el-icon>清除筛选
</el-button>
@@ -29,6 +42,7 @@
row-key="id"
stripe
class="list-table"
@selection-change="onSelectionChange"
>
<el-table-column
v-for="column in columns"
@@ -73,17 +87,34 @@
<span v-else>{{ row[column.prop] || '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="listMode === 'fulfill'"
type="selection"
width="48"
:reserve-selection="false"
/>
<el-table-column label="操作" min-width="260" fixed="right">
<template #default="{ row }">
<el-button
v-if="!row.quotaCertWaived"
type="primary"
:loading="markingId === row.id"
@click="mark(row)"
>
<el-icon><CircleCheck /></el-icon>证书齐备
</el-button>
<span v-else>已确认</span>
<template v-if="listMode === 'fulfill'">
<el-button
type="primary"
:loading="markingId === row.id"
@click="fulfillOne(row)"
>
<el-icon><CircleCheck /></el-icon>完善并关闭
</el-button>
</template>
<template v-else>
<el-button
v-if="!row.quotaCertWaived"
type="primary"
:loading="markingId === row.id"
@click="mark(row)"
>
<el-icon><CircleCheck /></el-icon>证书齐备
</el-button>
<span v-else>已确认</span>
</template>
</template>
</el-table-column>
</ListTable>
@@ -124,6 +155,9 @@
const total = ref(0)
const loading = ref(false)
const markingId = ref('')
const batching = ref(false)
const listMode = ref<'mark' | 'fulfill'>('mark')
const selectedIds = ref<string[]>([])
const rows = ref<CertReadyPlan[]>([])
let epoch = 0
const defaultPlanDates = certReadyDefaultPlanDates()
@@ -203,13 +237,21 @@
async function load() {
const current = ++epoch
loading.value = true
selectedIds.value = []
try {
const value = await certReadyApi.list({
keyword: keyword.value || undefined,
page: page.value,
pageSize: 20,
...toQueryParams()
})
const value =
listMode.value === 'fulfill'
? await certReadyApi.listPendingFulfill({
keyword: keyword.value || undefined,
page: page.value,
pageSize: 20
})
: await certReadyApi.list({
keyword: keyword.value || undefined,
page: page.value,
pageSize: 20,
...toQueryParams()
})
if (current !== epoch) return
rows.value = value?.list || []
total.value = value?.total || 0
@@ -225,6 +267,11 @@
void load()
}
function onModeChange() {
page.value = 1
void load()
}
function reset() {
keyword.value = ''
if (hasActiveFilters.value) clearAllFilters()
@@ -234,7 +281,7 @@
async function mark(row: CertReadyPlan) {
try {
await ElMessageBox.confirm(
'确认后,这条运输计划在安排和归档时都不再校验购买证、运输证,也不扣减额度。确认人和时间会记在本行。',
'确认后可先安排运输;新规则下归档前仍须在「待完善并关闭」中挂上购买证/运输证。确认人和时间会记在本行。',
'证书齐备',
{ type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' }
)
@@ -253,6 +300,42 @@
}
}
async function fulfillOne(row: CertReadyPlan) {
markingId.value = row.id
try {
await certReadyApi.fulfill(row.id)
ElMessage.success('已完善并关闭')
await load()
} catch (error) {
ElMessage.error(certReadyErrorText(error, '完善并关闭失败'))
} finally {
markingId.value = ''
}
}
function onSelectionChange(selection: CertReadyPlan[]) {
selectedIds.value = selection.map((row) => row.id)
}
async function fulfillBatch() {
if (!selectedIds.value.length) return
batching.value = true
try {
const res = await certReadyApi.fulfillBatch(selectedIds.value)
const failed = (res?.results || []).filter((r) => !r.ok)
if (failed.length) {
ElMessage.warning(`部分失败:${failed.map((f) => f.message || f.planId).join(';')}`)
} else {
ElMessage.success('批量完善完成')
}
await load()
} catch (error) {
ElMessage.error(certReadyErrorText(error, '批量完善失败'))
} finally {
batching.value = false
}
}
const tableWrapRef = ref<HTMLElement | null>(null)
const pagerRef = ref<HTMLElement | null>(null)
const tableRef = ref()
@@ -0,0 +1,78 @@
<!-- 运输证件规则切点 — 系统信息 Tab;P01 简表单 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">运输证件规则</h2>
</div>
<div class="p-6 max-w-xl">
<p class="text-sm text-slate-500 mb-4">
配置「客户+品类」额度生效时间。未配置时无法安排运输。计划创建时按该时间打规则版本;修改后只影响之后新建的计划。
</p>
<el-form label-position="top" class="dialog-form-layout">
<el-form-item label="客户品类额度生效时间">
<el-date-picker
v-model="effectiveAt"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择生效时间"
class="w-full"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">
<el-icon><Check /></el-icon>保存
</el-button>
<el-button :loading="loading" @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</el-form-item>
<p v-if="!configured" class="text-amber-600 text-sm">当前未配置:运输计划安排将被拦住。</p>
<p v-else class="text-emerald-700 text-sm">已配置生效时间:{{ effectiveAt }}</p>
</el-form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { certQuotaRuleApi } from '@/api/pl/quota-cert'
import { Check, Refresh } from '@element-plus/icons-vue'
const loading = ref(false)
const saving = ref(false)
const effectiveAt = ref<string | null>(null)
const configured = ref(false)
async function load() {
loading.value = true
try {
const data = await certQuotaRuleApi.get()
effectiveAt.value = data?.effectiveAt || null
configured.value = Boolean(data?.configured)
} catch (error: any) {
ElMessage.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const data = await certQuotaRuleApi.save(effectiveAt.value)
effectiveAt.value = data?.effectiveAt || null
configured.value = Boolean(data?.configured)
ElMessage.success(configured.value ? '已保存生效时间' : '已清空生效时间(安排将被拦住)')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>