feat(pl): 实现合同产品用证与额度结转以支持购买证和运输证全流程管理。
This commit is contained in:
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) || '—'
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user