This commit is contained in:
zlwu committed 2026-07-12 17:14:53 +08:00
1 parent ffc89d9b57
commit c2cba3e668
53 files changed
+11794 -6264

No files matched your search

+5
View File
@@ -147,6 +147,8 @@ export interface Personnel {
phoneNo?: string
age?: number
idCardNo?: string
personnelType: string
personnelAffiliation: string
status: string
remark?: string
nearestExpireDate?: string
@@ -162,6 +164,8 @@ export interface PersonnelPayload {
phoneNo?: string
age?: number | null
idCardNo?: string
personnelType: string
personnelAffiliation: string
status: string
remark?: string
certList?: CertRow[]
@@ -170,6 +174,7 @@ export interface PersonnelPayload {
export interface PersonnelQuery {
q?: string
status?: string
personnelAffiliation?: string
page?: number
pageSize?: number
}
@@ -3,11 +3,11 @@
<div class="cert-form-header">
<span class="cert-form-title">{{ title }}</span>
<el-button type="primary" size="small" @click="handleAddCert">
<el-icon><Plus /></el-icon>添加证书
<el-icon><Plus /></el-icon>{{ addButtonLabel }}
</el-button>
</div>
<div v-if="!certList.length" class="cert-empty">暂无证书,点击「添加证书」</div>
<div v-if="!certList.length" class="cert-empty">{{ emptyHint }}</div>
<el-table v-else-if="layout === 'table'" :data="certList" stripe class="list-table cert-form-table">
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -20,7 +20,7 @@
>
<template #default="{ row }">
<el-select
v-if="hasTrailerPlateOption"
v-if="hasTrailerPlateOption && !isRowRequired(row)"
:model-value="normalizeVehicleCertPart(row.vehiclePart)"
placeholder="请选择车牌号"
style="width: 100%"
@@ -33,12 +33,17 @@
:value="option.value"
/>
</el-select>
<span v-else>{{ headPlateLabel }}</span>
<span v-else>{{ resolvePlateLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="证书名称" min-width="160">
<template #default="{ row }">
<span v-if="isRowRequired(row)" class="cert-name-locked">
{{ row.certName }}
<el-tag type="info" size="small">必备</el-tag>
</span>
<el-select
v-else
v-model="row.certName"
placeholder="请选择或输入"
filterable
@@ -87,8 +92,8 @@
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="handleRemoveCert($index)">
<template #default="{ row, $index }">
<el-button v-if="!isRowRequired(row)" link type="danger" @click="handleRemoveCert($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
@@ -98,15 +103,19 @@
<div v-else class="cert-card-list">
<div v-for="(cert, index) in certList" :key="cert._key" class="cert-card">
<div class="cert-card-head">
<span class="cert-card-index">证书 {{ index + 1 }}</span>
<el-button link type="danger" @click="handleRemoveCert(index)">
<span class="cert-card-index">
{{ isRowRequired(cert) ? `必备 · ${cert.certName}` : `证书 ${index + 1}` }}
</span>
<el-button v-if="!isRowRequired(cert)" link type="danger" @click="handleRemoveCert(index)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
<div class="form-grid-4 cert-card-grid">
<el-form-item label="证书名称" required>
<span v-if="isRowRequired(cert)" class="cert-name-locked">{{ cert.certName }}</span>
<el-select
v-else
v-model="cert.certName"
placeholder="请选择或输入证书名称"
filterable
@@ -155,12 +164,23 @@ import { Delete, Plus } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import type { CertRow } from '@/api/pl'
import {
ensureCarrierVehicleCertSlots,
ensureSelfVehicleCertSlots,
mergeCertListWithRequiredSlots,
type CertRowWithMeta
} from '@/utils/pl-cert-slots'
import { matchRequiredCertName } from '@/utils/pl-personnel-cert'
import {
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
type CertRowWithKey = CertRow & { _key: string }
type CertRowWithKey = CertRowWithMeta & { _key: string }
type CertUploadRef = InstanceType<typeof AttachmentUploadField>
const props = withDefaults(
@@ -169,10 +189,16 @@ const props = withDefaults(
uploadFolder: string
certNameOptions?: string[]
layout?: 'card' | 'table'
/** 列表模式:展示车牌号列,可从主车/挂车车牌中选择 */
showVehiclePartColumn?: boolean
licensePlate?: string
trailerNo?: string
/** 人员/单段车辆:固定必备证书名称 */
requiredCertNames?: readonly string[]
requiredVehiclePart?: string
/** 山峡自有车统一表格:按主车/挂车自动注入必备槽位 */
enforceSelfVehicleRequired?: boolean
/** 承运商车辆:按主车/挂车注入必备槽位(行驶证、营运证) */
enforceCarrierVehicleRequired?: boolean
}>(),
{
title: '证书',
@@ -180,10 +206,27 @@ const props = withDefaults(
layout: 'card',
showVehiclePartColumn: false,
licensePlate: '',
trailerNo: ''
trailerNo: '',
requiredCertNames: () => [],
requiredVehiclePart: PL_VEHICLE_CERT_PART_HEAD,
enforceSelfVehicleRequired: false,
enforceCarrierVehicleRequired: false
}
)
const hasRequiredMode = computed(
() =>
props.enforceSelfVehicleRequired ||
props.enforceCarrierVehicleRequired ||
(props.requiredCertNames?.length ?? 0) > 0
)
const addButtonLabel = computed(() => (hasRequiredMode.value ? '添加其他证书' : '添加证书'))
const emptyHint = computed(() =>
hasRequiredMode.value ? '暂无证书' : '暂无证书,点击「添加证书」'
)
const headPlateLabel = computed(() => props.licensePlate?.trim() || '—')
const hasTrailerPlateOption = computed(() => Boolean(props.trailerNo?.trim()))
@@ -203,23 +246,88 @@ const vehiclePlateOptions = computed(() => {
return options
})
function resolvePlateLabel(row: CertRowWithKey) {
const part = normalizeVehicleCertPart(row.vehiclePart)
if (part === PL_VEHICLE_CERT_PART_TRAILER) {
return props.trailerNo?.trim() || '挂车'
}
return headPlateLabel.value
}
function requiredNamesForRow(row: CertRowWithKey): readonly string[] {
if (props.requiredCertNames?.length) {
return props.requiredCertNames
}
if (!props.enforceSelfVehicleRequired && !props.enforceCarrierVehicleRequired) return []
const part = normalizeVehicleCertPart(row.vehiclePart)
if (props.enforceCarrierVehicleRequired) {
return part === PL_VEHICLE_CERT_PART_TRAILER
? PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES
}
return part === PL_VEHICLE_CERT_PART_TRAILER
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
}
function isRowRequired(row: CertRowWithKey) {
if (row._required) return true
const requiredNames = requiredNamesForRow(row)
if (!requiredNames.length) return false
return requiredNames.some(name => matchRequiredCertName(row.certName, name))
}
function syncRequiredSlots() {
if (props.enforceSelfVehicleRequired) {
certList.value = ensureSelfVehicleCertSlots(certList.value, props.trailerNo)
return
}
if (props.enforceCarrierVehicleRequired) {
certList.value = ensureCarrierVehicleCertSlots(certList.value, props.trailerNo)
return
}
if (props.requiredCertNames?.length) {
certList.value = mergeCertListWithRequiredSlots(certList.value, props.requiredCertNames, {
vehiclePart: props.requiredVehiclePart
})
}
}
function handleVehiclePartChange(row: CertRowWithKey, value: string) {
if (isRowRequired(row)) return
row.vehiclePart = normalizeVehicleCertPart(value)
}
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
watch(
() =>
[
props.enforceSelfVehicleRequired,
props.enforceCarrierVehicleRequired,
props.requiredCertNames,
props.trailerNo
] as const,
() => {
if (hasRequiredMode.value) {
syncRequiredSlots()
}
},
{ immediate: true }
)
const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
function createEmptyCert(): CertRowWithKey {
return {
_key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
vehiclePart: props.requiredVehiclePart || PL_VEHICLE_CERT_PART_HEAD,
certName: '',
certNo: '',
expireDate: '',
remindDays: 30,
attachmentUrls: ''
attachmentUrls: '',
_required: false
}
}
@@ -228,6 +336,8 @@ function handleAddCert() {
}
function handleRemoveCert(index: number) {
const row = certList.value[index]
if (row && isRowRequired(row)) return
const next = [...certList.value]
next.splice(index, 1)
certList.value = next
@@ -250,7 +360,11 @@ function clearUploadRefs() {
certUploadRefs.value = {}
}
defineExpose({ hasUploading, clearUploadRefs })
function refreshRequiredSlots() {
syncRequiredSlots()
}
defineExpose({ hasUploading, clearUploadRefs, refreshRequiredSlots })
</script>
<style scoped lang="scss">
@@ -282,6 +396,14 @@ defineExpose({ hasUploading, clearUploadRefs })
background: #f8fafc;
}
.cert-name-locked {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--text, #1e293b);
}
.cert-form-table {
border-top: 1px solid var(--mint-border, #d9e8e2);
@@ -26,61 +26,67 @@
</section>
<section class="pl-plan-form-section pl-plan-freight-section">
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="pl-plan-freight-section-head">
<h4 class="pl-plan-subtitle">运费信息</h4>
<label class="pl-plan-freight-switch">
<span class="pl-plan-freight-switch-label">是否有运输费用</span>
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</label>
</div>
<el-form
v-if="formData.hasFreight === 1"
:model="formData"
label-width="140px"
label-position="right"
class="pl-plan-freight-form"
>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
<div class="form-grid-4 pl-plan-archive-freight-grid">
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item label="运输公里数">
<el-input-number
v-model="formData.distanceKm"
:min="0"
:precision="2"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<div v-else class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
<div class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
/>
</el-form-item>
<div v-else class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number
v-model="formData.distanceKm"
:min="0"
:precision="2"
/>
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
/>
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</template>
</div>
</el-form>
</section>
@@ -119,7 +125,10 @@ import {
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { enrichPlanProductsHazardFromCrm } from '@/composables/usePlToxicProductCertCheck'
import {
buildMissingHazardCertReadyMessage,
enrichPlanProductsHazardFromCrm
} from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
@@ -302,6 +311,16 @@ async function handleSubmit() {
return
}
if (!isAdjustMode.value) {
const certMessage = buildMissingHazardCertReadyMessage(plan.value, productRows.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法归档', {
type: 'warning'
})
return
}
}
submitLoading.value = true
try {
const body: PlanArchiveBody = {
@@ -392,6 +411,7 @@ async function handleSubmit() {
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 4px;
margin-bottom: 0;
}
:deep(.el-form-item__label) {
@@ -400,6 +420,28 @@ async function handleSubmit() {
}
}
.pl-plan-freight-section-head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
margin-bottom: 10px;
}
.pl-plan-freight-switch {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
cursor: pointer;
}
.pl-plan-freight-switch-label {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pl-plan-freight-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -418,8 +460,13 @@ async function handleSubmit() {
min-width: 0;
}
.form-grid-4 {
.pl-plan-archive-freight-grid {
align-items: center;
row-gap: 12px;
}
.pl-plan-archive-freight-grid-cell {
min-height: 1px;
}
:deep(.el-select),
@@ -11,6 +11,8 @@
show-vehicle-part-column
:license-plate="licensePlate"
:trailer-no="trailerNo"
:enforce-self-vehicle-required="enforceSelfVehicleRequired"
:enforce-carrier-vehicle-required="enforceCarrierVehicleRequired"
/>
<el-tabs
@@ -73,13 +75,19 @@ const props = withDefaults(
certNameOptions?: string[]
title?: string
layout?: 'card' | 'table'
/** 山峡自有车:注入必备证书槽位并校验 */
enforceSelfVehicleRequired?: boolean
/** 承运商车辆:注入必备证书槽位并校验 */
enforceCarrierVehicleRequired?: boolean
}>(),
{
licensePlate: '',
trailerNo: '',
certNameOptions: () => [],
title: '车辆证书',
layout: 'card'
layout: 'card',
enforceSelfVehicleRequired: false,
enforceCarrierVehicleRequired: false
}
)
@@ -149,9 +157,16 @@ function clearUploadRefs() {
trailerCertFormRef.value?.clearUploadRefs()
}
function refreshRequiredSlots() {
unifiedCertFormRef.value?.refreshRequiredSlots?.()
headCertFormRef.value?.refreshRequiredSlots?.()
trailerCertFormRef.value?.refreshRequiredSlots?.()
}
defineExpose({
hasUploading,
clearUploadRefs
clearUploadRefs,
refreshRequiredSlots
})
</script>
@@ -46,7 +46,8 @@ import type { ViewTableMode } from '@/components/business/system-view-dialog/typ
import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
getSelfVehiclePartCertMissingSuffix,
getVehiclePartCertMissingSuffix,
type PlVehicleCertCheckType,
splitVehicleCertsByPart
} from '@/utils/pl-vehicle-cert'
@@ -56,6 +57,8 @@ const props = withDefaults(
licensePlate?: string
trailerNo?: string
vehicleId?: string
vehicleCertCheckType?: PlVehicleCertCheckType
/** @deprecated 使用 vehicleCertCheckType */
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
@@ -65,6 +68,7 @@ const props = withDefaults(
licensePlate: '',
trailerNo: '',
vehicleId: '',
vehicleCertCheckType: undefined,
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
@@ -98,16 +102,26 @@ const scopedCertList = computed(() => {
const headCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).headCerts)
const trailerCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).trailerCerts)
const resolvedVehicleCertCheckType = computed<PlVehicleCertCheckType | undefined>(() => {
if (props.vehicleCertCheckType) return props.vehicleCertCheckType
return props.checkSelfVehicleCerts ? 'self' : undefined
})
const headTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return headTabLabel.value
return headTabLabel.value + getSelfVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD)
const checkType = resolvedVehicleCertCheckType.value
if (!checkType) return headTabLabel.value
return (
headTabLabel.value +
getVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD, checkType)
)
})
const trailerTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return trailerTabLabel.value
const checkType = resolvedVehicleCertCheckType.value
if (!checkType) return trailerTabLabel.value
return (
trailerTabLabel.value +
getSelfVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER)
getVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER, checkType)
)
})
@@ -7,7 +7,7 @@
:license-plate="licensePlate"
:trailer-no="trailerNo"
:vehicle-id="vehicleId"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:vehicle-cert-check-type="vehicleCertCheckType"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
@@ -20,7 +20,7 @@
>
<PersonnelViewPanel
:person="person"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:check-linked-personnel-certs="showLinkedPersonnelCertCheck"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
@@ -34,7 +34,7 @@ import PersonnelViewPanel from '@/components/business/pl-vehicle-personnel-view-
import type { CertRead } from '@/api/pl'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
import { getSelfVehicleCertMissingSuffix } from '@/utils/pl-vehicle-cert'
import { getVehicleCertMissingSuffix, type PlVehicleCertCheckType } from '@/utils/pl-vehicle-cert'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
import { formatPlPersonnelNameWithRole } from '@/utils/pl-display'
@@ -45,6 +45,10 @@ const props = withDefaults(
trailerNo?: string
vehicleId?: string
personnelList?: VehiclePersonnelViewRow[]
vehicleCertCheckType?: PlVehicleCertCheckType
/** 山峡自有车:关联人员证书缺失提示 */
checkLinkedPersonnelCerts?: boolean
/** @deprecated 使用 vehicleCertCheckType + checkLinkedPersonnelCerts */
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
@@ -55,6 +59,8 @@ const props = withDefaults(
trailerNo: '',
vehicleId: '',
personnelList: () => [],
vehicleCertCheckType: undefined,
checkLinkedPersonnelCerts: false,
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
@@ -73,23 +79,29 @@ const tabsInstanceKey = computed(
const vehicleCertTabLabel = computed(() => {
const label = '车辆证书'
if (!props.checkSelfVehicleCerts) return label
const checkType = props.vehicleCertCheckType
if (!checkType) return label
return (
label +
getSelfVehicleCertMissingSuffix({
getVehicleCertMissingSuffix({
vehicleType: checkType,
certList: props.certList,
trailerNo: props.trailerNo
})
)
})
const showLinkedPersonnelCertCheck = computed(
() => props.checkLinkedPersonnelCerts || props.checkSelfVehicleCerts
)
function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
return `person-${props.vehicleId || props.licensePlate || 'vehicle'}-${person.personnelId || `${person.personnelName}-${index}`}`
}
function getPersonTabLabel(person: VehiclePersonnelViewRow) {
const suffix =
props.checkSelfVehicleCerts && person.role
showLinkedPersonnelCertCheck.value && person.role
? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
: ''
return `${formatPlPersonnelNameWithRole(person.personnelName, person.role)}${suffix}`
@@ -38,19 +38,26 @@ const props = withDefaults(
person: VehiclePersonnelViewRow
showTitle?: boolean
checkSelfVehicleCerts?: boolean
/** @deprecated 使用 checkLinkedPersonnelCerts */
checkLinkedPersonnelCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
showTitle: false,
checkSelfVehicleCerts: false,
checkLinkedPersonnelCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const showPersonnelCertCheck = computed(
() => props.checkLinkedPersonnelCerts || props.checkSelfVehicleCerts
)
const personnelCertMissingSuffix = computed(() => {
if (!props.checkSelfVehicleCerts || !props.person.role) return ''
if (!showPersonnelCertCheck.value || !props.person.role) return ''
return getPersonnelCertMissingSuffix(
props.person.certList || [],
props.person.role as 'driver' | 'escort'
@@ -18,7 +18,8 @@
:trailer-no="detail?.trailerNo"
:vehicle-id="detail?.id"
:personnel-list="personnelList"
:check-self-vehicle-certs="detail?.vehicleType === 'self'"
:vehicle-cert-check-type="detail?.vehicleType === 'self' || detail?.vehicleType === 'carrier' ? detail.vehicleType : undefined"
:check-linked-personnel-certs="detail?.vehicleType === 'self'"
cert-layout="table"
/>
</template>
+241
View File
@@ -0,0 +1,241 @@
import type { CertRow } from '@/api/pl'
import { matchRequiredCertName } from '@/utils/pl-personnel-cert'
import {
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
type PlVehicleCertCheckType,
getVehicleHeadRequiredCertNames,
getVehicleTrailerRequiredCertNames,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
export type CertRowWithMeta = CertRow & { _key?: string; _required?: boolean }
export function isRequiredCertSlotComplete(
cert: CertRow | undefined,
requiredName: string
): boolean {
if (!cert) return false
if (!matchRequiredCertName(cert.certName, requiredName)) return false
if (!cert.certNo?.trim()) return false
if (!cert.expireDate) return false
if (!cert.attachmentUrls?.trim()) return false
return true
}
export function getMissingRequiredCertFieldLabels(
cert: CertRow | undefined,
requiredName: string
): string[] {
if (!cert || !matchRequiredCertName(cert.certName, requiredName)) {
return ['证号', '有效期', '附件']
}
const missing: string[] = []
if (!cert.certNo?.trim()) missing.push('证号')
if (!cert.expireDate) missing.push('有效期')
if (!cert.attachmentUrls?.trim()) missing.push('附件')
return missing
}
export function collectRequiredCertSlotErrors(
certList: CertRow[],
requiredNames: readonly string[],
partLabel = ''
): string[] {
const errors: string[] = []
for (const requiredName of requiredNames) {
const matched = certList.find(c => matchRequiredCertName(c.certName, requiredName))
const missing = getMissingRequiredCertFieldLabels(matched, requiredName)
if (missing.length) {
const prefix = partLabel ? `${partLabel}「${requiredName}」` : `「${requiredName}」`
errors.push(`${prefix}缺少:${missing.join('、')}`)
}
}
return errors
}
export function collectSelfVehicleRequiredCertErrors(
certList: CertRow[],
trailerNo?: string | null
): string[] {
return collectVehicleRequiredCertErrors(certList, trailerNo, 'self')
}
export function collectCarrierVehicleRequiredCertErrors(
certList: CertRow[],
trailerNo?: string | null
): string[] {
return collectVehicleRequiredCertErrors(certList, trailerNo, 'carrier')
}
export function collectVehicleRequiredCertErrors(
certList: CertRow[],
trailerNo: string | null | undefined,
vehicleType: PlVehicleCertCheckType
): string[] {
const headCerts = certList.filter(
c => normalizeVehicleCertPart(c.vehiclePart) === PL_VEHICLE_CERT_PART_HEAD
)
const trailerCerts = certList.filter(
c => normalizeVehicleCertPart(c.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
)
const errors = [
...collectRequiredCertSlotErrors(
headCerts,
getVehicleHeadRequiredCertNames(vehicleType),
'主车'
),
...(trailerNo?.trim()
? collectRequiredCertSlotErrors(
trailerCerts,
getVehicleTrailerRequiredCertNames(vehicleType),
'挂车'
)
: [])
]
return errors
}
function createEmptyCertRow(
certName: string,
vehiclePart?: string,
required = false
): CertRowWithMeta {
return {
_key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
vehiclePart,
certName,
certNo: '',
expireDate: '',
remindDays: 30,
attachmentUrls: '',
_required: required
}
}
function findCertIndexForRequired(
certs: CertRowWithMeta[],
requiredName: string,
vehiclePart?: string
): number {
return certs.findIndex(cert => {
if (vehiclePart && normalizeVehicleCertPart(cert.vehiclePart) !== vehiclePart) {
return false
}
return matchRequiredCertName(cert.certName, requiredName)
})
}
export function mergeCertListWithRequiredSlots(
existing: CertRowWithMeta[],
requiredNames: readonly string[],
options?: { vehiclePart?: string }
): CertRowWithMeta[] {
const vehiclePart = options?.vehiclePart
const next = existing.map(cert => ({ ...cert }))
const normalizedPart = vehiclePart ? normalizeVehicleCertPart(vehiclePart) : null
const extras = next.filter(cert => {
if (normalizedPart && normalizeVehicleCertPart(cert.vehiclePart) !== normalizedPart) {
return false
}
return !requiredNames.some(name => matchRequiredCertName(cert.certName, name))
})
const requiredRows: CertRowWithMeta[] = requiredNames.map(requiredName => {
const idx = findCertIndexForRequired(next, requiredName, normalizedPart || undefined)
if (idx >= 0) {
return {
...next[idx],
certName: requiredName,
vehiclePart: normalizedPart || next[idx].vehiclePart || PL_VEHICLE_CERT_PART_HEAD,
_required: true
}
}
return createEmptyCertRow(
requiredName,
normalizedPart || PL_VEHICLE_CERT_PART_HEAD,
true
)
})
const otherPartRows = normalizedPart
? next.filter(cert => normalizeVehicleCertPart(cert.vehiclePart) !== normalizedPart)
: []
return [...otherPartRows, ...requiredRows, ...extras.map(c => ({ ...c, _required: false }))]
}
function ensureVehicleCertSlotsByNames(
certList: CertRowWithMeta[],
trailerNo: string | null | undefined,
headRequired: readonly string[],
trailerRequired: readonly string[]
): CertRowWithMeta[] {
let merged = mergeCertListWithRequiredSlots(certList, headRequired, {
vehiclePart: PL_VEHICLE_CERT_PART_HEAD
})
if (trailerNo?.trim()) {
merged = mergeCertListWithRequiredSlots(merged, trailerRequired, {
vehiclePart: PL_VEHICLE_CERT_PART_TRAILER
})
} else {
merged = stripTrailerRequiredSlots(merged, trailerRequired)
}
return merged
}
export function ensureSelfVehicleCertSlots(
certList: CertRowWithMeta[],
trailerNo?: string | null
): CertRowWithMeta[] {
return ensureVehicleCertSlotsByNames(
certList,
trailerNo,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
)
}
export function ensureCarrierVehicleCertSlots(
certList: CertRowWithMeta[],
trailerNo?: string | null
): CertRowWithMeta[] {
return ensureVehicleCertSlotsByNames(
certList,
trailerNo,
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
)
}
export function ensureVehicleCertSlots(
certList: CertRowWithMeta[],
trailerNo: string | null | undefined,
vehicleType: PlVehicleCertCheckType
): CertRowWithMeta[] {
return vehicleType === 'carrier'
? ensureCarrierVehicleCertSlots(certList, trailerNo)
: ensureSelfVehicleCertSlots(certList, trailerNo)
}
export function stripTrailerRequiredSlots(
certList: CertRowWithMeta[],
trailerRequiredNames: readonly string[] = PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
): CertRowWithMeta[] {
return certList.filter(cert => {
if (normalizeVehicleCertPart(cert.vehiclePart) !== PL_VEHICLE_CERT_PART_TRAILER) {
return true
}
return !trailerRequiredNames.some(name => matchRequiredCertName(cert.certName, name))
})
}
export function formatRequiredCertErrors(errors: string[]): string {
if (!errors.length) return ''
return `必备证书未齐全:${errors.join(';')}`
}
+1 -1
View File
@@ -225,7 +225,7 @@ export function getPlPlanFollowStatusLabel(status?: string) {
}
export function getPlPlanArchiveStatusTagType(status?: string) {
return status === 'archived' ? 'info' : 'success'
return status === 'archived' ? 'info' : 'danger'
}
export function getPlPlanArchiveStatusLabel(status?: string) {
+113 -6
View File
@@ -11,6 +11,95 @@ export const PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES = [
export const PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES = ['身份证', '从业资格证'] as const
export type PersonnelCertRole = 'driver' | 'escort'
export type PersonnelType = 'driver' | 'escort' | 'both'
export const PL_PERSONNEL_TYPE_OPTIONS = [
{ value: 'driver', label: '驾驶员' },
{ value: 'escort', label: '押运员' },
{ value: 'both', label: '驾驶员兼押运员' }
] as const
export type PersonnelAffiliation = 'self' | 'carrier'
export const PL_PERSONNEL_AFFILIATION_OPTIONS = [
{ value: 'self', label: '山峡' },
{ value: 'carrier', label: '非山峡' }
] as const
export function getPersonnelAffiliationLabel(affiliation?: string | null): string {
const found = PL_PERSONNEL_AFFILIATION_OPTIONS.find(item => item.value === affiliation)
return found?.label || affiliation || '—'
}
export function getPersonnelAffiliationTagType(
affiliation?: string | null
): 'success' | 'warning' | 'info' {
if (affiliation === 'self') return 'success'
if (affiliation === 'carrier') return 'warning'
return 'info'
}
export function validatePersonnelAffiliationVehicleCompat(
personnelAffiliation: string | undefined,
vehicleType: string | undefined,
onError: (message: string) => void
): boolean {
if (!personnelAffiliation || !vehicleType) return true
if (personnelAffiliation === vehicleType) return true
const vehicleLabel = vehicleType === 'self' ? '山峡' : '非山峡'
onError(`${vehicleLabel}车辆仅可关联${vehicleLabel}人员`)
return false
}
export function getPersonnelTypeLabel(type?: string | null): string {
const found = PL_PERSONNEL_TYPE_OPTIONS.find(item => item.value === type)
return found?.label || type || '—'
}
export function getPersonnelTypeTagType(type?: string | null): 'success' | 'warning' | 'primary' | 'info' {
if (type === 'driver') return 'primary'
if (type === 'escort') return 'warning'
if (type === 'both') return 'success'
return 'info'
}
export function getRequiredPersonnelCertNamesByType(
personnelType?: string | null
): readonly string[] {
if (personnelType === 'escort') {
return PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES
}
return PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES
}
export function validatePersonnelTypeRoleCompat(
personnelType: string | undefined,
role: string | undefined,
onError: (message: string) => void
): boolean {
if (!personnelType || !role) return true
if (personnelType === 'driver' && role === 'escort') {
onError('驾驶员类型人员不可关联为押运员')
return false
}
if (personnelType === 'escort' && role === 'driver') {
onError('押运员类型人员不可关联为驾驶员')
return false
}
return true
}
export function isPersonnelCertStrictComplete(
cert: Pick<CertRow, 'certName' | 'certNo' | 'expireDate' | 'attachmentUrls'> | undefined,
requiredName: string
): boolean {
if (!cert?.certName?.trim()) return false
if (!matchRequiredCertName(cert.certName, requiredName)) return false
if (!cert.certNo?.trim()) return false
if (!cert.expireDate) return false
if (!cert.attachmentUrls?.trim()) return false
return true
}
export function matchRequiredCertName(
actualName: string | undefined,
@@ -28,8 +117,13 @@ export function matchRequiredCertName(
return name === requiredName
}
export function isPersonnelCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
export function isPersonnelCertUploaded(cert?: Pick<CertRow, 'certName' | 'certNo' | 'expireDate' | 'attachmentUrls'>) {
return Boolean(
cert?.certName?.trim() &&
cert?.certNo?.trim() &&
cert?.expireDate &&
cert?.attachmentUrls?.trim()
)
}
export function getRequiredPersonnelCertNames(role: PersonnelCertRole): readonly string[] {
@@ -55,15 +149,28 @@ export function hasPersonnelCertMissing(
return getMissingPersonnelCertNames(role, certList).length > 0
}
/** 人员主数据页:至少满足押运员必备证书(身份证 + 从业资格证) */
export function hasPersonnelMasterCertMissing(certList: CertRow[] = []): boolean {
return hasPersonnelCertMissing('escort', certList)
/** 人员主数据页:按人员类型检查必备证书 */
export function hasPersonnelMasterCertMissing(
certList: CertRow[] = [],
personnelType?: string | null
): boolean {
const required = getRequiredPersonnelCertNamesByType(personnelType || 'both')
return required.some(requiredName => {
const matched = certList.find(cert => matchRequiredCertName(cert.certName, requiredName))
return !isPersonnelCertStrictComplete(matched, requiredName)
})
}
export function getPersonnelCertMissingSuffix(
certList: CertRow[] = [],
role?: PersonnelCertRole
role?: PersonnelCertRole,
personnelType?: string | null
): string {
if (personnelType) {
return hasPersonnelMasterCertMissing(certList, personnelType)
? PL_PERSONNEL_CERT_MISSING_SUFFIX
: ''
}
if (role) {
return hasPersonnelCertMissing(role, certList) ? PL_PERSONNEL_CERT_MISSING_SUFFIX : ''
}
+80 -14
View File
@@ -21,6 +21,12 @@ export const PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = [
'罐检报告'
] as const
export const PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
export const PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
export type PlVehicleCertCheckType = 'self' | 'carrier'
export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'行驶证',
'营运证',
@@ -70,18 +76,40 @@ export function mergeVehicleCertsByPart<T extends CertRow>(
return [...normalizedHead, ...normalizedTrailer]
}
export function isVehicleCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
export function isVehicleCertUploaded(cert?: Pick<CertRow, 'certName' | 'certNo' | 'expireDate' | 'attachmentUrls'>) {
return Boolean(
cert?.certName?.trim() &&
cert?.certNo?.trim() &&
cert?.expireDate &&
cert?.attachmentUrls?.trim()
)
}
export function getMissingSelfVehiclePartCertNames(
export function getVehicleHeadRequiredCertNames(
vehicleType: PlVehicleCertCheckType
): readonly string[] {
return vehicleType === 'carrier'
? PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
}
export function getVehicleTrailerRequiredCertNames(
vehicleType: PlVehicleCertCheckType
): readonly string[] {
return vehicleType === 'carrier'
? PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
}
export function getMissingVehiclePartCertNames(
certList: CertRow[] = [],
part: VehicleCertPart
part: VehicleCertPart,
vehicleType: PlVehicleCertCheckType
): string[] {
const requiredNames =
part === PL_VEHICLE_CERT_PART_TRAILER
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
? getVehicleTrailerRequiredCertNames(vehicleType)
: getVehicleHeadRequiredCertNames(vehicleType)
return requiredNames.filter(requiredName => {
const matched = certList.find(cert => cert.certName?.trim() === requiredName)
@@ -89,39 +117,77 @@ export function getMissingSelfVehiclePartCertNames(
})
}
export function hasSelfVehicleCertMissing(options: {
export function getMissingSelfVehiclePartCertNames(
certList: CertRow[] = [],
part: VehicleCertPart
): string[] {
return getMissingVehiclePartCertNames(certList, part, 'self')
}
export function hasVehicleCertMissing(options: {
vehicleType: PlVehicleCertCheckType
certList?: CertRow[]
trailerNo?: string | null
}): boolean {
const { certList = [], trailerNo } = options
const { vehicleType, certList = [], trailerNo } = options
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList)
const hasTrailer = Boolean(trailerNo?.trim())
if (getMissingSelfVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD).length > 0) {
if (
getMissingVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD, vehicleType).length > 0
) {
return true
}
if (hasTrailer && getMissingSelfVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER).length > 0) {
if (
hasTrailer &&
getMissingVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER, vehicleType)
.length > 0
) {
return true
}
return false
}
export function hasSelfVehicleCertMissing(options: {
certList?: CertRow[]
trailerNo?: string | null
}): boolean {
return hasVehicleCertMissing({ ...options, vehicleType: 'self' })
}
export function getVehicleCertMissingSuffix(options: {
vehicleType: PlVehicleCertCheckType
certList?: CertRow[]
trailerNo?: string | null
}): string {
return hasVehicleCertMissing(options) ? PL_VEHICLE_CERT_MISSING_SUFFIX : ''
}
export function getSelfVehicleCertMissingSuffix(options: {
certList?: CertRow[]
trailerNo?: string | null
}): string {
return hasSelfVehicleCertMissing(options) ? PL_VEHICLE_CERT_MISSING_SUFFIX : ''
return getVehicleCertMissingSuffix({ ...options, vehicleType: 'self' })
}
export function getVehiclePartCertMissingSuffix(
certList: CertRow[] = [],
part: VehicleCertPart,
vehicleType?: PlVehicleCertCheckType | null
): string {
if (!vehicleType) return ''
return getMissingVehiclePartCertNames(certList, part, vehicleType).length > 0
? PL_VEHICLE_CERT_MISSING_SUFFIX
: ''
}
export function getSelfVehiclePartCertMissingSuffix(
certList: CertRow[] = [],
part: VehicleCertPart
): string {
return getMissingSelfVehiclePartCertNames(certList, part).length > 0
? PL_VEHICLE_CERT_MISSING_SUFFIX
: ''
return getVehiclePartCertMissingSuffix(certList, part, 'self')
}
/** @deprecated 保存不再阻断证书完整性,仅校验证书名称非空等基础项 */
+14 -26
View File
@@ -426,6 +426,7 @@ const formData = reactive({
const createQualificationRules = {
businessLicenseUrls: [
{
required: true,
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || !String(value).trim()) {
callback(new Error('请上传营业执照'))
@@ -438,6 +439,7 @@ const createQualificationRules = {
],
roadTransportLicenseUrls: [
{
required: true,
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || !String(value).trim()) {
callback(new Error('请上传道路经营许可证'))
@@ -450,6 +452,7 @@ const createQualificationRules = {
],
transportAgreementUrls: [
{
required: true,
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || !String(value).trim()) {
callback(new Error('请上传运输协议'))
@@ -462,10 +465,12 @@ const createQualificationRules = {
]
}
const formRules = computed(() => ({
const formRules = {
carrierName: [{ required: true, message: '请输入承运商名称', trigger: 'blur' }],
...(isEdit.value ? {} : createQualificationRules)
}))
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
...createQualificationRules
}
function handleCertFieldChange(
field: 'businessLicenseUrls' | 'roadTransportLicenseUrls' | 'transportAgreementUrls'
@@ -658,29 +663,12 @@ async function handleSubmit() {
contactPhone: formData.contactPhone,
status: formData.status,
remark: formData.remark,
...(isEdit.value
? {
...(formData.businessLicenseUrls
? { businessLicenseUrls: formData.businessLicenseUrls }
: {}),
...(formData.roadTransportLicenseUrls
? { roadTransportLicenseUrls: formData.roadTransportLicenseUrls }
: {}),
...(formData.transportAgreementUrls
? { transportAgreementUrls: formData.transportAgreementUrls }
: {}),
...(formData.transportAgreementExpireDate
? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
: {})
}
: {
businessLicenseUrls: formData.businessLicenseUrls,
roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
transportAgreementUrls: formData.transportAgreementUrls,
...(formData.transportAgreementExpireDate
? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
: {})
})
businessLicenseUrls: formData.businessLicenseUrls,
roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
transportAgreementUrls: formData.transportAgreementUrls,
...(formData.transportAgreementExpireDate
? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
: {})
}
if (isEdit.value) {
await carrierApi.update(formData.id, payload)
+145 -17
View File
@@ -24,6 +24,16 @@
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="人员归属">
<el-select v-model="searchForm.personnelAffiliation" placeholder="全部" clearable>
<el-option
v-for="option in PL_PERSONNEL_AFFILIATION_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
@@ -112,6 +122,32 @@
<el-link type="primary" @click="handleView(row)">{{ row.personnelName }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('personnelAffiliation')"
label="人员归属"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="getPersonnelAffiliationTagType(row.personnelAffiliation)" size="small">
{{ getPersonnelAffiliationLabel(row.personnelAffiliation) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('personnelType')"
label="人员类型"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="getPersonnelTypeTagType(row.personnelType)" size="small">
{{ getPersonnelTypeLabel(row.personnelType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('phoneNo')"
prop="phoneNo"
@@ -224,6 +260,26 @@
<el-form-item label="身份证号" prop="idCardNo">
<el-input v-model="formData.idCardNo" placeholder="请输入身份证号" maxlength="32" />
</el-form-item>
<el-form-item label="人员归属" prop="personnelAffiliation">
<el-select v-model="formData.personnelAffiliation" placeholder="请选择人员归属">
<el-option
v-for="option in PL_PERSONNEL_AFFILIATION_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="人员类型" prop="personnelType">
<el-select v-model="formData.personnelType" placeholder="请选择人员类型">
<el-option
v-for="option in PL_PERSONNEL_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择">
<el-option label="在职" value="1" />
@@ -248,6 +304,7 @@
title="人员证书"
upload-folder="pl/personnel-cert"
:cert-name-options="certNameOptions"
:required-cert-names="requiredPersonnelCertNames"
layout="table"
/>
</el-form>
@@ -311,7 +368,21 @@ import { pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
import {
collectRequiredCertSlotErrors,
formatRequiredCertErrors,
mergeCertListWithRequiredSlots
} from '@/utils/pl-cert-slots'
import {
getPersonnelCertMissingSuffix,
getRequiredPersonnelCertNamesByType,
getPersonnelTypeLabel,
getPersonnelTypeTagType,
getPersonnelAffiliationLabel,
getPersonnelAffiliationTagType,
PL_PERSONNEL_AFFILIATION_OPTIONS,
PL_PERSONNEL_TYPE_OPTIONS
} from '@/utils/pl-personnel-cert'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlCertFormList>
@@ -319,6 +390,8 @@ type CertFormListRef = InstanceType<typeof PlCertFormList>
const loading = ref(false)
const listColumnDefs = [
{ prop: 'personnelAffiliation', label: '人员归属' },
{ prop: 'personnelType', label: '人员类型' },
{ prop: 'phoneNo', label: '手机号' },
{ prop: 'age', label: '年龄' },
{ prop: 'idCardNo', label: '身份证号' },
@@ -359,7 +432,8 @@ const certNameOptions = [
const searchForm = reactive({
q: '',
status: ''
status: '',
personnelAffiliation: ''
})
const pagination = reactive({
@@ -374,12 +448,20 @@ const formData = reactive({
phoneNo: '',
age: null as number | null,
idCardNo: '',
personnelType: '',
personnelAffiliation: 'self',
status: '1',
remark: ''
})
const requiredPersonnelCertNames = computed(() =>
getRequiredPersonnelCertNamesByType(formData.personnelType)
)
const formRules = {
personnelName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
personnelAffiliation: [{ required: true, message: '请选择人员归属', trigger: 'change' }],
personnelType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
@@ -410,7 +492,7 @@ const viewHeroMeta = computed(() => {
const viewDialogTitle = computed(() => {
const name = viewDetail.value?.personnelName || '人员详情'
if (!viewDetail.value) return name
return name + getPersonnelCertMissingSuffix(viewDetail.value.certList || [])
return name + getPersonnelCertMissingSuffix(viewDetail.value.certList || [], undefined, viewDetail.value.personnelType)
})
const viewSections = computed<ViewSection[]>(() => {
@@ -423,6 +505,16 @@ const viewSections = computed<ViewSection[]>(() => {
fields: [
{ label: '姓名', value: d.personnelName },
{ label: '手机号', value: d.phoneNo || '—' },
{
label: '人员归属',
value: getPersonnelAffiliationLabel(d.personnelAffiliation),
tagType: getPersonnelAffiliationTagType(d.personnelAffiliation)
},
{
label: '人员类型',
value: getPersonnelTypeLabel(d.personnelType),
tagType: getPersonnelTypeTagType(d.personnelType)
},
{ label: '年龄', value: d.age ?? '—' },
{ label: '身份证号', value: maskIdCard(d.idCardNo) },
{
@@ -443,7 +535,7 @@ const viewSections = computed<ViewSection[]>(() => {
tableMode: 'triple'
},
{
title: `人员证书${viewDetail.value ? getPersonnelCertMissingSuffix(viewDetail.value.certList || []) : ''}`,
title: `人员证书${viewDetail.value ? getPersonnelCertMissingSuffix(viewDetail.value.certList || [], undefined, viewDetail.value.personnelType) : ''}`,
fields: [],
slot: 'certs'
}
@@ -451,15 +543,39 @@ const viewSections = computed<ViewSection[]>(() => {
})
function validateCerts(): boolean {
if (!formData.personnelType) {
ElMessage.warning('请选择人员类型')
return false
}
const errors = collectRequiredCertSlotErrors(
certList.value,
requiredPersonnelCertNames.value
)
if (errors.length) {
ElMessage.warning(formatRequiredCertErrors(errors))
return false
}
for (const cert of certList.value) {
if (!cert.certName?.trim()) {
ElMessage.warning('证书名称不能为空')
if (!cert._required && !cert.certName?.trim()) {
ElMessage.warning('其他证书名称不能为空')
return false
}
}
return true
}
watch(
() => formData.personnelType,
() => {
if (!formData.personnelType) return
certList.value = mergeCertListWithRequiredSlots(
certList.value,
getRequiredPersonnelCertNamesByType(formData.personnelType)
)
certFormListRef.value?.refreshRequiredSlots?.()
}
)
function resetCertList() {
certList.value = []
certFormListRef.value?.clearUploadRefs()
@@ -471,6 +587,7 @@ async function fetchData() {
const res = await personnelApi.list({
q: searchForm.q || undefined,
status: searchForm.status || undefined,
personnelAffiliation: searchForm.personnelAffiliation || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
@@ -492,6 +609,7 @@ function handleSearch() {
function handleReset() {
searchForm.q = ''
searchForm.status = ''
searchForm.personnelAffiliation = ''
selectedRows.value = []
pagination.page = 1
fetchData()
@@ -548,6 +666,8 @@ function handleAdd() {
phoneNo: '',
age: null,
idCardNo: '',
personnelType: '',
personnelAffiliation: 'self',
status: '1',
remark: ''
})
@@ -583,19 +703,24 @@ async function handleEdit(row: Personnel) {
phoneNo: detail.phoneNo || '',
age: detail.age ?? null,
idCardNo: detail.idCardNo || '',
personnelType: detail.personnelType || 'both',
personnelAffiliation: detail.personnelAffiliation || 'self',
status: detail.status,
remark: detail.remark || ''
})
certList.value = (detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
certFormListRef.value?.clearUploadRefs()
certList.value = mergeCertListWithRequiredSlots(
(detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
})),
getRequiredPersonnelCertNamesByType(detail.personnelType || 'both')
)
certFormListRef.value?.refreshRequiredSlots?.()
} catch {
ElMessage.error('加载人员详情失败')
}
@@ -616,6 +741,8 @@ async function handleSubmit() {
phoneNo: formData.phoneNo || undefined,
age: formData.age ?? undefined,
idCardNo: formData.idCardNo || undefined,
personnelType: formData.personnelType,
personnelAffiliation: formData.personnelAffiliation,
status: formData.status,
remark: formData.remark || undefined,
certList: certList.value.map(c => ({
@@ -664,7 +791,8 @@ async function handleDelete(row: Personnel) {
useDbInstantSearch(
() => ({
q: searchForm.q,
status: searchForm.status
status: searchForm.status,
personnelAffiliation: searchForm.personnelAffiliation
}),
handleSearch
)
+66 -34
View File
@@ -148,36 +148,6 @@
min-width="120"
align="center"
/>
<el-table-column
v-if="isColumnVisible('planType')"
prop="planType"
label="单据类型"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getPlanTypeTagType(row.planType)" size="small">
{{ getPlanTypeLabel(row.planType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('customerName')"
prop="customerName"
label="客户名称"
min-width="180"
show-overflow-tooltip
/>
<el-table-column v-if="isColumnVisible('products')" label="产品信息" min-width="240">
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" />
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('planAttachments')" label="计划附件" min-width="200">
<template #default="{ row }">
<PlPlanListCertCell :products="row.products" compact hide-product />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('archiveStatus')"
prop="archiveStatus"
@@ -217,6 +187,36 @@
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('planType')"
prop="planType"
label="单据类型"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getPlanTypeTagType(row.planType)" size="small">
{{ getPlanTypeLabel(row.planType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('customerName')"
prop="customerName"
label="客户名称"
min-width="180"
show-overflow-tooltip
/>
<el-table-column v-if="isColumnVisible('products')" label="产品信息" min-width="240">
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" />
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('planAttachments')" label="计划附件" min-width="200">
<template #default="{ row }">
<PlPlanListCertCell :products="row.products" compact hide-product />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('licensePlate')"
prop="licensePlate"
@@ -313,6 +313,11 @@ import {
Search
} from '@element-plus/icons-vue'
import { planApi, type Plan } from '@/api/pl'
import {
buildMissingHazardCertReadyMessage,
enrichPlanProductsHazardFromCrm
} from '@/composables/usePlToxicProductCertCheck'
import { pickBxData } from '@/utils/http/bx'
import PlPlanArchiveDialog from '@/components/business/pl-plan-archive-dialog/index.vue'
import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
@@ -347,13 +352,13 @@ const planList = ref<Plan[]>([])
const listColumnDefs = [
{ prop: 'planDate', label: '计划日期' },
{ prop: 'archiveStatus', label: '归档状态' },
{ prop: 'followStatus', label: '跟进状态' },
{ prop: 'transportStatus', label: '运输状态' },
{ prop: 'planType', label: '单据类型' },
{ prop: 'customerName', label: '客户名称' },
{ prop: 'products', label: '产品信息' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'archiveStatus', label: '归档状态' },
{ prop: 'followStatus', label: '跟进状态' },
{ prop: 'transportStatus', label: '运输状态' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'personnelSummary', label: '人员' },
{ prop: 'followUserName', label: '跟进人', defaultVisible: false }
@@ -530,7 +535,34 @@ function handleEdit(row: Plan) {
formDialogVisible.value = true
}
function handleArchive(row: Plan) {
async function handleArchive(row: Plan) {
if (row.archiveStatus === 'archived') {
archivePlanId.value = row.id
archiveDialogVisible.value = true
return
}
try {
const res = await planApi.get(row.id)
const data = pickBxData<Plan>(res)
if (!data?.id) {
throw new Error('运输计划数据无效')
}
const rawProducts = Array.isArray(data.products) ? data.products : []
const enrichedProducts = await enrichPlanProductsHazardFromCrm(rawProducts)
const certMessage = buildMissingHazardCertReadyMessage(data, enrichedProducts)
if (certMessage) {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法归档', {
type: 'warning'
})
return
}
} catch (error) {
const message = error instanceof Error ? error.message : '加载运输计划失败'
ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
return
}
archivePlanId.value = row.id
archiveDialogVisible.value = true
}
+1 -1
View File
@@ -197,7 +197,7 @@
align="center"
>
<template #default="{ row }">
<el-tag :type="row.transportStatus === 'arranged' ? 'success' : 'info'" size="small">
<el-tag :type="row.transportStatus === 'arranged' ? 'success' : 'danger'" size="small">
{{ row.transportStatus === 'arranged' ? '已安排' : '待安排' }}
</el-tag>
</template>
+47 -22
View File
@@ -316,6 +316,7 @@
upload-folder="pl/vehicle-cert"
:cert-name-options="certNameOptions"
layout="table"
enforce-self-vehicle-required
/>
</el-form>
<template #footer>
@@ -362,7 +363,8 @@
:trailer-no="viewDetail?.trailerNo"
:vehicle-id="viewDetail?.id"
:personnel-list="viewPersonnelList"
check-self-vehicle-certs
vehicle-cert-check-type="self"
check-linked-personnel-certs
cert-layout="table"
/>
</template>
@@ -403,12 +405,16 @@ import {
getPlVehicleStatusTagType,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import {
collectSelfVehicleRequiredCertErrors,
ensureSelfVehicleCertSlots,
formatRequiredCertErrors
} from '@/utils/pl-cert-slots'
import {
PL_VEHICLE_CERT_NAME_OPTIONS,
splitVehicleCertsByPart,
getSelfVehicleCertMissingSuffix,
validateVehicleCertNamesNotEmpty
getSelfVehicleCertMissingSuffix
} from '@/utils/pl-vehicle-cert'
import { validatePersonnelTypeRoleCompat, validatePersonnelAffiliationVehicleCompat } from '@/utils/pl-personnel-cert'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
const VEHICLE_TYPE_SELF = 'self'
@@ -628,7 +634,7 @@ function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
}
async function loadPersonnelOptions() {
const res = await personnelApi.list({ status: '1', pageSize: 100 })
const res = await personnelApi.list({ status: '1', pageSize: 100, personnelAffiliation: 'self' })
personnelOptions.value = pickBxList<Personnel>(res)
}
@@ -682,14 +688,29 @@ function validateSubTables(): boolean {
ElMessage.warning('请选择人员类型')
return false
}
const person = personnelOptions.value.find(p => p.id === row.personnelId)
if (
!validatePersonnelTypeRoleCompat(person?.personnelType, row.role, message =>
ElMessage.warning(message)
)
) {
return false
}
if (
!validatePersonnelAffiliationVehicleCompat(person?.personnelAffiliation, 'self', message =>
ElMessage.warning(message)
)
) {
return false
}
}
const hasTrailer = Boolean(formData.trailerNo?.trim())
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList.value)
return validateVehicleCertNamesNotEmpty(
[headCerts, ...(hasTrailer ? [trailerCerts] : [])],
message => ElMessage.warning(message)
)
const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
if (certErrors.length) {
ElMessage.warning(formatRequiredCertErrors(certErrors))
return false
}
return true
}
async function fetchData() {
@@ -794,6 +815,7 @@ function handleAdd() {
remark: ''
})
resetSubTables()
certList.value = ensureSelfVehicleCertSlots([], formData.trailerNo)
loadPersonnelOptions()
dialogVisible.value = true
}
@@ -845,17 +867,20 @@ async function handleEdit(row: Vehicle) {
age: p.age ?? null
}))
)
certList.value = (detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
certFormListRef.value?.clearUploadRefs()
certList.value = ensureSelfVehicleCertSlots(
(detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
})),
formData.trailerNo
)
certFormListRef.value?.refreshRequiredSlots?.()
} catch {
dialogVisible.value = false
ElMessage.error('加载车辆详情失败')
+91 -15
View File
@@ -335,6 +335,8 @@
upload-folder="pl/vehicle-cert"
:cert-name-options="certNameOptions"
layout="table"
:enforce-self-vehicle-required="formData.vehicleType === 'self'"
:enforce-carrier-vehicle-required="formData.vehicleType === 'carrier'"
/>
</el-form>
<template #footer>
@@ -381,7 +383,10 @@
:trailer-no="viewDetail?.trailerNo"
:vehicle-id="viewDetail?.id"
:personnel-list="viewPersonnelList"
:check-self-vehicle-certs="viewDetail?.vehicleType === 'self'"
:vehicle-cert-check-type="viewVehicleCertCheckType"
:check-linked-personnel-certs="
viewDetail?.vehicleType === 'self' || viewDetail?.vehicleType === 'carrier'
"
cert-layout="table"
/>
</template>
@@ -416,12 +421,18 @@ import { pickBxList, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
import { formatPlVehiclePlateDisplay, normalizePlPersonnelSummaryDisplay } from '@/utils/pl-display'
import {
collectCarrierVehicleRequiredCertErrors,
collectSelfVehicleRequiredCertErrors,
ensureVehicleCertSlots,
formatRequiredCertErrors
} from '@/utils/pl-cert-slots'
import {
PL_VEHICLE_CERT_NAME_OPTIONS,
splitVehicleCertsByPart,
getSelfVehicleCertMissingSuffix,
validateVehicleCertNamesNotEmpty
getVehicleCertMissingSuffix,
type PlVehicleCertCheckType
} from '@/utils/pl-vehicle-cert'
import { validatePersonnelTypeRoleCompat, validatePersonnelAffiliationVehicleCompat } from '@/utils/pl-personnel-cert'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
type CertListItem = CertRow & { _key?: string }
@@ -534,12 +545,19 @@ const viewHeroMeta = computed(() => {
return parts.join(' · ')
})
const viewVehicleCertCheckType = computed<PlVehicleCertCheckType | undefined>(() => {
const type = viewDetail.value?.vehicleType
return type === 'self' || type === 'carrier' ? type : undefined
})
const viewDialogTitle = computed(() => {
const plate = viewDetail.value?.licensePlate || '车辆详情'
if (!viewDetail.value || viewDetail.value.vehicleType !== 'self') return plate
const checkType = viewVehicleCertCheckType.value
if (!viewDetail.value || !checkType) return plate
return (
plate +
getSelfVehicleCertMissingSuffix({
getVehicleCertMissingSuffix({
vehicleType: checkType,
certList: viewDetail.value.certList,
trailerNo: viewDetail.value.trailerNo
})
@@ -682,7 +700,13 @@ function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
}
async function loadPersonnelOptions() {
const res = await personnelApi.list({ status: '1', pageSize: 100 })
const affiliation =
formData.vehicleType === 'carrier' ? 'carrier' : formData.vehicleType === 'self' ? 'self' : undefined
const res = await personnelApi.list({
status: '1',
pageSize: 100,
personnelAffiliation: affiliation
})
personnelOptions.value = pickBxList<Personnel>(res)
}
@@ -736,14 +760,41 @@ function validateSubTables(): boolean {
ElMessage.warning('请选择人员类型')
return false
}
if (formData.vehicleType === 'self' || formData.vehicleType === 'carrier') {
const person = personnelOptions.value.find(p => p.id === row.personnelId)
if (
!validatePersonnelTypeRoleCompat(person?.personnelType, row.role, message =>
ElMessage.warning(message)
)
) {
return false
}
if (
!validatePersonnelAffiliationVehicleCompat(
person?.personnelAffiliation,
formData.vehicleType,
message => ElMessage.warning(message)
)
) {
return false
}
}
}
const hasTrailer = Boolean(formData.trailerNo?.trim())
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList.value)
return validateVehicleCertNamesNotEmpty(
[headCerts, ...(hasTrailer ? [trailerCerts] : [])],
message => ElMessage.warning(message)
)
if (formData.vehicleType === 'self') {
const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
if (certErrors.length) {
ElMessage.warning(formatRequiredCertErrors(certErrors))
return false
}
} else if (formData.vehicleType === 'carrier') {
const certErrors = collectCarrierVehicleRequiredCertErrors(certList.value, formData.trailerNo)
if (certErrors.length) {
ElMessage.warning(formatRequiredCertErrors(certErrors))
return false
}
}
return true
}
async function fetchData() {
@@ -850,6 +901,7 @@ function handleAdd() {
remark: ''
})
resetSubTables()
certList.value = ensureVehicleCertSlots([], formData.trailerNo, formData.vehicleType as PlVehicleCertCheckType)
loadPersonnelOptions()
dialogVisible.value = true
}
@@ -898,7 +950,7 @@ async function handleEdit(row: Vehicle) {
age: p.age ?? null
}))
)
certList.value = (detail.certList || []).map(c => ({
const mappedCerts = (detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
@@ -908,7 +960,15 @@ async function handleEdit(row: Vehicle) {
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
certFormListRef.value?.clearUploadRefs()
certList.value =
detail.vehicleType === 'self' || detail.vehicleType === 'carrier'
? ensureVehicleCertSlots(
mappedCerts,
formData.trailerNo,
detail.vehicleType as PlVehicleCertCheckType
)
: mappedCerts
certFormListRef.value?.refreshRequiredSlots?.()
} finally {
dialogLoading.value = false
}
@@ -993,6 +1053,22 @@ async function handleDelete(row: Vehicle) {
fetchData()
}
watch(
() => formData.vehicleType,
async type => {
if (!dialogVisible.value) return
await loadPersonnelOptions()
if (type === 'self' || type === 'carrier') {
certList.value = ensureVehicleCertSlots(
certList.value,
formData.trailerNo,
type as PlVehicleCertCheckType
)
certFormListRef.value?.refreshRequiredSlots?.()
}
}
)
useDbInstantSearch(
() => ({
q: searchForm.q,