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

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

No files matched your search

+5
View File
@@ -110,6 +110,7 @@
const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list']
const panelTitleBarClass = 'title-bar-heading'
const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证)$/]
const panelTitleHeadingSelector =
'.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2'
@@ -122,6 +123,10 @@
if (!preferPanelIcon) return
if (panelTitleBarTextRules.some((rule) => rule.test(text))) {
heading.classList.add(panelTitleBarClass)
}
if (heading.classList.contains(panelTitleBarClass)) return
heading.classList.remove(panelTitleBarClass)
+11
View File
@@ -1,3 +1,4 @@
import type { QuotaConfiguration, ManualQuotaQuantity } from './quota-cert'
/**
* 运输计划管理 API
*/
@@ -858,6 +859,13 @@ export interface PlanArchiveFeedbacksBlock {
}
export interface PlanArchiveBody {
operationId?: string
quotaAllowSupplement?: boolean
quotaConfiguration?: QuotaConfiguration
quotaManualActuals?: ManualQuotaQuantity[]
quotaReason?: string
quotaEvidenceUrls?: string
quotaSourceNote?: string
products?: PlanArchiveProductItem[]
load?: PlanArchiveLoadBlock
deliveries?: PlanArchiveDeliveryBlock[]
@@ -1165,6 +1173,9 @@ export interface TransportStopDibangOption {
}
export interface TransportArrange {
operationId?: string
quotaConfiguration?: QuotaConfiguration
quotaManualActuals?: ManualQuotaQuantity[]
vehicleId: string
driverId?: string
escortId?: string
+270
View File
@@ -0,0 +1,270 @@
import request, { pickBxData } from '@/utils/http/bx'
export type CertType = 'purchase' | 'transport'
export interface PurchaseReference {
id?: string
referenceType: 'purchase_permit' | 'purchase_filing' | 'export_permit' | 'unknown'
referenceNo: string
purchaseCertId?: string | null
remark?: string | null
orderNum?: number | null
}
export interface CertBody {
certNo: string
issueDate?: string | null
productNameOnCert: string
totalQty: string | number
quantityUnit: string
validFrom?: string | null
validTo?: string | null
validUseText: string
entryMode: 'manual' | 'image_parse'
attachmentUrls?: string | null
status?: 'draft' | 'suspended' | 'void'
remark?: string | null
orderNum?: number | null
buyerName?: string | null
buyerAddress?: string | null
buyerLegalRepresentative?: string | null
buyerPhone?: string | null
sellerName?: string | null
sellerAddress?: string | null
sellerLegalRepresentative?: string | null
sellerPhone?: string | null
purchasePurpose?: string | null
shipperName?: string | null
shipperAddress?: string | null
shipperLegalRepresentative?: string | null
shipperPhone?: string | null
packagingText?: string | null
carrierName?: string | null
carrierAddressPhone?: string | null
transportMode?: string | null
vehicleNumbersJson?: string[]
routeText?: string | null
consigneeName?: string | null
applicantName?: string | null
purchaseRefs?: PurchaseReference[]
}
export interface QuotaCert extends Omit<CertBody, 'status'> {
id: string
status: 'draft' | 'active' | 'suspended' | 'void'
revision: number
reservedQty: string | number
usedQty: string | number
availableQty: string | number
verifiedTime?: string | null
openingConfirmed: boolean
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
createTime?: string
}
export interface ScopeProduct {
id?: string
contractProductId: string
scopeStatus?: 'active' | 'inactive'
remark?: string | null
orderNum?: number | null
}
export interface CertBinding {
id?: string
certType: CertType
certId: string
linkStatus?: 'active' | 'inactive'
products: ScopeProduct[]
scopeRevision?: number
remark?: string | null
orderNum?: number | null
}
export interface ContractOption {
id: string
contractNo: string
contractName: string
contractType?: string
}
export interface ContractProduct {
id: string
productId: string
productName: string
model?: string
unit?: string
quantity?: string | number
}
export interface LegalDocument {
id: string
planNo: string
archiveStatus: string
transportStatus: string
products: {
id: string
productId: string
productName: string
model?: string
legalUseCertUrl: string | null
documentStatus: 'uploaded' | 'pending'
contexts?: {
stopType: string
stopProductId: string
partyName?: string
address?: string
contractId?: string
contractNo?: string
contractProductId?: string
}[]
}[]
}
const base = '/transport/quota-certs'
export const quotaCertApi = {
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) =>
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
get: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
save: (kind: CertType, body: CertBody, id?: string) =>
(id ? request.put(`${base}/${kind}/${id}`, body) : request.post(`${base}/${kind}`, body)).then(
pickBxData<QuotaCert>
),
remove: (kind: CertType, id: string) => request.delete(`${base}/${kind}/${id}`),
confirm: (
kind: CertType,
id: string,
body: {
operationId: string
usedQty: string
singleUseConsumed: boolean
reason: string
sourceNote?: string
evidenceUrls?: string
}
) => request.post(`${base}/${kind}/${id}/confirm`, body).then(pickBxData<QuotaCert>),
verify: (kind: CertType, id: string, body: { operationId: string; reason: string }) =>
request.post(`${base}/${kind}/${id}/verify`, body).then(pickBxData<QuotaCert>),
recognize: (kind: CertType, attachmentUrl: string) =>
request.post(`${base}/${kind}/recognize`, { attachmentUrl }, { timeout: 90000 }).then(
pickBxData<{
candidates: Partial<CertBody>
invalidFields: Record<string, string>
requiresConfirmation: boolean
}>
),
contracts: (keyword = '') =>
request.get(`${base}/contracts`, { params: { keyword } }).then(pickBxData<ContractOption[]>),
products: (id: string) =>
request.get(`${base}/contracts/${id}/products`).then(pickBxData<ContractProduct[]>),
bindings: (id: string) =>
request.get(`${base}/contracts/${id}/bindings`).then(pickBxData<CertBinding[]>),
saveBindings: (id: string, bindings: CertBinding[]) =>
request.put(`${base}/contracts/${id}/bindings`, { bindings }).then(pickBxData<CertBinding[]>),
certificateContracts: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/contracts`).then(pickBxData<ContractOption[]>)
}
const legalBase = '/transport/legal-use-documents'
export const legalDocumentApi = {
list: (params: { keyword?: string; status?: string }) =>
request.get(legalBase, { params }).then(pickBxData<LegalDocument[]>),
get: (id: string) => request.get(`${legalBase}/${id}`).then(pickBxData<LegalDocument>),
save: (id: string, products: { id: string; legalUseCertUrl?: string | null }[]) =>
request.patch(`${legalBase}/${id}`, { products }).then(pickBxData<LegalDocument>)
}
export interface QuotaSourceOption {
sourceStopType: 'load' | 'delivery'
sourceStopProductId: string
planProductId: string
productName: string
model?: string
quantity: string | number
unit: string
contractId: string
contractProductId: string
partyName?: string
}
export interface QuotaSourcePair {
sourceStopType: 'load' | 'delivery'
sourceStopProductId: string
relatedStopProductIds: string[]
}
export interface ManualQuotaQuantity {
permitKind: CertType
sourceId: string
permitId: string
sourceQuantity: string | number
permitQuantity: string | number
note: string
}
export interface QuotaCondition {
certType: CertType
certId: string
certRevision: number
sourceFingerprint: string
partiesConfirmed: boolean
routeConfirmed: boolean
note: string
}
export interface QuotaConfiguration {
sourceMapping?: QuotaSourcePair[] | null
purchaseCertIds: string[]
transportCertIds: string[]
manualAllocations: ManualQuotaQuantity[]
conditions: QuotaCondition[]
}
export interface PlanQuota {
planId: string
planNo: string
licensePlate?: string
canSaveConfiguration?: boolean
configuration: Partial<QuotaConfiguration>
sourceOptions: QuotaSourceOption[]
usages: Record<string, any>[]
}
export interface QuotaContext {
sourceFingerprint: string
sources: {
source_stop_type: string
source_stop_product_id: string
quantity: string
unit: string
snapshot: { stops: Record<string, any>[]; vehicle?: string; trailer?: string }
}[]
}
export interface QuotaPreview extends Partial<QuotaContext> {
status: string
reasons?: string[]
diagnostics?: {
certType: CertType
sourceIds: string[]
unit: string
demand: string
availableUpperBound: string | null
gapLowerBound: string | null
reason: string
}[]
purchaseAllocations?: Record<string, any>[]
transportAllocations?: Record<string, any>[]
}
export const planQuotaApi = {
get: (id: string) => request.get(`${base}/plans/${id}/usage`).then(pickBxData<PlanQuota>),
context: (id: string, body: QuotaConfiguration & QuotaPreviewVehicle) =>
request.post(`${base}/plans/${id}/context`, body).then(pickBxData<QuotaContext>),
preview: (id: string, body: QuotaConfiguration & QuotaPreviewVehicle) =>
request.post(`${base}/plans/${id}/preview`, body).then(pickBxData<QuotaPreview>),
save: (id: string, body: QuotaConfiguration) =>
request.put(`${base}/plans/${id}/configuration`, body).then(pickBxData<QuotaConfiguration>)
}
export interface QuotaPreviewVehicle {
targetLicensePlate?: string
targetTrailerNo?: string | null
}
export interface CertTotalAdjustment {
operationId: string
totalQty: string
reason: string
sourceNote: string
evidenceUrls: string
}
export const quotaAdjustmentApi = {
logs: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/logs`).then(pickBxData<Record<string, any>[]>),
total: (kind: CertType, id: string, body: CertTotalAdjustment) =>
request.post(`${base}/${kind}/${id}/total-adjust`, body)
}
@@ -1,5 +1,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<PlQuotaSettlementDialog ref="quotaSettlementDialog" />
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
@@ -9,6 +10,8 @@
class="dialog-form-layout pl-plan-archive-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
:close-on-press-escape="!submitLoading"
:show-close="!submitLoading"
style="backdrop-filter: none"
@close="handleClose"
@closed="handleClosed"
@@ -221,7 +224,7 @@
<template #footer>
<div class="dialog-footer pl-arrange-footer">
<el-button @click="innerVisible = false">
<el-button :disabled="submitLoading" @click="innerVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
@@ -243,6 +246,9 @@
</template>
<script setup lang="ts">
import PlQuotaSettlementDialog from '@/components/business/pl-quota-settlement-dialog/index.vue'
const quotaSettlementDialog = ref<InstanceType<typeof PlQuotaSettlementDialog>>()
let quotaArchiveAttempt = { signature: '', id: '' }
import { Check, Close, Document, Edit } from '@element-plus/icons-vue'
import { fetchProductDetail, type ProductDetail } from '@/api/crm/product'
import {
@@ -271,7 +277,6 @@
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import { type PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { buildMissingHazardCertReadyMessage } from '@/composables/usePlToxicProductCertCheck'
import { applyAliasDisplayMeta, buildProductAliasOptions } from '@/utils/crm-product-brand'
import { useDecimalInputBinding } from '@/composables/useDecimalInput'
import {
@@ -311,6 +316,14 @@
const loading = ref(false)
const submitLoading = ref(false)
let archiveSession = 0
onBeforeUnmount(() => { archiveSession++; quotaSettlementDialog.value?.cancel() })
watch([innerVisible, () => props.planId], () => {
archiveSession++
submitLoading.value = false
quotaSettlementDialog.value?.cancel()
}, { flush: 'sync' })
const draftLoading = ref(false)
const activeTab = ref<'archive' | 'overview'>('archive')
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
@@ -1528,34 +1541,35 @@
return
}
if (!isAdjustMode.value) {
const certMessage = buildMissingHazardCertReadyMessage(plan.value, productRows.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法归档', {
type: 'warning'
})
return
}
}
if (hasFeedbackAttachmentUploading()) {
ElMessage.warning('附件正在上传,请稍候再保存')
return
}
if (submitLoading.value) return
const session = archiveSession, targetId = props.planId
const current = () => session === archiveSession && targetId === props.planId && innerVisible.value
const archiveBody: PlanArchiveBody = JSON.parse(JSON.stringify(buildArchiveBody()))
submitLoading.value = true
try {
await submitArchiveWithOverageConfirm()
if (productRows.value.some(p => Number(p.isToxic) === 1)) {
const value = await quotaSettlementDialog.value?.open(targetId, isAdjustMode.value)
if (!current() || !value) return
Object.assign(archiveBody, value)
}
if (!current()) return
await submitArchiveWithOverageConfirm(targetId, archiveBody, current)
if (!current()) return
ElMessage.success(isAdjustMode.value ? '保存成功' : '归档成功')
tabsRef.value?.reload()
emit('success')
innerVisible.value = false
} catch (e: unknown) {
if ((e as { cancelled?: boolean }).cancelled) return
if (!current() || (e as { cancelled?: boolean }).cancelled) return
const message = e instanceof Error ? e.message : isAdjustMode.value ? '保存失败' : '归档失败'
ElMessage.error(message)
} finally {
submitLoading.value = false
if (session === archiveSession) submitLoading.value = false
}
}
@@ -1574,10 +1588,25 @@
deliveryBlocks.value.forEach((block) => mark(block.products))
}
async function submitArchiveWithOverageConfirm(confirmOverage = false): Promise<void> {
async function submitArchiveWithOverageConfirm(targetId: string, frozenBody: PlanArchiveBody, current: () => boolean, confirmOverage = false): Promise<void> {
try {
await planApi.archive(props.planId, { ...buildArchiveBody(), confirmOverage })
const archiveBody = { ...frozenBody, confirmOverage }
const signature = JSON.stringify({ planId: targetId, archiveBody })
if (signature !== quotaArchiveAttempt.signature) quotaArchiveAttempt = { signature, id: crypto.randomUUID() }
const response = await planApi.archive(targetId, { ...archiveBody, operationId: quotaArchiveAttempt.id })
if (!current()) return
const saved = pickBxData<Plan & { quotaSettlementError?: string }>(response)
if (saved?.quotaSettlementError) {
// 业务异常已作为一次请求提交;补齐条件后允许新尝试,不回放旧异常。
quotaArchiveAttempt = { signature: '', id: '' }
const message = `实际数量已保存,额度结转未完成:${saved.quotaSettlementError}`
await ElMessageBox.alert(message, '用证结转异常', { type: 'warning' })
const error = new Error(message) as Error & { cancelled?: boolean }
error.cancelled = true
throw error
}
} catch (e: unknown) {
if (!current()) return
const overage = extractArchiveLineOverageDetail(e)
if (!overage || confirmOverage) throw e
applyArchiveOverageHighlight(overage.items)
@@ -1587,7 +1616,7 @@
;(cancelled as Error & { cancelled?: boolean }).cancelled = true
throw cancelled
}
await submitArchiveWithOverageConfirm(true)
if (current()) await submitArchiveWithOverageConfirm(targetId, frozenBody, current, true)
}
}
</script>
@@ -1,5 +1,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<PlPlanQuotaDialog ref="quotaDialog" />
<MobilePlPlanForm v-if="isPageVariant" :controller="mobileController">
<template #load>
<el-form label-position="top" label-width="100px" class="pl-dialog-form">
@@ -615,6 +616,9 @@
</template>
<script setup lang="ts">
import PlPlanQuotaDialog from '@/components/business/pl-plan-quota-dialog/index.vue'
const quotaDialog = ref<InstanceType<typeof PlPlanQuotaDialog>>()
let arrangeAttempt = { signature: '', id: '' }
import PlanArrangeSheets from '@/views/m/pl/plan/PlanArrangeSheets.vue'
const arrangeSheetsRef = ref<InstanceType<typeof PlanArrangeSheets> | null>(null)
const mobileSavedPlanId = ref('')
@@ -734,7 +738,6 @@
import { normalizeProductUnitValue } from '@/utils/crm-product-display'
import { formatPlVehiclePlateOptionLabel } from '@/utils/pl-display'
import {
buildMissingHazardCertReadyMessage,
enrichPlanProductsHazardFromCrm
} from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
@@ -2624,14 +2627,10 @@
const rawProducts = Array.isArray(plan.products) ? plan.products : []
const enrichedProducts = await enrichPlanProductsHazardFromCrm(rawProducts)
if (!isPageSubmitCurrent(generation)) return false
const certMessage = buildMissingHazardCertReadyMessage(plan, enrichedProducts)
if (certMessage) {
if (isPageVariant.value) await arrangePresenter({ title: '无法安排运输', message: `${certMessage},请至【危化品证上传】页面操作。` })
else await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法安排运输', {
type: 'warning'
})
return false
}
const quotaConfiguration = enrichedProducts.some(p => Number(p.isToxic) === 1)
? await quotaDialog.value?.open(planId, true, { targetLicensePlate: currentVehiclePlate.value, targetTrailerNo: selectedVehicle.value?.trailerNo || null })
: undefined
if (enrichedProducts.some(p => Number(p.isToxic) === 1) && !quotaConfiguration) return false
const isRearrange = plan.transportStatus === 'arranged'
const dibangSelected = planHasAnyDibangSelected(plan, loadForm.value, deliveryForms.value)
@@ -2674,8 +2673,10 @@
getUnitLabel
})
const signature = JSON.stringify({planId, payload, quotaConfiguration})
if (signature !== arrangeAttempt.signature) arrangeAttempt = { signature, id: crypto.randomUUID() }
try {
await submitPlanArrange(planId, payload, isPageVariant.value ? arrangePresenter : undefined)
await submitPlanArrange(planId, { ...payload, operationId: arrangeAttempt.id, quotaConfiguration }, isPageVariant.value ? arrangePresenter : undefined)
if (!isPageSubmitCurrent(generation)) return false
} catch {
return false
@@ -0,0 +1,639 @@
<template>
<el-dialog
v-model="visible"
:title="`计划用证 · ${plan?.planNo || ''}`"
width="1080px"
class="dialog-form-layout quota-dialog"
align-center
:close-on-click-modal="false"
:close-on-press-escape="!busy"
:show-close="!busy"
@closed="!visible && cancel()"
>
<div v-loading="loading">
<el-alert
type="info"
:closable="false"
:title="
forSupplement
? '仅为超过原预占的实际量选择补充证件。保留原来源与分配顺序,提交归档时重新校验;本次选择不扣额度。'
: '绑定不扣额度。正式安排时按当前合同、证件与来源重新校验并预占;合法使用证明可后补。'
"
/>
<template v-if="plan">
<p>当前车号:{{ plan.licensePlate || '待安排' }}</p>
<p
v-if="!forArrange && !forSupplement && plan.canSaveConfiguration === false"
class="form-tip"
>已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。</p
>
<section class="view-section">
<h3>1. 明确唯一计量侧与装送配对</h3>
<p v-if="!groups.length">当前计划没有易制毒产品,无需购买证、运输证额度。</p>
<div v-for="group in groups" :key="group.id" class="view-section">
<strong>{{ group.name }}</strong>
<el-radio-group
:model-value="sides[group.id]"
:disabled="busy || forSupplement"
@update:model-value="setSide(group.id, $event as string)"
>
<el-radio v-if="group.load.length" value="load">按装货计量</el-radio>
<el-radio v-if="group.delivery.length" value="delivery">按送货计量</el-radio>
</el-radio-group>
<div
v-for="source in group[sides[group.id] as 'load' | 'delivery'] || []"
:key="source.sourceStopProductId"
class="view-section"
>
<p
>{{ source.partyName || '未填写单位' }} · {{ source.quantity }} {{ source.unit }} ·
{{ source.model }}</p
>
<el-select
v-if="opposite(group).length"
v-model="pairs[source.sourceStopProductId]"
multiple
:disabled="busy || forSupplement"
placeholder="勾选对应另一侧产品行(必选)"
style="width: 100%"
@change="invalidate"
>
<el-option
v-for="other in opposite(group)"
:key="other.sourceStopProductId"
:value="other.sourceStopProductId"
:label="`${other.partyName || '未填写单位'} · ${other.quantity} ${other.unit} · ${other.sourceStopProductId.slice(-6)}`"
/>
</el-select>
</div>
</div>
</section>
<section v-if="groups.length" class="view-section">
<h3>2. 选择合同产品适用的证件</h3>
<el-form label-position="top" :disabled="busy"
><div class="form-grid-2">
<el-form-item label="购买证"
><el-select
v-model="config.purchaseCertIds"
multiple
filterable
@change="invalidate"
><el-option
v-for="c in purchases"
:key="c.id"
:value="c.id"
:label="certLabel(c)" /></el-select
></el-form-item>
<el-form-item label="运输证"
><el-select
v-model="config.transportCertIds"
multiple
filterable
@change="invalidate"
><el-option
v-for="c in transports"
:key="c.id"
:value="c.id"
:label="certLabel(c)" /></el-select
></el-form-item> </div
></el-form>
<el-button :loading="busy" @click="loadContext"
><el-icon><Refresh /></el-icon>读取当前来源与核对条件</el-button
>
<template v-if="context">
<div v-for="(source, i) in context.sources" :key="i" class="view-section">
<p
>{{ source.source_stop_type === 'load' ? '装货' : '送货' }}计量:{{
source.quantity
}}
{{ source.unit }};车号 {{ source.snapshot.vehicle || '未填写' }}</p
>
<p v-for="stop in source.snapshot.stops" :key="stop.id"
>{{ stop.supplier_name || stop.customer_name || '未填写单位' }} ·
{{ stop.address || '未填写地址' }}</p
>
</div>
<div v-for="item in selected" :key="item.kind + item.cert.id" class="view-section">
<h4>{{ item.kind === 'purchase' ? '购买证' : '运输证' }} · {{ item.cert.certNo }}</h4>
<p
>{{ item.cert.buyerName || item.cert.shipperName }} →
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
{{ item.cert.validFrom }} 至 {{ item.cert.validTo }};{{
item.cert.validUseText
}}</p
>
<p v-if="item.kind === 'transport'"
>{{ item.cert.vehicleNumbersJson?.join('、') }} ·
{{ item.cert.routeText || '未填写路线' }}</p
>
<el-checkbox
v-model="condition(item.kind, item.cert).partiesConfirmed"
:disabled="busy"
>已核对证面参与方与上述实际装送单位适用</el-checkbox
>
<el-checkbox
v-if="item.kind === 'transport'"
v-model="condition(item.kind, item.cert).routeConfirmed"
:disabled="busy"
>已核对车号、运输路线与装送条件适用</el-checkbox
>
<el-input
v-model="condition(item.kind, item.cert).note"
:disabled="busy"
placeholder="填写核对依据或适用说明(必填)"
/>
</div>
</template>
</section>
<section v-if="groups.length" class="view-section">
<h3>3. 不同单位人工换算(同单位无需填写)</h3>
<p v-if="forSupplement" class="form-tip"
>这里只填写超出原预占部分的来源量及证面量;原预占内的实际量在上一层核对。不得重复计算两部分。</p
>
<div
v-for="(row, i) in config.manualAllocations"
:key="i"
class="view-section form-grid-2"
>
<el-select v-model="row.sourceId" :disabled="busy" placeholder="计量来源"
><el-option
v-for="source in measured"
:key="source.sourceStopProductId"
:value="`${source.sourceStopType}:${source.sourceStopProductId}`"
:label="`${source.productName} · ${source.partyName || ''} · ${source.quantity} ${source.unit}`"
/></el-select>
<el-select
:model-value="`${row.permitKind}:${row.permitId}`"
:disabled="busy"
placeholder="证件"
@update:model-value="selectManualCert(row, $event)"
><el-option
v-for="item in selected"
:key="item.kind + item.cert.id"
:value="`${item.kind}:${item.cert.id}`"
:label="`${item.cert.certNo}(${item.cert.quantityUnit})`"
/></el-select>
<el-input
v-model="row.sourceQuantity"
:disabled="busy"
placeholder="来源数量"
/><el-input
v-model="row.permitQuantity"
:disabled="busy"
placeholder="换算后的证面数量"
/>
<el-input v-model="row.note" :disabled="busy" placeholder="换算依据(必填)" />
<el-button :disabled="busy" @click="config.manualAllocations.splice(i, 1)"
><el-icon><Delete /></el-icon>移除此项</el-button
>
</div>
<el-button
:disabled="busy"
@click="
config.manualAllocations.push({
permitKind: 'purchase',
sourceId: '',
permitId: '',
sourceQuantity: '',
permitQuantity: '',
note: ''
})
"
><el-icon><Plus /></el-icon>添加人工换算</el-button
>
</section>
<section v-if="!forSupplement" class="view-section"
><h3>4. 预览分配</h3>
<el-button :loading="busy" @click="preview"
><el-icon><Search /></el-icon>重新计算</el-button
>
<template v-if="result"
><el-tag
:type="
result.status === 'available' || result.status === 'not_required'
? 'success'
: 'warning'
"
>{{ statusLabels[result.status] || result.status }}</el-tag
><p v-for="reason in result.reasons" :key="reason">{{ reason }}</p>
<div
v-for="row in result.diagnostics || []"
:key="row.certType + row.unit"
class="view-section"
>
<strong
>{{ row.certType === 'purchase' ? '购买证' : '运输证' }} · 需求 {{ row.demand }}
{{ row.unit }}</strong
>
<p v-if="row.availableUpperBound !== null">
适用余额至多 {{ row.availableUpperBound }} {{ row.unit }};缺口至少
{{ row.gapLowerBound }} {{ row.unit }}
</p>
<p>{{ row.reason }}</p>
</div>
<el-table :data="allocations" stripe
><el-table-column prop="kind" label="证种" min-width="90" /><el-table-column
prop="certNo"
label="证号"
min-width="160" /><el-table-column
prop="sourceId"
label="计量来源"
min-width="190" /><el-table-column
prop="sourceQuantity"
label="来源量"
min-width="100" /><el-table-column
prop="permitQuantity"
label="证面量"
min-width="100" /><el-table-column
prop="permitUnit"
label="证面单位"
min-width="100" /><el-table-column
prop="allocationSeq"
label="结转顺序"
min-width="100"
/></el-table>
</template>
<p class="form-tip">预览不占用额度;正式保存前余额变化将重新校验。</p>
</section>
<section v-if="plan.usages.length" class="view-section"
><h3>已有占用与结转</h3
><el-table :data="plan.usages" stripe
><el-table-column prop="certId" label="证件" min-width="180" /><el-table-column
prop="usageStatus"
label="状态"
min-width="120"
><template #default="{ row }"
><el-tag :type="row.usageStatus === 'exception' ? 'danger' : 'info'">{{
usageLabels[row.usageStatus] || row.usageStatus
}}</el-tag></template
></el-table-column
><el-table-column prop="reservedQty" label="预占量" min-width="120" /><el-table-column
prop="usedQty"
label="使用量"
min-width="120" /></el-table
></section>
</template>
</div>
<template #footer
><el-button :disabled="busy" @click="cancel"
><el-icon><Close /></el-icon>取消</el-button
><el-button
type="primary"
:disabled="
loading || !plan || (!forArrange && !forSupplement && plan.canSaveConfiguration === false)
"
:loading="busy"
@click="accept"
><el-icon><Check /></el-icon
>{{
forSupplement ? '确认补充分配候选' : forArrange ? '确认本次用证配置' : '保存配置'
}}</el-button
></template
>
</el-dialog>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { Check, Close, Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
planQuotaApi,
quotaCertApi,
type PlanQuota,
type QuotaConfiguration,
type QuotaContext,
type QuotaPreview,
type QuotaCert,
type CertType,
type QuotaSourceOption,
type QuotaPreviewVehicle,
type ManualQuotaQuantity
} from '@/api/pl/quota-cert'
const visible = ref(false),
loading = ref(false),
busy = ref(false),
forArrange = ref(false),
forSupplement = ref(false)
const plan = ref<PlanQuota>(),
purchases = ref<QuotaCert[]>([]),
transports = ref<QuotaCert[]>([])
const empty = (): QuotaConfiguration => ({
sourceMapping: null,
purchaseCertIds: [],
transportCertIds: [],
manualAllocations: [],
conditions: []
})
const config = ref<QuotaConfiguration>(empty()),
context = ref<QuotaContext>(),
result = ref<QuotaPreview>()
const sides = reactive<Record<string, string>>({}),
pairs = reactive<Record<string, string[]>>({})
let previewVehicle: QuotaPreviewVehicle = {}
let epoch = 0,
resolve: ((value: QuotaConfiguration | undefined) => void) | undefined
const groups = computed(() => {
const values = new Map<
string,
{ id: string; name: string; load: QuotaSourceOption[]; delivery: QuotaSourceOption[] }
>()
for (const row of plan.value?.sourceOptions || []) {
if (!values.has(row.planProductId))
values.set(row.planProductId, {
id: row.planProductId,
name: row.productName,
load: [],
delivery: []
})
values.get(row.planProductId)![row.sourceStopType].push(row)
}
return [...values.values()]
})
const measured = computed(() =>
groups.value.flatMap((g) => g[sides[g.id] as 'load' | 'delivery'] || [])
)
const selected = computed(() => [
...purchases.value
.filter((c) => config.value.purchaseCertIds.includes(c.id))
.map((cert) => ({ kind: 'purchase' as const, cert })),
...transports.value
.filter((c) => config.value.transportCertIds.includes(c.id))
.map((cert) => ({ kind: 'transport' as const, cert }))
])
const usageLabels: Record<string, string> = {
reserved: '已预占',
in_transit: '执行中',
settled: '已核销',
released: '已释放',
exception: '结转异常',
draft: '草稿'
}
const statusLabels: Record<string, string> = {
available: '当前额度可用',
not_required: '无需两证额度',
needs_confirmation: '待核对条件',
insufficient: '额度不足',
calculation_incomplete: '计算未完成',
needs_configuration: '待完善配置'
}
const allocations = computed(() =>
['purchase', 'transport'].flatMap((kind) =>
(
result.value?.[kind === 'purchase' ? 'purchaseAllocations' : 'transportAllocations'] || []
).map((row) => ({
...row,
kind: kind === 'purchase' ? '购买证' : '运输证',
certNo:
selected.value.find((i) => i.kind === kind && i.cert.id === row.certId)?.cert.certNo ||
row.certId
}))
)
)
function certLabel(c: QuotaCert) {
return `${c.certNo} · 可用 ${c.availableQty} ${c.quantityUnit}`
}
function opposite(group: (typeof groups.value)[number]) {
return group[sides[group.id] === 'load' ? 'delivery' : 'load']
}
function invalidate() {
context.value = undefined
result.value = undefined
config.value.conditions = []
}
function setSide(id: string, side: string) {
sides[id] = side
invalidate()
}
function payload() {
return {
...config.value,
sourceMapping: measured.value.map((s) => ({
sourceStopType: s.sourceStopType,
sourceStopProductId: s.sourceStopProductId,
relatedStopProductIds: pairs[s.sourceStopProductId] || []
}))
}
}
function condition(kind: CertType, cert: QuotaCert) {
let c = config.value.conditions.find((i) => i.certType === kind && i.certId === cert.id)
if (!c) {
c = {
certType: kind,
certId: cert.id,
certRevision: cert.revision,
sourceFingerprint: context.value?.sourceFingerprint || '',
partiesConfirmed: false,
routeConfirmed: false,
note: ''
}
config.value.conditions.push(c)
}
return c
}
function selectManualCert(row: ManualQuotaQuantity, value: string) {
const [kind, ...id] = value.split(':')
row.permitKind = kind as CertType
row.permitId = id.join(':')
}
async function loadContext() {
if (!plan.value || busy.value) return
busy.value = true
const current = epoch
try {
const value = await planQuotaApi.context(plan.value.planId, {
...payload(),
...previewVehicle
})
if (current !== epoch) return
context.value = value
config.value.conditions = config.value.conditions.filter(
(c) =>
c.sourceFingerprint === value.sourceFingerprint &&
selected.value.some(
(i) =>
i.kind === c.certType && i.cert.id === c.certId && i.cert.revision === c.certRevision
)
)
selected.value.forEach((i) => condition(i.kind, i.cert))
} finally {
if (current === epoch) busy.value = false
}
}
async function preview() {
if (!plan.value || busy.value) return
busy.value = true
const current = epoch
try {
const value = await planQuotaApi.preview(plan.value.planId, {
...payload(),
...previewVehicle
})
if (current === epoch) result.value = value
} finally {
if (current === epoch) busy.value = false
}
}
async function accept() {
if (!plan.value || busy.value) return
const current = epoch,
id = plan.value.planId
const isCurrent = () => current === epoch && plan.value?.planId === id && visible.value
if (forArrange.value) {
await preview()
if (!isCurrent()) return
if (!['available', 'not_required'].includes(result.value?.status || '')) return
}
const value = payload()
if (forSupplement.value) {
if (
(!value.purchaseCertIds.length && !value.transportCertIds.length) ||
!context.value ||
selected.value.some(({ kind, cert }) => {
const c = value.conditions.find((c) => c.certType === kind && c.certId === cert.id)
return (
!c ||
c.certRevision !== cert.revision ||
c.sourceFingerprint !== context.value?.sourceFingerprint ||
!c.partiesConfirmed ||
(kind === 'transport' && !c.routeConfirmed) ||
!c.note.trim()
)
})
) {
ElMessage.warning('请选择需补量的证件,并读取当前来源后逐证确认参与方、路线及依据')
return
}
}
if (forArrange.value || forSupplement.value) {
resolve?.(value)
resolve = undefined
visible.value = false
return
}
busy.value = true
try {
await planQuotaApi.save(id, value)
if (!isCurrent()) return
ElMessage.success('用证配置已保存')
resolve?.(value)
resolve = undefined
visible.value = false
} finally {
if (current === epoch) busy.value = false
}
}
function cancel() {
if (busy.value) return
epoch++
visible.value = false
resolve?.(undefined)
resolve = undefined
}
async function open(
id: string,
arrange = false,
vehicle: QuotaPreviewVehicle = {},
supplement = false
): Promise<QuotaConfiguration | undefined> {
if (busy.value) return
resolve?.(undefined)
const current = ++epoch
visible.value = true
loading.value = true
plan.value = undefined
context.value = undefined
result.value = undefined
forArrange.value = arrange
forSupplement.value = supplement
previewVehicle = vehicle
Object.keys(sides).forEach((k) => delete sides[k])
Object.keys(pairs).forEach((k) => delete pairs[k])
try {
const [p, b, t] = await Promise.all([
planQuotaApi.get(id),
quotaCertApi.list('purchase'),
quotaCertApi.list('transport')
])
if (current !== epoch) return
plan.value = p
purchases.value = b
transports.value = t
config.value = { ...empty(), ...p.configuration }
if (supplement)
config.value = {
...config.value,
purchaseCertIds: [],
transportCertIds: [],
manualAllocations: [],
conditions: []
}
for (const group of groups.value) {
const old = config.value.sourceMapping?.find((s) =>
group[s.sourceStopType].some((r) => r.sourceStopProductId === s.sourceStopProductId)
)
sides[group.id] =
old?.sourceStopType ||
(group.load.length && !group.delivery.length
? 'load'
: group.delivery.length && !group.load.length
? 'delivery'
: '')
}
for (const row of config.value.sourceMapping || [])
pairs[row.sourceStopProductId] = [...row.relatedStopProductIds]
return await new Promise((r) => {
resolve = r
loading.value = false
})
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => JSON.stringify(config.value.manualAllocations),
() => {
result.value = undefined
}
)
function invalidateSession() {
epoch++
visible.value = false
plan.value = undefined
context.value = undefined
result.value = undefined
config.value = empty()
loading.value = false
busy.value = false
resolve?.(undefined)
resolve = undefined
}
const sessionUser = useUserStore()
watch(
() => JSON.stringify([sessionUser.accessToken, sessionUser.getUserInfo]),
invalidateSession,
{ flush: 'sync' }
)
defineExpose({ open, invalidateSession })
onBeforeUnmount(() => {
epoch++
resolve?.(undefined)
})
</script>
<style scoped>
.quota-dialog :deep(.el-select) {
width: 100%;
}
.view-section {
margin-top: 16px;
}
.view-section h4 {
margin: 12px 0;
}
.view-section .el-checkbox {
white-space: normal;
height: auto;
min-height: 36px;
}
.view-section .el-input {
margin-top: 8px;
}
</style>
@@ -0,0 +1,242 @@
<template>
<PlPlanQuotaDialog ref="supplementDialog" />
<el-dialog
v-model="visible"
title="实际用证量核对"
width="880px"
class="dialog-form-layout"
align-center
:close-on-click-modal="false"
@closed="!visible && cancel()"
>
<p class="form-tip"
>异单位按实际量重新填写,不沿用安排时换算比例。每个来源、每类证件的分配合计必须与本次实际数量一致;0
也须明确填写。</p
>
<p v-if="!rows.length" class="form-tip"
>当前原分配均为同单位,系统按本次各来源实际数量和原顺序结转。</p
>
<el-checkbox v-model="allowSupplement">实际量超出原预占,需要补充分配</el-checkbox>
<template v-if="allowSupplement">
<p class="form-tip"
>下方填写原分配部分的实际来源量和本次实际证面量。超出原来源量的部分在补充候选中填写;仅证面量增加时选择对应原证补差。来源量增加需双证覆盖,系统不沿用换算比例。补充校验失败将保存真实数量及异常,保持原账。</p
>
<el-button :loading="choosing" @click="chooseSupplement"
><el-icon><Plus /></el-icon
>{{ supplementConfiguration ? '重新选择补充证件' : '选择补充证件并核对条件' }}</el-button
>
<p v-if="supplementConfiguration"
>已选购买证 {{ supplementConfiguration.purchaseCertIds.length }} 张、运输证
{{ supplementConfiguration.transportCertIds.length }} 张;提交时重新校验余额和条件。</p
>
</template>
<section v-for="row in rows" :key="row.key" class="view-section">
<h3>{{ row.certNo }} · {{ row.kind === 'purchase' ? '购买证' : '运输证' }}</h3>
<p>{{ row.productName }} · {{ row.sourceLabel }} · 结转顺序 {{ row.seq }}</p>
<p
>安排分配:{{ row.plannedSource }} {{ row.sourceUnit }} → {{ row.plannedPermit }}
{{ row.permitUnit }}</p
>
<el-form label-position="top"
><div class="form-grid-2">
<el-form-item :label="`本证分配的实际来源量(${row.sourceUnit})`"
><el-input v-model="row.value.sourceQuantity" placeholder="包括真实 0"
/></el-form-item>
<el-form-item :label="`实际证面量(${row.permitUnit})`"
><el-input v-model="row.value.permitQuantity" placeholder="包括真实 0"
/></el-form-item>
<el-form-item label="本次换算依据" class="form-col-span-2"
><el-input v-model="row.value.note" type="textarea"
/></el-form-item> </div
></el-form>
</section>
<el-form v-if="adjustment || allowSupplement" label-position="top">
<el-form-item label="实际用量更正/补分配理由(必填)"
><el-input v-model="reason" type="textarea"
/></el-form-item>
<el-form-item label="更正依据说明(与凭据至少填写一项)"
><el-input v-model="sourceNote" type="textarea"
/></el-form-item>
<el-form-item label="更正凭据"
><AttachmentUploadField
ref="attachments"
:key="epoch"
v-model="evidenceUrls"
folder="pl/quota-adjustment"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="cancel"
><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :disabled="choosing" @click="accept"
><el-icon><Check /></el-icon>确认实际用证量</el-button
></template
>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, Plus } from '@element-plus/icons-vue'
import {
planQuotaApi,
type ManualQuotaQuantity,
type QuotaConfiguration
} from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlPlanQuotaDialog from '@/components/business/pl-plan-quota-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
interface Result {
quotaAllowSupplement: boolean
quotaConfiguration?: QuotaConfiguration
quotaManualActuals: ManualQuotaQuantity[]
quotaReason: string
quotaSourceNote: string
quotaEvidenceUrls: string
}
const visible = ref(false),
adjustment = ref(false),
reason = ref(''),
sourceNote = ref(''),
evidenceUrls = ref('')
const attachments = ref<InstanceType<typeof AttachmentUploadField>>()
const supplementDialog = ref<InstanceType<typeof PlPlanQuotaDialog>>()
const allowSupplement = ref(false),
choosing = ref(false),
targetPlanId = ref('')
const supplementConfiguration = ref<QuotaConfiguration>()
const rows = ref<
{
key: string
kind: string
certNo: string
productName: string
sourceLabel: string
sourceUnit: string
permitUnit: string
plannedSource: string
plannedPermit: string
seq: number
value: ManualQuotaQuantity
}[]
>([])
let epoch = 0,
resolve: ((value: Result | undefined) => void) | undefined
async function open(planId: string, adjust = false): Promise<Result | undefined> {
resolve?.(undefined)
const current = ++epoch
targetPlanId.value = planId
allowSupplement.value = false
choosing.value = false
supplementConfiguration.value = undefined
const plan = await planQuotaApi.get(planId)
if (current !== epoch) return
const values = plan.usages
.filter((u) => u.usageStatus !== 'released')
.flatMap((u) =>
(u.items || []).map((i: any) => ({
u,
i,
source: `${i.sourceStopType}:${i.sourceStopProductId}`,
key: `${u.certType}:${i.sourceStopType}:${i.sourceStopProductId}`
}))
)
const foreign = new Set(
values.filter((v) => v.i.sourceUnit !== v.u.quantityUnit).map((v) => v.key)
)
rows.value = values
.filter((v) => foreign.has(v.key))
.map(({ u, i, source }) => ({
key: i.id,
kind: u.certType,
certNo: u.certNoSnapshot,
productName:
plan.sourceOptions.find((s) => s.sourceStopProductId === i.sourceStopProductId)
?.productName || '',
sourceLabel: `${i.sourceStopType === 'load' ? '装货' : '送货'} ${i.sourceStopProductId.slice(-6)}`,
sourceUnit: i.sourceUnit,
permitUnit: u.quantityUnit,
plannedSource: i.allocatedSourceQty,
plannedPermit: i.plannedCertQty,
seq: i.allocationSeq,
value: {
permitKind: u.certType,
permitId: u.certId,
sourceId: source,
sourceQuantity: '',
permitQuantity: '',
note: ''
}
}))
adjustment.value = adjust
reason.value = ''
sourceNote.value = ''
evidenceUrls.value = ''
visible.value = true
return new Promise((r) => (resolve = r))
}
function cancel() {
epoch++
supplementDialog.value?.invalidateSession()
visible.value = false
resolve?.(undefined)
resolve = undefined
}
async function chooseSupplement() {
if (choosing.value) return
const current = epoch
choosing.value = true
try {
const value = await supplementDialog.value?.open(targetPlanId.value, false, {}, true)
if (current === epoch && visible.value && value) supplementConfiguration.value = value
} finally {
if (current === epoch) choosing.value = false
}
}
function accept() {
if (choosing.value) return
if (allowSupplement.value && !supplementConfiguration.value) {
ElMessage.warning('请先选择补充证件并核对条件')
return
}
const decimal = /^\d{1,12}(\.\d{1,6})?$/
if (
rows.value.some(
(r) =>
!decimal.test(String(r.value.sourceQuantity)) ||
!decimal.test(String(r.value.permitQuantity)) ||
!r.value.note.trim()
)
) {
ElMessage.warning('请逐行填写非负实际量和换算依据,最多六位小数')
return
}
if (
(adjustment.value || allowSupplement.value) &&
(!reason.value.trim() || (!sourceNote.value.trim() && !evidenceUrls.value))
) {
ElMessage.warning('请填写更正理由和依据')
return
}
if (attachments.value?.hasUploading()) {
ElMessage.warning('请等待凭据上传完成')
return
}
resolve?.({
quotaAllowSupplement: allowSupplement.value,
quotaConfiguration: allowSupplement.value ? supplementConfiguration.value : undefined,
quotaManualActuals: rows.value.map((r) => ({ ...r.value })),
quotaReason: reason.value,
quotaSourceNote: sourceNote.value,
quotaEvidenceUrls: evidenceUrls.value
})
resolve = undefined
visible.value = false
}
defineExpose({ open, cancel })
const user = useUserStore()
watch(
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => cancel(),
{ flush: 'sync' }
)
onBeforeUnmount(() => cancel())
</script>
@@ -8,6 +8,7 @@ export const MENU_BADGE_CODES = {
PL_VEHICLE_MAINTENANCE_PROCESS: 'pl:vehicle-maintenance-process:list',
PL_VEHICLE_UREA_DISBURSE: 'pl:vehicle-urea-disburse:list',
PL_PLAN_CERT: 'pl:plan-cert:list',
PL_LEGAL_USE_DOCUMENT: 'pl:legal-use-document:list',
PL_PLAN_ARCHIVE: 'pl:plan-archive:list',
PL_PLAN_CONTRACT: 'pl:plan-contract:list',
BX_ADVANCE_APPROVE: 'bx:advanceApprove:list',
@@ -32,6 +33,7 @@ export const PL_MENU_BADGE_CODES = [
MENU_BADGE_CODES.PL_VEHICLE_MAINTENANCE_PROCESS,
MENU_BADGE_CODES.PL_VEHICLE_UREA_DISBURSE,
MENU_BADGE_CODES.PL_PLAN_CERT,
MENU_BADGE_CODES.PL_LEGAL_USE_DOCUMENT,
MENU_BADGE_CODES.PL_PLAN_ARCHIVE,
MENU_BADGE_CODES.PL_PLAN_CONTRACT
] as const
+4 -4
View File
@@ -51,7 +51,7 @@
import MpStructSkeleton from '@/components/mp/MpStructSkeleton.vue'
import { MP_CONTRACT_APPROVE_TABS } from '@/utils/mp-crm-contract-card'
import { MP_QUOTE_APPROVE_TABS, MP_QUOTE_MANAGE_TABS } from '@/utils/mp-crm-quote-card'
import { PL_PLAN_CERT_TABS, PL_PLAN_LIST_TABS } from '@/utils/pl-display'
import { PL_PLAN_LIST_TABS } from '@/utils/pl-display'
import {
beginMpWebviewAuthBoot,
endMpWebviewAuthBoot,
@@ -131,10 +131,10 @@
},
'/m/pl/plan-cert': {
mode: 'list',
title: '危化品证上传',
title: '计划用证',
search: '计划编号 / 供方 / 需方',
tabs: PL_PLAN_CERT_TABS.map((tab) => tab.label),
filters: ['全部日期', '全部齐备状态', '更多'],
tabs: [],
filters: ['用证状态'],
caption: '正在加载计划'
}
}
+17 -3
View File
@@ -59,14 +59,28 @@ export const plRoutes: AppRouteRecord[] = [
id: 'PL_GROUP_CERT',
name: 'PlCertUpload',
path: 'cert-upload',
meta: { title: '危化品证上传', keepAlive: false },
meta: { title: '计划用证', keepAlive: false },
children: [
{
id: 'PL00000000000000000000000000015',
name: 'PlPlanCert',
path: 'plan-cert',
component: '/pl/plan-cert/index',
meta: { title: '危化品证上传', keepAlive: false, menuCode: 'pl:plan-cert:list' }
meta: { title: '计划用证', keepAlive: false, menuCode: 'pl:plan-cert:list' }
},
{
id: 'PL_QUOTA_CERT',
name: 'PlQuotaCert',
path: 'quota-cert',
component: '/pl/quota-cert/index',
meta: { title: '购买证与运输证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_LEGAL_USE_DOCUMENT',
name: 'PlLegalUseDocument',
path: 'legal-use-document',
component: '/pl/legal-use-document/index',
meta: { title: '合法使用证明', keepAlive: false, menuCode: 'pl:legal-use-document:list' }
}
]
},
@@ -225,7 +239,7 @@ export const plRoutes: AppRouteRecord[] = [
name: 'PlPlanCertRedirect',
path: 'plan-cert',
redirect: '/pl/cert-upload/plan-cert',
meta: { title: '危化品证上传', isHide: true }
meta: { title: '计划用证', isHide: true }
},
{
id: 'PL_REDIRECT_FREIGHT',
+8 -2
View File
@@ -141,17 +141,23 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/m/pl/dispatch-calendar/index.vue'),
meta: { title: '派车日历', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/legal-use-document',
name: 'MpPlLegalUseDocument',
component: () => import('@views/m/pl/legal-use-document/index.vue'),
meta: { title: '合法使用证明', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert',
name: 'MpPlPlanCert',
component: () => import('@views/m/pl/plan-cert/index.vue'),
meta: { title: '危化品证上传', isHideTab: true, isMpWebview: true }
meta: { title: '计划用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/:id',
name: 'MpPlPlanCertDetail',
component: () => import('@views/m/pl/plan-cert/detail.vue'),
meta: { title: '危化品证详情', isHideTab: true, isMpWebview: true }
meta: { title: '计划用证详情', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan',
@@ -41,10 +41,9 @@ describe('mp-pl-plan-card-fields', () => {
])
})
it('cert preview keys exclude export/print and keep ready status', () => {
it('cert preview keys exclude retired ready status', () => {
assert.deepEqual([...MP_PLAN_CERT_CARD_PREVIEW_KEYS], [
'deliveryTimes',
'hazardCertReady',
'licensePlate',
'personnelSummary',
'loadCustomer',
@@ -47,7 +47,6 @@ export function buildPlanCardExtraFlatFields(values: Record<string, string>): Mp
/** 危化证列表卡预览键(meta 含日期/类型/公司,Tag 含跟进状态) */
export const MP_PLAN_CERT_CARD_PREVIEW_KEYS = [
'deliveryTimes',
'hazardCertReady',
'licensePlate',
'personnelSummary',
'loadCustomer',
+1
View File
@@ -489,6 +489,7 @@ export function getPlPlanArchiveStatusTagType(status?: string) {
export function getPlPlanArchiveStatusLabel(status?: string) {
if (status === 'cancelled') return '已取消'
if (status === 'exception') return '用证结转异常'
if (status === 'archived') return '已归档'
if (status === 'draft') return '已暂存'
return '未归档'
@@ -595,7 +595,6 @@ export const PL_PLAN_SPAN_PROPS = new Set([
'erpEntered',
'planAttachments',
'carrierName',
'hazardCertReady',
'loadCustomer',
'loadAddressTime',
'loadDibang',
@@ -24,6 +24,13 @@
>{{ dictStore.getLabel('crm_contract_status', detailContract?.status) }}</el-tag>
</div>
<div class="detail-actions">
<el-button
v-if="canViewQuotaCert && detailContract?.id"
class="btn-ghost"
@click="router.push({ name: 'PlQuotaCert', query: { contractId: String(detailContract.id) } })"
>
<el-icon><DocumentChecked /></el-icon>适用证件与产品
</el-button>
<el-button
v-if="manageActions"
class="btn-ghost"
@@ -974,6 +981,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { fetchGetMenuCodes } from '@/api/auth'
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from '../contractType'
import {
displayFrameworkMoney,
@@ -1078,6 +1086,7 @@ import {
const dictStore = useDictStore();
const router = useRouter();
const canViewQuotaCert = ref(false)
const props = withDefaults(
defineProps<{
@@ -3268,6 +3277,8 @@ const handleGeneratePdfFromDialog = async () => {
onMounted(async () => {
await dictStore.loadAllDict();
const perms = await fetchGetMenuCodes()
canViewQuotaCert.value = !!perms.all || !!perms.isSuperAdmin || (perms.menuCodes || []).includes('pl:quota-cert:list')
});
function formatPlanQty(value: unknown) {
@@ -0,0 +1,199 @@
<template>
<MpWebviewShell @ready="init">
<MpBusinessHeader
title="合法使用证明"
searchable
:keyword="keyword"
search-placeholder="输入计划编号"
@update:keyword="keyword = $event"
@search="load"
@back="goToMiniWorkbench"
/>
<main class="mp-h5-list-content">
<van-dropdown-menu
><van-dropdown-item v-model="status" :options="statusOptions" @change="load"
/></van-dropdown-menu>
<p class="form-tip">易制爆产品的合法使用证明可在安排、归档后补传,不影响额度预占。</p>
<p v-if="error" role="alert">{{ error }}</p>
<van-loading v-if="loading" />
<section v-for="row in rows" :key="row.id" class="mp-wv-section"
><h3>{{ row.planNo }}</h3
><p>{{ row.products.map((p) => p.productName).join('、') }}</p
><van-tag
:type="row.products.every((p) => p.documentStatus === 'uploaded') ? 'success' : 'warning'"
>{{ row.products.filter((p) => p.documentStatus === 'pending').length }} 项待补</van-tag
><van-button block plain type="primary" @click="open(row.id)">{{
canUpload ? '维护资料' : '查看资料'
}}</van-button></section
>
<van-empty v-if="!loading && !rows.length && !error" description="暂无符合条件的计划" />
</main>
<van-popup
:show="visible"
position="bottom"
round
teleport="body"
:close-on-click-overlay="!saving"
:style="{ height: '70vh', '--van-primary-color': 'var(--el-color-primary)' }"
class="mp-h5-sheet"
@update:show="close"
>
<van-nav-bar
:title="detail?.planNo || '合法使用证明'"
left-text="关闭"
@click-left="close(false)"
/>
<div class="mp-h5-list-content"
><section
v-for="p in detail?.products || []"
:key="`${session}:${p.id}`"
class="mp-wv-section"
><h3>{{ p.productName }} {{ p.model }}</h3>
<p v-for="context in p.contexts || []" :key="context.stopProductId"
>{{ context.stopType === 'load' ? '装货' : '送货' }}:{{
context.partyName || '未填写单位'
}}
· {{ context.address || '未填写地址' }} · 合同
{{ context.contractNo || '未关联或无查看权限' }}</p
>
<AttachmentUploadField
:ref="(el) => setUpload(p.id, el)"
v-model="p.legalUseCertUrl"
:disabled="!canUpload || saving"
folder="pl/legal-use-document" /></section
><van-button
v-if="canUpload"
block
type="primary"
:loading="saving"
:disabled="saving"
@click="save"
>保存资料</van-button
></div
>
</van-popup>
</MpWebviewShell>
</template>
<script setup lang="ts">
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { legalDocumentApi, type LegalDocument } from '@/api/pl/quota-cert'
import { useUserStore } from '@/store/modules/user'
import { goToMiniWorkbench } from '@/utils/mp-webview'
import '@/styles/mp-webview.scss'
const status = ref('')
const statusOptions = [
{ text: '全部资料', value: '' },
{ text: '待上传', value: 'pending' },
{ text: '已上传', value: 'uploaded' }
]
const keyword = ref(''),
rows = ref<LegalDocument[]>([]),
detail = ref<LegalDocument>(),
visible = ref(false),
canUpload = ref(false),
loading = ref(false),
saving = ref(false),
error = ref(''),
session = ref(0)
const user = useUserStore(),
uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
let shellReady = false
let epoch = 0,
listVersion = 0
function setUpload(id: string, el: any) {
if (el) uploads.set(id, el)
else uploads.delete(id)
}
async function init() {
shellReady = true
const current = epoch
const p = await fetchGetMenuCodes()
if (current !== epoch) return
canUpload.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:plan-cert:upload')
await load()
}
async function load() {
const current = epoch,
version = ++listVersion
loading.value = true
error.value = ''
try {
const value = await legalDocumentApi.list({
keyword: keyword.value,
status: status.value || undefined
})
if (current === epoch && version === listVersion) rows.value = value
} catch {
if (current === epoch && version === listVersion) error.value = '资料加载失败,请重新搜索'
} finally {
if (current === epoch && version === listVersion) loading.value = false
}
}
async function open(id: string) {
if (saving.value) return
const current = ++session.value,
auth = epoch
const value = await legalDocumentApi.get(id)
if (current !== session.value || auth !== epoch) return
detail.value = value
visible.value = true
}
function close(value: boolean) {
if (saving.value) return
visible.value = value
if (!value) {
session.value++
detail.value = undefined
}
}
async function save() {
if (!detail.value || saving.value || !canUpload.value) return
if ([...uploads.values()].some((u) => u.hasUploading())) {
ElMessage.warning('请等待附件上传完成')
return
}
const current = session.value,
auth = epoch,
id = detail.value.id
saving.value = true
try {
await legalDocumentApi.save(
id,
detail.value.products.map((p) => ({ id: p.id, legalUseCertUrl: p.legalUseCertUrl }))
)
if (current !== session.value || auth !== epoch) return
visible.value = false
detail.value = undefined
session.value++
ElMessage.success('合法使用证明已保存')
await load()
} finally {
if (auth === epoch) saving.value = false
}
}
watch(
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
epoch++
session.value++
rows.value = []
detail.value = undefined
visible.value = false
saving.value = false
canUpload.value = false
uploads.clear()
error.value = ''
loading.value = false
if (shellReady && user.accessToken) void init()
},
{ flush: 'sync' }
)
onBeforeUnmount(() => {
epoch++
session.value++
})
</script>
@@ -271,10 +271,6 @@
label: '送货时间',
value: formatMpPlanYmdList(props.plan.deliveryTimes)
},
hazardCertReady: {
label: '证书齐备',
value: isHazardCertReady(props.plan) ? '已齐备' : certGapLabel.value
},
licensePlate: {
label: '车牌号',
value: formatPlVehiclePlateDisplay(props.plan.licensePlate, props.plan.trailerNo) || '—'
+2 -252
View File
@@ -1,254 +1,4 @@
<template>
<MpWebviewShell @ready="init">
<main class="cert-module cert-detail-page mp-h5-detail-page">
<MpBusinessHeader title="危化品证详情" back-label="返回危化品证列表" @back="back" />
<CertDetail
v-if="shellReady && userStore.accessToken"
:key="'detail-' + recordEpoch"
ref="detailRef"
:plan-id="id"
:can-upload="canUpload"
:can-ready="canReady"
:can-price="canPrice"
:busy="acting"
:permission-error="permissionError"
:permissions-loaded="permissionsLoaded"
@view="openDetail"
@upload="openUpload"
@ready="setReady"
@retry-permissions="loadPermissions"
/>
<CertUpload
v-if="shellReady && userStore.accessToken"
:key="'upload-' + recordEpoch"
ref="uploadRef"
v-model="uploadOpen"
:plan-id="id"
:permitted="canUpload"
@success="saved"
/>
</main>
</MpWebviewShell>
</template>
<template><PlanQuotaPage /></template>
<script setup lang="ts">
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
import '@/styles/mp-webview.scss'
import { fetchGetMenuCodes } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import { planApi, type Plan } from '@/api/pl'
import {
HAZARD_CERT_READY_CONFIRM_HINT,
isHazardCertReady,
planHasHazardProducts
} from '@/composables/usePlToxicProductCertCheck'
import { canEdit } from './model'
import CertDetail from './CertDetail.vue'
import CertUpload from './CertUpload.vue'
import './cert.scss'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const id = computed(() => String(route.params.id || ''))
const detailRef = ref<InstanceType<typeof CertDetail>>()
const uploadRef = ref<InstanceType<typeof CertUpload>>()
const shellReady = ref(false)
const recordEpoch = ref(0)
let disposed = false
let recordVersion = 0
let operationVersion = 0
let permissionVersion = 0
let queryVersion = 0
const uploadOpen = ref(false)
const acting = ref(false)
const canUpload = ref(false)
const canReady = ref(false)
const canPrice = ref(false)
const permissionError = ref(false)
const permissionsLoaded = ref(false)
function back() {
void router.replace('/m/pl/plan-cert')
}
function openDetail(planId: string) {
void router.push(`/m/pl/plan-cert/${planId}`)
}
function openUpload(plan: Plan) {
if (
!shellReady.value ||
disposed ||
plan.id !== id.value ||
!canUpload.value ||
!canEdit(plan) ||
acting.value
) {
ElMessage.warning('无上传权限或该计划不可编辑')
return
}
uploadOpen.value = true
}
async function saved(planId: string) {
if (disposed || planId !== id.value) return
await detailRef.value?.reload()
}
function invalidateRecord() {
recordEpoch.value++
recordVersion++
operationVersion++
queryVersion++
// 父路由同步失效,早于 Vue 更新子组件 props / 卸载 keyed 实例。
uploadRef.value?.invalidateRecord()
detailRef.value?.invalidateRecord()
uploadOpen.value = false
acting.value = false
}
watch(
id,
() => {
invalidateRecord()
if (shellReady.value) void openQueryContract()
},
{ flush: 'sync' }
)
watch(
() => JSON.stringify([userStore.accessToken, userStore.info]),
() => {
invalidateRecord()
permissionVersion++
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionsLoaded.value = false
permissionError.value = false
if (shellReady.value && !disposed && userStore.accessToken) {
void loadPermissions()
void openQueryContract()
}
},
{ flush: 'sync' }
)
onBeforeUnmount(() => {
disposed = true
permissionVersion++
invalidateRecord()
})
async function setReady(plan: Plan, ready: boolean) {
if (
!shellReady.value ||
disposed ||
plan.id !== id.value ||
!canReady.value ||
acting.value ||
!canEdit(plan)
)
return
const targetId = id.value
const version = ++operationVersion
const current = () => !disposed && version === operationVersion && targetId === id.value
acting.value = true
try {
const result = await planApi.get(targetId)
if (!current() || !canReady.value) return
const fresh = (result.data ?? result) as Plan
if (!fresh || String(fresh.id || '') !== targetId) throw new Error('计划不存在或已失效')
if (!canEdit(fresh) || !planHasHazardProducts(fresh.products)) {
ElMessage.warning('该计划当前不可操作')
await detailRef.value?.reload()
return
}
if (isHazardCertReady(fresh) === ready) {
ElMessage.info(ready ? '证书已齐备' : '证书已取消齐备')
await detailRef.value?.reload()
return
}
const message = ready
? `${HAZARD_CERT_READY_CONFIRM_HINT}确认后可安排运输,之后仍可补充附件。`
: '取消后将无法安排运输。'
try {
await showCrmConfirmDialog({
kind: ready ? 'default' : 'danger',
title: ready ? '证书齐备确认' : '取消齐备',
message,
confirmButtonText: ready ? '确认齐备' : '确认取消'
})
} catch {
return
}
if (!current() || !canReady.value) return
await planApi.setHazardCertReady(targetId, ready)
if (!current() || !canReady.value) return
ElMessage.success(ready ? '证书已标记齐备' : '已取消证书齐备')
await detailRef.value?.reload()
} catch {
if (current()) ElMessage.error('操作失败,请重试')
} finally {
if (current()) acting.value = false
}
}
async function loadPermissions() {
if (disposed || !userStore.accessToken) return
const version = ++permissionVersion
const current = () => !disposed && version === permissionVersion
operationVersion++
acting.value = false
uploadRef.value?.invalidateRecord()
uploadOpen.value = false
permissionsLoaded.value = false
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionError.value = false
try {
const permission = await fetchGetMenuCodes()
if (!current()) return
const all = !!permission?.all || !!permission?.isSuperAdmin
canUpload.value = all || (permission?.menuCodes || []).includes('pl:plan-cert:upload')
canReady.value = all || (permission?.menuCodes || []).includes('pl:plan-cert:ready')
canPrice.value =
all || (permission?.menuCodes || []).includes('pl:contract:view-product-price')
} catch {
if (current()) permissionError.value = true
} finally {
if (current()) permissionsLoaded.value = true
}
}
async function openQueryContract() {
if (!shellReady.value || disposed) return
const version = ++queryVersion
const targetId = id.value
const record = recordVersion
const contractId = String(route.query.contractId || '')
if (!contractId) return
await nextTick()
if (disposed || record !== recordVersion || version !== queryVersion || targetId !== id.value)
return
await detailRef.value?.openContract(contractId)
}
async function init() {
if (shellReady.value || disposed) return
shellReady.value = true
const version = recordVersion
await loadPermissions()
if (disposed || version !== recordVersion) return
await openQueryContract()
}
watch(
() => route.query.contractId,
() => void openQueryContract()
)
import PlanQuotaPage from './index.vue'
</script>
+21 -638
View File
@@ -1,650 +1,33 @@
<template>
<MpWebviewShell @ready="init">
<main class="cert-module cert-page mp-plan-cert-list mp-cert-list-touch-scope">
<MpBusinessHeader
:keyword="keyword"
title="危化品证上传"
searchable
search-placeholder="计划编号 / 供方 / 需方"
search-label="搜索计划"
back-label="返回工作台"
@back="goToMiniWorkbench"
@search="search"
@update:keyword="onKeywordUpdate"
>
<template #before-search>
<van-tabs
:active="tab"
class="mp-customer-view-tabs"
shrink
:ellipsis="false"
aria-label="计划分类"
@change="changeTab"
>
<van-tab
v-for="item in PL_PLAN_CERT_TABS"
:key="item.name"
:name="item.name"
:title="item.label"
/>
</van-tabs>
</template>
<template #after-search>
<van-dropdown-menu class="mp-customer-dd">
<van-dropdown-item
v-model="dateKind"
:options="dateDropdownOptions"
@change="setDate"
/>
<van-dropdown-item
v-model="readyKind"
:options="readyDropdownOptions"
@change="setReadyFilter"
/>
<van-dropdown-item
ref="moreDropdown"
:title="extraFilterCount > 0 ? '更多·已筛' : '更多'"
@open="openMoreFilters"
/>
</van-dropdown-menu>
</template>
</MpBusinessHeader>
<div class="cert-body">
<p v-if="sessionChanged" class="cert-hint" role="alert">
登录信息已变更,请重新加载当前会话的数据。
<button class="cert-btn" @click="reloadSession">重新加载</button>
</p>
<p v-if="permissionError" class="cert-hint" role="alert"
>操作权限读取失败,暂不可上传或确认齐备。<button
class="cert-link"
@click="loadPermissions"
>重试权限</button
></p
>
<p v-else-if="permissionsLoaded && !canUpload && !canReady" class="cert-hint"
>当前仅有查看权限</p
>
<p v-if="error" class="cert-error" role="alert">{{ error }}</p>
<button v-if="error" class="cert-btn" @click="retryLoad">重试加载</button>
<div v-if="!loading && !error && !visibleCards.length" class="cert-empty"
>暂无符合条件的计划</div
>
<PlanCard
v-for="item in visibleCards"
:key="item.plan.id"
:plan="planDetails.cardPlan(item.plan)"
:rows="item.rows"
:can-upload="canUpload"
:can-ready="canReady"
:busy="acting"
:expanded="expandedIds.has(item.plan.id)"
:details-ready="!!planDetails.records[item.plan.id]?.plan"
:detail-error="planDetails.records[item.plan.id]?.error || ''"
@retry="planDetails.ensure(item.plan.id)"
@update:expanded="toggleExpanded(item.plan.id, $event)"
@view="openDetail"
@upload="openUpload"
@ready="setReady"
@contract="(id) => openContract(item.plan.id, id)"
/>
<MpListPageSkeleton
v-if="loading"
variant="body"
layout="customer"
:card-count="1"
:caption="failedMore ? '正在加载更多' : '正在加载计划'"
:aria-label="failedMore ? '正在加载更多' : '正在加载计划'"
/>
<p v-else-if="!error && plans.length" class="cert-total mp-h5-load-hint" role="status">
<span>共 {{ total }} 条计划 · 已加载 {{ plans.length }} 条</span>
<span v-if="plans.length >= total"> · 已加载全部</span>
</p>
<div
v-if="plans.length < total && !error"
ref="loadSentinel"
class="mp-h5-load-sentinel"
aria-hidden="true"
/>
</div>
</main>
<CertFilters
:key="sessionGeneration"
v-model="filtersOpen"
:filters="filters"
:sort="sort"
:context="query()"
@apply="applyFilters"
/>
<CertUpload
ref="uploadRef"
v-model="uploadOpen"
:plan-id="uploadId"
:permitted="canUpload"
@success="saved"
/>
</MpWebviewShell>
<MpWebviewShell @ready="ready = true"
><MpBusinessHeader
title="计划用证"
back-label="返回工作台"
@back="goToMiniWorkbench" /><PlanQuotaList
v-if="ready"
:key="session"
mobile
:plan-id="planId"
/></MpWebviewShell>
</template>
<script setup lang="ts">
import { useListReturnState } from '../../crm/useListReturnState'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import MpListPageSkeleton from '@/components/mp/MpListPageSkeleton.vue'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
import '@/styles/mp-webview.scss'
import '../../crm/customer/customer.scss'
import { planApi, type Plan, type PlanListResponse } from '@/api/pl'
import { fetchGetMenuCodes } from '@/api/auth'
import PlanQuotaList from '@/views/pl/plan-quota/index.vue'
import { useUserStore } from '@/store/modules/user'
import { goToMiniWorkbench } from '@/utils/mp-webview'
import { PL_PLAN_CERT_TABS, PL_PLAN_DEFAULT_FOLLOW_STATUSES } from '@/utils/pl-display'
import { plPlanCertColumnFilterDefs } from '@/views/pl/plan-cert/columnFilterDefs'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { flattenPlanArchivePairRows } from '@/utils/pl-plan-list-display-rows'
import {
applyMergedCustomerQueryParams,
applyContractSideQueryParams,
selectedContractStopKinds,
selectedKeywordsFromFilter
} from '@/utils/pl-plan-pair-list-query'
import {
planHasHazardProducts,
HAZARD_CERT_READY_CONFIRM_HINT
} from '@/composables/usePlToxicProductCertCheck'
import { buildQuery, canEdit, readyTargets, dateShortcut } from './model'
import CertFilters from './CertFilters.vue'
import CertUpload from './CertUpload.vue'
import PlanCard from './PlanCard.vue'
import { useMobilePlanListDetails } from '../plan/mobileListDetails'
import './cert.scss'
const userStore = useUserStore()
const menuStore = useMenuStore()
const sessionScope = () => JSON.stringify([userStore.accessToken, userStore.getUserInfo])
async function refreshSidebarBadges() {
try {
await refreshMenuBadges(menuStore.menuList)
} catch {
/* badge refresh is best-effort */
}
}
let disposed = false
let actionVersion = 0
let permissionVersion = 0
const tab = ref('cert_incomplete')
const keyword = ref('')
const appliedKeyword = ref('')
const dates = ref<string[]>([])
const dateKind = ref('all')
const dateOptions = [
{ value: 'all', label: '全部日期' },
{ value: 'today', label: '今天' },
{ value: 'tomorrow', label: '明天' },
{ value: 'yesterday', label: '昨天' },
{ value: 'week', label: '本周' }
]
const readyOptions = [
{ value: 'all', label: '全部齐备状态' },
{ value: '0', label: '未齐备' },
{ value: '1', label: '已齐备' }
]
const { filters, toQueryParams } = useServerColumnFilters(plPlanCertColumnFilterDefs, {
initialFilters: { followStatus: { text: '', selected: [...PL_PLAN_DEFAULT_FOLLOW_STATUSES] } }
})
const sort = reactive({ sortField: '', sortOrder: 'desc' })
const readyKind = ref('all')
const dateDropdownOptions = computed(() =>
dateOptions.map((item) => ({ text: item.label, value: item.value }))
)
const readyDropdownOptions = computed(() =>
readyOptions.map((item) => ({ text: item.label, value: item.value }))
)
const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
const loadSentinel = ref<HTMLElement | null>(null)
const extraFilterCount = computed(() => {
let count = Object.keys(filters).filter(
(key) => !['followStatus', 'planDate', 'hazardCertReady'].includes(key)
).length
const selectedFollow = (filters.followStatus as any)?.selected || []
if (
selectedFollow.length !== PL_PLAN_DEFAULT_FOLLOW_STATUSES.length ||
selectedFollow.some(
(value: string) => !PL_PLAN_DEFAULT_FOLLOW_STATUSES.includes(value as any)
)
)
count++
if (sort.sortField) count++
return count
})
const filtersOpen = ref(false)
const uploadOpen = ref(false)
const uploadId = ref('')
const uploadRef = ref<InstanceType<typeof CertUpload> | null>(null)
let uploadScope = ''
const sessionChanged = ref(false)
const sessionGeneration = ref(0)
const canUpload = ref(false)
const canReady = ref(false)
const canPrice = ref(false)
const permissionError = ref(false)
const permissionsLoaded = ref(false)
const plans = ref<Plan[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(true)
const error = ref('')
const failedMore = ref(false)
const acting = ref(false)
let requestVersion = 0
const expandedIds = ref(new Set<string>())
const hasMore = computed(() => plans.value.length < total.value)
const visibleCards = computed(() => {
const rows = flattenPlanArchivePairRows(plans.value, {
loadCustomerKeywords: selectedKeywordsFromFilter(filters.loadCustomer as any),
deliveryCustomerKeywords: selectedKeywordsFromFilter(filters.deliveryCustomer as any),
productKeywords: selectedKeywordsFromFilter(filters.productName as any),
kinds: selectedContractStopKinds(filters.productContract as any)
})
return plans.value
.map((plan) => ({ plan, rows: rows.filter((row) => row.plan.id === plan.id) }))
.filter((item) => item.rows.length)
})
function query() {
const q = buildQuery(
tab.value,
{ ...toQueryParams(), ...(sort.sortField ? sort : {}) },
appliedKeyword.value,
dates.value
)
applyMergedCustomerQueryParams(q)
applyContractSideQueryParams(q, filters.productContract as any)
return q
}
function requestKey() {
return JSON.stringify([keyword.value, query(), sessionScope()])
}
const planDetails = useMobilePlanListDetails({
scope: requestKey,
load: (id) => planApi.get(id, true)
})
watch(expandedIds, (ids) => {
ids.forEach((id) => void planDetails.ensure(id))
})
async function load(more = false) {
if (disposed || (more && loading.value)) return false
const v = ++requestVersion
const key = requestKey()
const params = { ...query() }
const current = () => !disposed && v === requestVersion && key === requestKey()
const nextPage = more ? page.value + 1 : 1
loading.value = true
error.value = ''
failedMore.value = more
if (!more) {
planDetails.reset()
page.value = 1
plans.value = []
expandedIds.value = new Set()
}
try {
const res = await planApi.list({ ...params, page: nextPage, pageSize: 20 })
if (!current()) return false
const data = (res.data ?? res) as PlanListResponse
plans.value = more
? [...plans.value, ...(data.list || [])].filter(
(p, i, all) => all.findIndex((other) => other.id === p.id) === i
)
: data.list || []
total.value = Number(data.total || 0)
page.value = nextPage
return true
} catch {
if (current()) error.value = '计划加载失败,请重试'
return false
} finally {
if (current()) loading.value = false
}
}
const filterState = computed({
get: () => filters,
set: (value) => {
Object.keys(filters).forEach((key) => delete filters[key])
Object.assign(filters, value)
}
})
const sortState = computed({ get: () => sort, set: (value) => Object.assign(sort, value) })
function retryLoad() {
return listReturnState.retry(!failedMore.value)
}
const listReturnState = useListReturnState({
retryFailedRestore: true,
fields: { keyword, appliedKeyword, tab, dates, dateKind, readyKind, filterState, sortState },
page,
expandedIds,
hasMore,
queryKey: requestKey,
fetchPage: (reset) => load(!reset),
restoreExpanded: async (ids) => {
const key = requestKey()
const version = requestVersion
const results = await Promise.all(
ids.filter((id) => plans.value.some((plan) => plan.id === id)).map(planDetails.ensure)
)
if (disposed || key !== requestKey() || version !== requestVersion) return false
if (results.some((ok) => !ok)) {
error.value = '展开信息恢复失败,请重试'
return false
}
return true
},
isDetailPath: (path) => /^\/m\/pl\/plan-cert\/[^/]+$/.test(path)
})
function toggleExpanded(id: string, expanded: boolean) {
const next = new Set(expandedIds.value)
if (expanded) next.add(id)
else next.delete(id)
expandedIds.value = next
}
function search() {
if (disposed) return
appliedKeyword.value = keyword.value
void load()
}
const scheduleKeywordSearch = useDebounceFn(() => search(), 300)
function onKeywordUpdate(value: string) {
const cleared = Boolean(keyword.value.trim()) && !value.trim()
keyword.value = value
if (cleared) {
search()
return
}
void scheduleKeywordSearch()
}
useIntersectionObserver(
loadSentinel,
([entry]) => {
if (
!entry?.isIntersecting ||
loading.value ||
error.value ||
listReturnState.restoring.value ||
plans.value.length >= total.value
)
return
void load(true)
},
{ rootMargin: '160px 0px' }
)
function changeTab(value: string) {
tab.value = value
delete filters.planType
if (value === 'cert_incomplete') {
delete filters.hazardCertReady
readyKind.value = 'all'
}
search()
}
function openMoreFilters() {
moreDropdown.value?.toggle?.(false)
filtersOpen.value = true
}
function setDate(kind: string) {
dateKind.value = kind
dates.value = dateShortcut(kind)
delete filters.planDate
search()
}
function setReadyFilter(value: string) {
if (value === '1' && tab.value === 'cert_incomplete') tab.value = 'all'
readyKind.value = value
if (value === 'all') delete filters.hazardCertReady
else filters.hazardCertReady = { text: '', selected: [value] } as any
search()
}
function applyFilters(next: Record<string, any>, nextSort: typeof sort, resetQuick = false) {
Object.keys(filters).forEach((k) => delete filters[k])
Object.assign(filters, next)
Object.assign(sort, nextSort)
if (resetQuick) {
dateKind.value = 'all'
dates.value = []
}
if (filters.planDate) {
dateKind.value = 'custom'
dates.value = []
} else if (dateKind.value === 'custom') {
dateKind.value = 'all'
}
const readyValues = (filters.hazardCertReady as any)?.selected || []
if (readyValues.map(String).includes('1') && tab.value === 'cert_incomplete') tab.value = 'all'
if (readyValues.length === 1 && ['0', '1'].includes(String(readyValues[0]))) {
readyKind.value = String(readyValues[0])
} else {
readyKind.value = 'all'
}
search()
}
function openDetail(id: string) {
void router.push(`/m/pl/plan-cert/${id}`)
}
function openContract(planId: string, contractId: string) {
void router.push({ path: `/m/pl/plan-cert/${planId}`, query: { contractId } })
}
function openUpload(p: Plan) {
if (!canUpload.value || !canEdit(p) || acting.value) {
ElMessage.warning('无上传权限或该计划不可编辑')
return
}
uploadScope = sessionScope()
uploadId.value = p.id
uploadOpen.value = true
}
async function saved(id: string) {
if (disposed || !canUpload.value || id !== uploadId.value || uploadScope !== sessionScope())
return
const scope = uploadScope
const generation = sessionGeneration.value
const loaded = await load()
await refreshSidebarBadges()
if (loaded && !disposed && generation === sessionGeneration.value && scope === sessionScope())
openDetail(id)
}
import '@/styles/mp-webview.scss'
const route = useRoute(),
user = useUserStore(),
ready = ref(false),
session = ref(0)
const planId = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
watch(
canReady,
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
actionVersion++
acting.value = false
ready.value = ready.value && !!user.accessToken
session.value++
},
{ flush: 'sync' }
)
async function setReady(row: Plan, ready: boolean) {
if (disposed || !canReady.value || acting.value) return
const id = row.id
const v = ++actionVersion
const scope = sessionScope()
const current = () =>
!disposed && v === actionVersion && scope === sessionScope() && canReady.value
acting.value = true
try {
const res = await planApi.get(id)
if (!current()) return
const fresh = (res.data ?? res) as Plan
if (!fresh || String(fresh.id || '') !== id) {
ElMessage.warning('计划信息不匹配,请重新打开')
return
}
if (!readyTargets([fresh], ready, (p) => planHasHazardProducts(p.products)).length) {
ElMessage.warning('该计划当前不可操作')
return
}
try {
await showCrmConfirmDialog({
kind: ready ? 'default' : 'danger',
title: ready ? '证书齐备确认' : '取消齐备',
message: ready
? `${HAZARD_CERT_READY_CONFIRM_HINT}确认后可安排运输,之后仍可补充附件。`
: '取消后将无法安排运输。',
confirmButtonText: ready ? '确认齐备' : '确认取消'
})
} catch {
return
}
if (!current()) return
await planApi.setHazardCertReady(id, ready)
if (!current()) return
ElMessage({ type: 'success', message: ready ? '已标记证书齐备' : '已取消齐备' })
await load()
if (current()) await refreshSidebarBadges()
} catch {
if (current()) ElMessage.warning('操作失败,请重试')
} finally {
if (!disposed && v === actionVersion) acting.value = false
}
}
async function loadPermissions() {
if (disposed) return
const v = ++permissionVersion
const scope = sessionScope()
const current = () => !disposed && v === permissionVersion && scope === sessionScope()
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionError.value = false
try {
const p = await fetchGetMenuCodes()
if (!current()) return
const all = !!p?.all || !!p?.isSuperAdmin
canUpload.value = all || (p?.menuCodes || []).includes('pl:plan-cert:upload')
canReady.value = all || (p?.menuCodes || []).includes('pl:plan-cert:ready')
canPrice.value = all || (p?.menuCodes || []).includes('pl:contract:view-product-price')
} catch {
if (current()) permissionError.value = true
} finally {
if (current()) permissionsLoaded.value = true
}
}
watch(
sessionScope,
() => {
requestVersion++
actionVersion++
permissionVersion++
sessionGeneration.value++
// Props update on the next render; invalidate pending child saves in this same tick.
uploadRef.value?.invalidateRecord()
uploadOpen.value = false
uploadId.value = ''
uploadScope = ''
canUpload.value = false
canReady.value = false
canPrice.value = false
permissionsLoaded.value = false
permissionError.value = false
plans.value = []
expandedIds.value = new Set()
total.value = 0
page.value = 1
loading.value = false
acting.value = false
error.value = ''
failedMore.value = false
keyword.value = ''
appliedKeyword.value = ''
tab.value = 'cert_incomplete'
dates.value = []
dateKind.value = 'all'
readyKind.value = 'all'
Object.keys(filters).forEach((key) => delete filters[key])
filters.followStatus = { text: '', selected: [...PL_PLAN_DEFAULT_FOLLOW_STATUSES] }
Object.assign(sort, { sortField: '', sortOrder: 'desc' })
filtersOpen.value = false
moreDropdown.value?.toggle?.(false)
sessionChanged.value = true
},
{ flush: 'sync' }
)
async function reloadSession() {
if (disposed) return
sessionChanged.value = false
await Promise.allSettled([loadPermissions(), load()])
}
async function init() {
if (sessionChanged.value) return reloadSession()
await Promise.allSettled([loadPermissions(), listReturnState.restore()])
}
const router = useRouter()
onBeforeUnmount(() => {
disposed = true
actionVersion++
permissionVersion++
requestVersion++
})
</script>
<style scoped lang="scss">
.mp-plan-list,
.mp-plan-cert-list {
:deep(.mp-wv-header__back),
:deep(.van-dropdown-menu__item),
:deep(.van-dropdown-menu__title),
:deep(.mp-wv-flat-card__toggle),
:deep(.mp-wv-flat-card__check),
:deep(.cert-link),
:deep(.cert-btn),
:deep(.mp-wv-btn),
:deep(.mp-wv-speed-item) {
min-width: 44px;
min-height: 44px;
height: auto;
}
:deep(.van-dropdown-menu__title) {
display: flex;
align-items: center;
}
:deep(.mp-wv-flat-card__check) {
flex: 0 0 44px;
justify-content: center;
}
:deep(.mp-wv-card-actions) {
flex-wrap: wrap;
}
:deep(.mp-wv-flat-card .mp-wv-card-actions .mp-wv-btn) {
min-height: 44px;
height: auto;
flex: 1 0 auto;
white-space: nowrap;
}
:deep(.mp-wv-flat-card__row > span:first-child),
:deep(.mp-wv-flat-card__line > span:first-child),
:deep(.mp-wv-card__kv-row > span:first-child) {
flex-shrink: 0;
white-space: nowrap;
}
}
.mp-plan-cert-list .cert-body {
padding-bottom: calc(148px + env(safe-area-inset-bottom, 0px));
}
.mp-plan-cert-list .cert-batch {
gap: 8px;
}
</style>
<style lang="scss">
html
body
:is(.mp-plan-cert-list, .mp-cert-filter-popup).mp-cert-list-touch-scope
:is(.mp-wv-btn, .cert-btn, .filter-chip, .van-button, .el-button, .cert-link):not(
.mp-wv-speed-main
):not(.van-stepper__minus):not(.van-stepper__plus) {
min-width: 44px !important;
min-height: 44px !important;
height: auto !important;
line-height: 42px !important;
}
.mp-plan-cert-list .cert-card-attachments {
min-width: 0;
padding-top: 12px;
}
</style>
@@ -1,51 +1,8 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildQuery, canEdit, readyTargets, certPayload, canSave, dateShortcut } from './model'
import { certPayload, canSave, dateShortcut } from './model'
import * as model from './model'
test('five tabs preserve hazard scope and enforce their constraints', () => {
for (const tab of ['cert_incomplete', 'all', 'inbound', 'outbound', 'direct']) {
const q = buildQuery(tab, {}, '')
assert.equal(q.hasHazardProduct, 1)
assert.equal(q.followStatus, undefined, 'default status is supplied by the list filters')
if (tab === 'cert_incomplete') {
assert.equal(q.certUploadStatus, 'incomplete')
assert.equal(q.hazardCertReady, 0)
} else if (tab !== 'all') assert.equal(q.planType, tab)
else assert.equal(q.planType, undefined)
}
})
test('tab wins conflicts while explicit cancelled status is retained', () => {
const q = buildQuery(
'cert_incomplete',
{ hazardCertReadyIn: '1', followStatusIn: 'cancelled' },
' x '
)
assert.equal(q.hazardCertReady, 0)
assert.equal(q.hazardCertReadyIn, undefined)
assert.equal(q.followStatus, 'cancelled')
assert.equal(q.q, 'x')
assert.equal(buildQuery('direct', { planTypeIn: 'outbound' }, '').planTypeIn, undefined)
assert.equal(buildQuery('all', { hazardCertReadyIn: '1' }, '').hazardCertReady, 1)
})
test('closed plans and wrong ready states are skipped; hazard predicate is required', () => {
const rows = [
{ id: 'a', hazardCertReady: 0 },
{ id: 'b', hazardCertReady: 1 },
{ id: 'c', followStatus: 'cancelled' },
{ id: 'd', archiveStatus: 'archived' },
{ id: 'e' }
]
assert.equal(canEdit(rows[2]), false)
assert.deepEqual(
readyTargets(rows, true, (p) => p.id !== 'e').map((p) => p.id),
['a']
)
assert.deepEqual(
readyTargets(rows, false, () => true).map((p) => p.id),
['b']
)
})
test('certificate payload contains only id and trimmed URLs; empty slots are allowed', () => {
assert.deepEqual(certPayload([{ id: 'p', productName: 'secret', purchaseCertUrl: ' a,b ' }]), [
{ id: 'p', purchaseCertUrl: 'a,b', transportCertUrl: '', legalUseCertUrl: '' }
@@ -141,11 +98,6 @@ test('contract reads enrich ids and saved product aliases without changing sourc
assert.equal(failed.warnings.length, 3)
})
test('clearing follow status does not silently restore the initial defaults', () => {
assert.equal(buildQuery('all', {}, '').followStatus, undefined)
assert.equal(buildQuery('all', { followStatusIn: 'cancelled' }, '').followStatus, 'cancelled')
})
test('explicit segment metadata wins over root product including empty alias and brand', () => {
const root = [
{
+4 -50
View File
@@ -249,33 +249,9 @@
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
<h3>危化品证书信息</h3>
<div class="mp-wv-cert-summary">
<span>{{ certSummaryText }}</span>
<span class="status-tag" :class="`status-tag--${certReadyTagType}`">{{
certReadyLabel
}}</span>
</div>
<article
v-for="product in hazardProducts"
:key="product.id"
class="plan-cert-product"
>
<h4>{{ product.standardProductName || product.productName }}</h4>
<div
v-for="slot in getRequiredCertSlots(product)"
:key="slot.key"
class="plan-cert-slot"
>
<p class="plan-subcard__label">{{ slot.label }}</p>
<AttachmentUploadField
v-if="getProductCertUrl(product, slot.key)"
:model-value="getProductCertUrl(product, slot.key)"
disabled
/>
<p v-else class="plan-empty-hint">未上传</p>
</div>
</article>
<p v-if="!hazardProducts.length" class="plan-empty-hint">本计划无易制毒/易制爆产品</p>
<p>购买证、运输证按合同产品与共享额度校验;合法使用证明可后补。</p>
<van-button block plain type="primary" @click="router.push(`/m/pl/plan-cert/${plan.id}`)">查看用证配置与占用</van-button>
<van-button block plain type="primary" @click="router.push('/m/pl/legal-use-document')">维护合法使用证明</van-button>
</section>
<section id="plan-sec-freight" class="mp-wv-section mp-h5-anchor">
@@ -459,12 +435,7 @@
getPlVehicleTypeLabel,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import {
isHazardCertReady,
isHazardProduct,
getRequiredCertSlots,
getProductCertUrl
} from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { planRequiresSiteLink } from '@/utils/pl-internal-customer'
import {
@@ -681,23 +652,6 @@
return qty ? `#${idx + 1} ${name} ${qty}` : `#${idx + 1} ${name}`
}
const hazardProducts = computed(() =>
(plan.value?.products || []).filter((p) => isHazardProduct(p))
)
const certReady = computed(() => isHazardCertReady(plan.value))
const certReadyLabel = computed(() => {
if (!hazardProducts.value.length) return '无需证书'
return certReady.value ? '已齐备' : '待确认'
})
const certReadyTagType = computed(() => {
if (!hazardProducts.value.length) return 'info'
return certReady.value ? 'success' : 'warning'
})
const certSummaryText = computed(() => {
if (!hazardProducts.value.length) return '本计划无易制毒/易制爆产品'
return `${hazardProducts.value.length} 个危化产品`
})
const feedbackLines = computed(() => {
const lines: { title: string; text: string }[] = []
for (const link of siteLinks.value) {
@@ -0,0 +1,190 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">合法使用证明</h2></div>
<div class="filter-block"
><el-form label-position="top" @submit.prevent="load">
<div class="filter-grid"
><el-form-item label="计划编号"
><el-input v-model="keyword" placeholder="输入计划编号" clearable /></el-form-item
><el-form-item label="上传情况"
><el-select v-model="status" clearable
><el-option value="pending" label="待上传" /><el-option
value="uploaded"
label="已上传" /></el-select></el-form-item
></div>
<div class="filter-actions-row"
><span class="filter-hint">合法使用证明可在运输安排或归档后补传。</span
><div class="filter-actions-end"
><el-button @click="reset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
><el-button type="primary" :loading="loading" @click="load"
><el-icon><Search /></el-icon>搜索</el-button
></div
></div
>
</el-form></div
>
<div class="list-body list-wrap"
><el-table
v-loading="loading"
:data="rows"
row-key="id"
stripe
class="list-table"
@row-dblclick="open"
>
<el-table-column label="计划编号" min-width="180"
><template #default="{ row }"
><el-link type="primary" @click="open(row)">{{ row.planNo }}</el-link></template
></el-table-column
>
<el-table-column label="易制爆产品" min-width="220"
><template #default="{ row }">{{
row.products.map((p: any) => p.productName).join('、')
}}</template></el-table-column
>
<el-table-column label="待补明细" min-width="120"
><template #default="{ row }"
><el-tag
size="small"
:type="
row.products.some((p: any) => p.documentStatus === 'pending')
? 'warning'
: 'success'
"
>{{ row.products.filter((p: any) => p.documentStatus === 'pending').length }} /
{{ row.products.length }}</el-tag
></template
></el-table-column
>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ row }"
><el-button @click="open(row)"
><el-icon><Document /></el-icon>{{ canUpload ? '维护资料' : '查看资料' }}</el-button
></template
></el-table-column
>
</el-table></div
>
</section>
<el-dialog
v-model="visible"
:title="`合法使用证明 · ${detail?.planNo || ''}`"
width="900px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<section v-for="p in detail?.products || []" :key="p.id" class="view-section">
<h3>{{ p.productName }} {{ p.model || '' }}</h3>
<p v-for="context in p.contexts || []" :key="context.stopProductId"
>{{ context.stopType === 'load' ? '装货' : '送货' }}:{{
context.partyName || '未填写单位'
}}
· {{ context.address || '未填写地址' }} · 合同
{{ context.contractNo || '未关联或无查看权限' }}</p
>
<el-tag size="small" :type="p.legalUseCertUrl ? 'success' : 'warning'">{{
p.legalUseCertUrl ? '已上传' : '待上传'
}}</el-tag>
<AttachmentUploadField
:ref="(el) => setUpload(p.id, el)"
v-model="p.legalUseCertUrl"
folder="pl/legal-use-document"
:disabled="!canUpload || saving"
/>
</section>
<template #footer
><el-button :disabled="saving" @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button v-if="canUpload" type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存资料</el-button
></template
>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Document, RefreshLeft, Search } from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import { legalDocumentApi, type LegalDocument } from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
const keyword = ref(''),
status = ref('')
const loading = ref(false),
saving = ref(false),
visible = ref(false),
canUpload = ref(false)
const rows = ref<LegalDocument[]>([]),
detail = ref<LegalDocument>()
async function load() {
loading.value = true
try {
rows.value = await legalDocumentApi.list({
keyword: keyword.value,
status: status.value || undefined
})
} finally {
loading.value = false
}
}
function reset() {
keyword.value = ''
status.value = ''
load()
}
const uploads = new Map<string, InstanceType<typeof AttachmentUploadField>>()
function setUpload(id: string, value: any) {
if (value) uploads.set(id, value)
else uploads.delete(id)
}
let session = 0
watch(
visible,
(value) => {
if (!value) session++
},
{ flush: 'sync' }
)
async function open(row: LegalDocument) {
if (saving.value) return
const current = ++session
const result = await legalDocumentApi.get(row.id)
if (current !== session) return
detail.value = result
visible.value = true
}
async function save() {
if (!detail.value || !canUpload.value || saving.value) return
if ([...uploads.values()].some((upload) => upload.hasUploading())) {
ElMessage.warning('请等待附件上传完成')
return
}
const current = session,
targetId = detail.value.id
saving.value = true
try {
const result = await legalDocumentApi.save(
targetId,
detail.value.products.map((p) => ({ id: p.id, legalUseCertUrl: p.legalUseCertUrl }))
)
if (current !== session || detail.value?.id !== targetId) return
detail.value = result
ElMessage.success('合法使用证明已保存')
visible.value = false
await load()
} finally {
saving.value = false
}
}
onMounted(async () => {
const p = await fetchGetMenuCodes()
canUpload.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:plan-cert:upload')
await load()
})
</script>
File diff suppressed because it is too large. Load diff
+158
View File
@@ -0,0 +1,158 @@
<template>
<div :class="mobile ? 'mp-h5-list-content' : 'page-container'">
<section class="panel">
<div v-if="!mobile" class="panel-top"><h2 class="title-bar-heading">计划用证</h2></div>
<div class="filter-block"
><el-form label-position="top" @submit.prevent="load"
><el-form-item label="计划编号"
><el-input v-model="keyword" clearable placeholder="输入计划编号" /></el-form-item
><el-form-item label="用证状态"
><el-select v-model="status" clearable
><el-option
v-for="(label, value) in stateLabels"
:key="value"
:label="label"
:value="value" /></el-select></el-form-item
><el-button type="primary" :loading="loading" @click="load"
><el-icon><Search /></el-icon>搜索</el-button
></el-form
></div
>
<p class="form-tip">按当前来源、合同产品范围和证件余额校验;实际安排时原子预占。</p>
<div v-if="mobile" v-loading="loading"
><section v-for="row in rows" :key="row.id" class="mp-wv-section"
><h3>{{ row.planNo }}</h3
><p>{{ row.licensePlate || '未安排车辆' }} · {{ row.planDate || '未定日期' }}</p
><el-tag
:type="
row.quotaStatus === 'exception'
? 'danger'
: ['reserved', 'settled'].includes(row.quotaStatus)
? 'success'
: 'info'
"
>{{ stateLabels[row.quotaStatus] }}</el-tag
><el-button @click="open(row.id)"
><el-icon><Document /></el-icon>用证配置与占用</el-button
></section
></div
>
<div v-else class="list-body list-wrap"
><el-table v-loading="loading" :data="rows" stripe class="list-table"
><el-table-column label="计划编号" min-width="190"
><template #default="{ row }"
><el-link type="primary" @click="open(row.id)">{{ row.planNo }}</el-link></template
></el-table-column
><el-table-column prop="planDate" label="计划日期" min-width="140" /><el-table-column
prop="licensePlate"
label="车号"
min-width="160"
/><el-table-column label="用证校验" min-width="160"
><template #default="{ row }"
><el-tag
:type="
row.quotaStatus === 'exception'
? 'danger'
: row.quotaStatus === 'reserved' || row.quotaStatus === 'settled'
? 'success'
: 'info'
"
>{{ stateLabels[row.quotaStatus] }}</el-tag
></template
></el-table-column
><el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ row }"
><el-button @click="open(row.id)"
><el-icon><Document /></el-icon>用证配置与占用</el-button
></template
></el-table-column
></el-table
></div
>
<div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="page"
:page-size="20"
:total="total"
layout="prev, pager, next"
@current-change="load"
/></div>
</section>
<PlPlanQuotaDialog ref="dialog" />
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { Document, Search } from '@element-plus/icons-vue'
import { type Plan } from '@/api/pl'
import request from '@/utils/http/bx'
const stateLabels: Record<string, string> = {
not_required: '无需两证',
needs_configuration: '待配置',
needs_validation: '待校验条件与余额',
reserved: '已持有预占',
settled: '已核销',
exception: '结转异常'
}
const status = ref('')
import { pickBxData } from '@/utils/http/bx'
import PlPlanQuotaDialog from '@/components/business/pl-plan-quota-dialog/index.vue'
const props = defineProps<{ mobile?: boolean; planId?: string }>()
const keyword = ref(''),
page = ref(1),
total = ref(0),
loading = ref(false),
rows = ref<(Plan & { quotaStatus: string })[]>([])
const dialog = ref<InstanceType<typeof PlPlanQuotaDialog>>()
let epoch = 0
async function load() {
const current = ++epoch
loading.value = true
try {
const value = pickBxData<{ list: (Plan & { quotaStatus: string })[]; total: number }>(
await request.get('/transport/quota-certs/plans', {
params: {
page: page.value,
pageSize: 20,
keyword: keyword.value,
status: status.value || undefined
}
})
)
if (current !== epoch) return
rows.value = value?.list || []
total.value = value?.total || 0
} finally {
if (current === epoch) loading.value = false
}
}
function open(id: string) {
const current = epoch
void dialog.value?.open(id).then(() => {
if (current === epoch) return load()
})
}
const user = useUserStore()
watch(
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
epoch++
rows.value = []
total.value = 0
page.value = 1
loading.value = false
dialog.value?.invalidateSession()
if (user.accessToken) void load()
},
{ flush: 'sync' }
)
onMounted(async () => {
const pending = load(),
current = epoch
await pending
if (current === epoch && props.planId) open(props.planId)
})
onBeforeUnmount(() => {
epoch++
})
</script>
@@ -0,0 +1,321 @@
<template>
<el-dialog
v-model="visible"
title="合同适用证件"
width="1040px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
>
<div v-loading="loading">
<el-alert
title="关联只定义适用范围。多个合同产品、车辆共用证件余额,正式安排时校验并预占。"
type="info"
:closable="false"
/>
<section class="view-section">
<h3>1. 选择山峡合同</h3>
<el-select
v-model="contractId"
filterable
remote
:remote-method="searchContracts"
:loading="searching"
:disabled="saving"
placeholder="输入合同编号或名称"
@change="loadContract"
>
<el-option
v-for="c in contracts"
:key="c.id"
:value="c.id"
:label="`${c.contractNo} · ${c.contractName}`"
/>
</el-select>
</section>
<template v-if="contractId && loadedContractId === contractId">
<section class="view-section">
<h3>2. 勾选合同产品行</h3>
<el-checkbox-group v-model="selectedProducts" :disabled="!canEdit || saving">
<el-checkbox v-for="p in products" :key="p.id" :value="p.id">{{
productLabel(p)
}}</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!products.length" description="该合同暂无有效产品明细" :image-size="64" />
</section>
<section class="view-section">
<h3>3. 分别选择证件及各自适用产品</h3>
<div class="form-grid-2">
<div v-for="kind in kinds" :key="kind.value">
<el-form label-position="top">
<el-form-item :label="kind.label">
<el-select
:model-value="
bindings.filter((b) => b.certType === kind.value).map((b) => b.certId)
"
multiple
filterable
:disabled="!canEdit || saving"
placeholder="可选择多张证件"
@update:model-value="(ids) => setCertificates(kind.value, ids)"
>
<el-option
v-for="c in certificates[kind.value]"
:key="c.id"
:value="c.id"
:label="`${c.certNo} · 可用 ${c.availableQty} ${c.quantityUnit}`"
:disabled="
c.status === 'void' &&
!bindings.some((b) => b.certType === kind.value && b.certId === c.id)
"
/>
</el-select>
</el-form-item>
</el-form>
<div
v-for="b in bindings.filter((b) => b.certType === kind.value)"
:key="b.certId"
class="view-card"
>
<h4>{{ certLabel(b) }}</h4>
<el-tag size="small" :type="b.linkStatus === 'inactive' ? 'info' : 'success'">{{
b.linkStatus === 'inactive' ? '已停用' : '可用范围'
}}</el-tag>
<el-checkbox-group
:model-value="b.products.map((p) => p.contractProductId)"
:disabled="!canEdit || saving"
@update:model-value="(ids) => setProducts(b, ids)"
>
<el-checkbox v-for="p in availableProducts(b)" :key="p.id" :value="p.id">{{
productLabel(p)
}}</el-checkbox>
</el-checkbox-group>
<div v-for="p in b.products" :key="p.contractProductId">
<el-switch
v-model="p.scopeStatus"
active-value="active"
inactive-value="inactive"
:active-text="`${products.find((row) => row.id === p.contractProductId)?.productName || p.contractProductId}:产品范围启用`"
inactive-text="产品范围停用"
:disabled="!canEdit || saving"
/>
</div>
<el-switch
v-model="b.linkStatus"
active-value="active"
inactive-value="inactive"
active-text="允许新增使用"
:disabled="!canEdit || saving"
/>
<p v-if="kind.value === 'transport'" class="form-tip">{{
transportDetails(b.certId)
}}</p>
</div>
</div>
</div>
</section>
<section class="view-section">
<h3>4. 核对产品与证件关联</h3>
<el-table
:data="
products.filter((p) =>
bindings.some((b) => b.products.some((s) => s.contractProductId === p.id))
)
"
stripe
class="list-table"
row-key="id"
>
<el-table-column label="合同产品行" min-width="200"
><template #default="{ row }">{{ productLabel(row) }}</template></el-table-column
>
<el-table-column
v-for="kind in kinds"
:key="kind.value"
:label="kind.label"
min-width="220"
>
<template #default="{ row }"
><el-tag
v-for="b in bindings.filter(
(b) =>
b.certType === kind.value &&
b.products.some((p) => p.contractProductId === row.id)
)"
:key="b.certId"
size="small"
:type="bindingProductEnabled(b, row.id) ? 'success' : 'info'"
>{{ certLabel(b)
}}{{ bindingProductEnabled(b, row.id) ? '' : '(停用)' }}</el-tag
></template
>
</el-table-column>
</el-table>
</section>
</template>
</div>
<template #footer>
<el-button @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
>
<el-button
v-if="canEdit"
type="primary"
:loading="saving"
:disabled="!contractId || loadedContractId !== contractId || loading"
@click="save"
><el-icon><Check /></el-icon>保存关联</el-button
>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import {
quotaCertApi,
type CertType,
type CertBinding,
type QuotaCert,
type ContractOption,
type ContractProduct
} from '@/api/pl/quota-cert'
import { bindingPayload, bindingProductEnabled } from './model'
const props = defineProps<{ canEdit: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const visible = ref(false)
const loading = ref(false)
const searching = ref(false)
const saving = ref(false)
const contractId = ref('')
const loadedContractId = ref('')
const contracts = ref<ContractOption[]>([])
const products = ref<ContractProduct[]>([])
const selectedProducts = ref<string[]>([])
const bindings = ref<CertBinding[]>([])
const original = ref<CertBinding[]>([])
const certificates = reactive<Record<CertType, QuotaCert[]>>({ purchase: [], transport: [] })
const kinds: { value: CertType; label: string }[] = [
{ value: 'purchase', label: '购买证' },
{ value: 'transport', label: '运输证' }
]
const productLabel = (p: ContractProduct) =>
`${p.productName}${p.model ? ' / ' + p.model : ''} · ${p.quantity ?? '—'} ${p.unit || ''} · ${p.id.slice(-6)}`
const certLabel = (b: CertBinding) =>
certificates[b.certType].find((c) => c.id === b.certId)?.certNo || b.certId
const availableProducts = (b: CertBinding) =>
products.value.filter(
(p) =>
selectedProducts.value.includes(p.id) ||
b.products.some((s) => s.contractProductId === p.id)
)
function transportDetails(id: string) {
const c = certificates.transport.find((c) => c.id === id)
return `车号:${c?.vehicleNumbersJson?.join('、') || '未填写'};引用:${c?.purchaseRefs?.map((r) => r.referenceNo).join('、') || '未填写'}`
}
async function searchContracts(keyword = '') {
searching.value = true
try {
contracts.value = await quotaCertApi.contracts(keyword)
} finally {
searching.value = false
}
}
async function open(id = '') {
if (saving.value) return
visible.value = true
contractId.value = id
loadedContractId.value = ''
bindings.value = []
original.value = []
products.value = []
loading.value = true
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase'),
quotaCertApi.list('transport'),
searchContracts()
])
certificates.purchase = purchase
certificates.transport = transport
if (id) await loadContract()
} finally {
loading.value = false
}
}
let loadVersion = 0
async function loadContract() {
const version = ++loadVersion
const id = contractId.value
loadedContractId.value = ''
loading.value = true
try {
const [rows, scope] = await Promise.all([
quotaCertApi.products(id),
quotaCertApi.bindings(id)
])
if (version !== loadVersion) return
products.value = rows
bindings.value = bindingPayload(scope)
original.value = JSON.parse(JSON.stringify(bindings.value))
selectedProducts.value = [
...new Set(scope.flatMap((b) => b.products.map((p) => p.contractProductId)))
]
loadedContractId.value = id
} finally {
if (version === loadVersion) loading.value = false
}
}
function setCertificates(kind: CertType, ids: string[]) {
bindings.value = [
...bindings.value.filter((b) => b.certType !== kind),
...ids.map((id) => {
const old =
bindings.value.find((b) => b.certType === kind && b.certId === id) ||
original.value.find((b) => b.certType === kind && b.certId === id)
return (
old || {
certType: kind,
certId: id,
linkStatus: 'active' as const,
products: selectedProducts.value.map((p) => ({
contractProductId: p,
scopeStatus: 'active' as const
}))
}
)
})
]
}
function setProducts(binding: CertBinding, values: (string | number)[]) {
const ids = values.map(String)
const old = original.value.find(
(b) => b.certType === binding.certType && b.certId === binding.certId
)
binding.products = ids.map(
(id) =>
binding.products.find((p) => p.contractProductId === id) ||
old?.products.find((p) => p.contractProductId === id) || {
contractProductId: id,
scopeStatus: 'active'
}
)
}
async function save() {
if (!props.canEdit) return
const targetContract = contractId.value
const version = loadVersion
saving.value = true
try {
const result = await quotaCertApi.saveBindings(targetContract, bindingPayload(bindings.value))
if (targetContract !== contractId.value || version !== loadVersion) return
bindings.value = bindingPayload(result)
original.value = JSON.parse(JSON.stringify(bindings.value))
ElMessage.success('合同及产品关联已保存')
emit('saved')
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
@@ -0,0 +1,149 @@
<template>
<el-dialog
v-model="visible"
:title="`额度流水 · ${cert?.certNo || ''}`"
width="980px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<template v-if="cert">
<p
>核定 {{ cert.totalQty }} {{ cert.quantityUnit }};预占 {{ cert.reservedQty }};已使用
{{ cert.usedQty }}</p
>
<el-table :data="logs" stripe
><el-table-column prop="createTime" label="时间" min-width="170" /><el-table-column
prop="eventType"
label="业务事件"
min-width="140"
><template #default="{ row }"
><el-tag type="info" size="small">{{
eventLabels[row.eventType] || row.eventType
}}</el-tag></template
></el-table-column
><el-table-column prop="totalDelta" label="总量变化" min-width="120" /><el-table-column
prop="reservedDelta"
label="预占变化"
min-width="120" /><el-table-column
prop="usedDelta"
label="已用变化"
min-width="120" /><el-table-column prop="reason" label="原因" min-width="220"
/></el-table>
<section v-if="canEdit" class="view-section"
><h3>更正证面核定总量</h3
><p class="form-tip"
>新总量不得低于已用与预占之和。更正会留下流水并更新证面版本,后续用证需重新核对。</p
>
<el-form label-position="top" :disabled="saving"
><div class="form-grid-2"
><el-form-item :label="`更正后总量(${cert.quantityUnit})`"
><el-input v-model="body.totalQty" /></el-form-item
><el-form-item label="更正理由"><el-input v-model="body.reason" /></el-form-item
><el-form-item label="依据说明(与凭据至少一项)" class="form-col-span-2"
><el-input v-model="body.sourceNote" type="textarea" /></el-form-item
><el-form-item label="更正凭据" class="form-col-span-2"
><AttachmentUploadField
ref="upload"
:key="epoch"
v-model="body.evidenceUrls"
:disabled="saving"
folder="pl/quota-adjustment" /></el-form-item></div
></el-form>
</section>
</template>
<template #footer
><el-button :disabled="saving" @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button v-if="canEdit" type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存总量更正</el-button
></template
>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import {
quotaAdjustmentApi,
quotaCertApi,
type QuotaCert,
type CertType
} from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
defineProps<{ canEdit: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const visible = ref(false),
saving = ref(false),
cert = ref<QuotaCert>(),
logs = ref<Record<string, any>[]>([]),
kind = ref<CertType>('purchase')
const upload = ref<InstanceType<typeof AttachmentUploadField>>()
const eventLabels: Record<string, string> = {
opening: '确认期初',
reserve: '安排预占',
resize: '调整预占',
release: '释放预占',
settle: '实际核销',
adjust: '实际量更正',
total_adjust: '核定总量更正',
verify: '信息复核'
}
const body = reactive({ totalQty: '', reason: '', sourceNote: '', evidenceUrls: '' })
let epoch = 0,
attempt = { signature: '', id: '' }
watch(
visible,
(v) => {
if (!v) epoch++
},
{ flush: 'sync' }
)
async function open(type: CertType, row: QuotaCert) {
if (saving.value) return
const current = ++epoch
const values = await quotaAdjustmentApi.logs(type, row.id)
if (current !== epoch) return
kind.value = type
cert.value = row
logs.value = values
Object.assign(body, {
totalQty: String(row.totalQty),
reason: '',
sourceNote: '',
evidenceUrls: ''
})
visible.value = true
}
async function save() {
if (!cert.value || saving.value) return
if (
!/^\d{1,12}(\.\d{1,6})?$/.test(body.totalQty) ||
!body.reason.trim() ||
(!body.sourceNote.trim() && !body.evidenceUrls)
) {
ElMessage.warning('请填写有效总量、更正理由与依据')
return
}
if (upload.value?.hasUploading()) {
ElMessage.warning('请等待凭据上传完成')
return
}
const id = cert.value.id,
type = kind.value,
signature = JSON.stringify({ id, type, ...body })
if (signature !== attempt.signature) attempt = { signature, id: crypto.randomUUID() }
saving.value = true
try {
await quotaAdjustmentApi.total(type, id, { ...body, operationId: attempt.id })
cert.value = await quotaCertApi.get(type, id)
logs.value = await quotaAdjustmentApi.logs(type, id)
ElMessage.success('总量更正已保存')
emit('saved')
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
+621
View File
@@ -0,0 +1,621 @@
<template>
<QuotaHistoryDialog ref="historyDialog" :can-edit="canEdit" @saved="load" />
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">购买证与运输证</h2></div>
<div class="filter-block">
<el-form label-position="top" @submit.prevent="load">
<div class="filter-grid">
<el-form-item label="证件种类"
><el-select v-model="kind" @change="load"
><el-option label="购买证" value="purchase" /><el-option
label="运输证"
value="transport" /></el-select
></el-form-item>
<el-form-item label="证件编号"
><el-input v-model="keyword" clearable placeholder="输入证件编号"
/></el-form-item>
<el-form-item label="状态"
><el-select v-model="status" clearable
><el-option
v-for="(label, value) in statusLabels"
:key="value"
:label="label"
:value="value" /></el-select
></el-form-item>
</div>
<div class="filter-actions-row"
><span class="filter-hint">先建档核对,再关联合同及产品;同一证件共用一个额度池。</span
><div class="filter-actions-end"
><el-button @click="reset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
><el-button type="primary" :loading="loading" @click="load"
><el-icon><Search /></el-icon>搜索</el-button
></div
></div
>
</el-form>
</div>
<div class="list-toolbar"
><div class="list-toolbar-row">
<span class="list-toolbar-selected">共 {{ rows.length }} 张</span>
<el-button v-if="canEdit" type="primary" @click="edit()"
><el-icon><Plus /></el-icon>新增证件</el-button
>
<el-button @click="scopeDialog?.open()"
><el-icon><Connection /></el-icon>关联合同产品</el-button
>
</div></div
>
<div class="list-body list-wrap">
<el-table
v-loading="loading"
:data="pageRows"
stripe
row-key="id"
class="list-table"
@row-dblclick="view"
>
<el-table-column label="证件编号" min-width="190"
><template #default="{ row }"
><el-link type="primary" @click="view(row)">{{ row.certNo }}</el-link></template
></el-table-column
>
<el-table-column prop="productNameOnCert" label="证面品名" min-width="150" />
<el-table-column label="状态" min-width="100"
><template #default="{ row }"
><el-tag size="small" :type="tagType(row.status)">{{
statusLabels[row.status as keyof typeof statusLabels]
}}</el-tag></template
></el-table-column
>
<el-table-column
v-for="column in qtyColumns"
:key="column.key"
:label="column.label"
min-width="130"
><template #default="{ row }"
>{{ row[column.key] }} {{ row.quantityUnit }}</template
></el-table-column
>
<el-table-column prop="validTo" label="有效期至" min-width="130" />
<el-table-column label="操作" min-width="320" fixed="right"
><template #default="{ row }">
<el-button v-if="canEdit" size="small" @click="edit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button
v-if="canEdit && row.status !== 'void'"
size="small"
@click="openConfirm(row)"
><el-icon><Check /></el-icon
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
>
<el-button size="small" @click="showContracts(row)"
><el-icon><Connection /></el-icon>关联合同</el-button
>
</template></el-table-column
>
</el-table>
<div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="page"
:page-size="20"
:total="rows.length"
layout="total, prev, pager, next"
/></div>
</div>
</section>
<el-dialog
v-model="formVisible"
:title="`${editingId ? '编辑' : '新增'}${kind === 'purchase' ? '购买证' : '运输证'}`"
width="960px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<el-form
:disabled="saving"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
>
<section class="view-section">
<el-form-item label="建档方式"
><el-radio-group v-model="form.entryMode" :disabled="!!editingId"
><el-radio value="manual">手工录入(可后补图片)</el-radio
><el-radio value="image_parse">图片解析后核对</el-radio></el-radio-group
></el-form-item
>
<el-form-item label="证件附件"
><AttachmentUploadField
ref="certAttachments"
:key="editSession"
:disabled="saving"
v-model="form.attachmentUrls"
folder="pl/quota-cert"
accept="image/*,.pdf"
@update:model-value="edited.add('attachmentUrls')"
/></el-form-item>
<template v-if="form.entryMode === 'image_parse' || !!form.attachmentUrls">
<el-select v-model="recognitionImage" placeholder="选择一张已上传的证件图片"
><el-option
v-for="url in (form.attachmentUrls || '').split(',').filter(Boolean)"
:key="url"
:value="url"
:label="url.split('/').pop()"
/></el-select>
<el-button :loading="recognizing" :disabled="!recognitionImage" @click="recognize"
><el-icon><Document /></el-icon>解析为候选字段</el-button
>
<p class="form-tip"
>请核对证号、数量、单位、有效次数和引用关系。解析只补充未编辑的空字段。</p
>
</template>
</section>
<section class="view-section"
><h3>证面信息</h3
><div class="form-grid-2">
<el-form-item
v-for="field in fields"
:key="field[0]"
:label="field[1]"
:prop="field[0]"
>
<el-date-picker
v-if="isDate(field[0])"
v-model="form[field[0]]"
type="date"
value-format="YYYY-MM-DD"
@change="edited.add(field[0])"
/>
<el-input
v-else
v-model="form[field[0]]"
:placeholder="
field[0] === 'validUseText' ? '例如:一次有效、多次有效' : `请输入${field[1]}`
"
@input="edited.add(field[0])"
/>
</el-form-item> </div
></section>
<section v-if="kind === 'transport'" class="view-section">
<el-form-item label="车牌号(多车共用额度)"
><el-select
v-model="form.vehicleNumbersJson"
multiple
filterable
allow-create
default-first-option
@change="edited.add('vehicleNumbersJson')"
/></el-form-item>
<h3>证面引用</h3>
<el-table :data="form.purchaseRefs" row-key="id" stripe>
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-select v-model="row.referenceType" @change="edited.add('purchaseRefs')"
><el-option
v-for="(label, value) in refLabels"
:key="value"
:value="value"
:label="label" /></el-select></template
></el-table-column>
<el-table-column label="引用证号" min-width="160"
><template #default="{ row }"
><el-input
v-model="row.referenceNo"
@input="edited.add('purchaseRefs')" /></template
></el-table-column>
<el-table-column label="对应购买证档案" min-width="200"
><template #default="{ row }"
><el-select
v-model="row.purchaseCertId"
filterable
clearable
@change="edited.add('purchaseRefs')"
><el-option
v-for="c in purchases"
:key="c.id"
:value="c.id"
:label="c.certNo" /></el-select></template
></el-table-column>
<el-table-column label="操作" min-width="260" fixed="right"
><template #default="{ $index }"
><el-button @click="removeReference($index)"
><el-icon><Delete /></el-icon>移除引用</el-button
></template
></el-table-column
>
</el-table>
<el-button @click="addReference"
><el-icon><Plus /></el-icon>添加引用</el-button
>
</section>
<el-form-item v-if="editingId" label="停止新增使用">
<el-select v-model="form.status" clearable placeholder="保持当前状态">
<el-option label="停用(可复核恢复)" value="suspended" />
<el-option label="作废(不可恢复)" value="void" />
</el-select>
</el-form-item>
<el-form-item label="系统备注"
><el-input v-model="form.remark" type="textarea"
/></el-form-item>
</el-form>
<template #footer
><el-button :disabled="saving" @click="formVisible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存草稿/修改</el-button
></template
>
</el-dialog>
<el-dialog
v-model="detailVisible"
title="证件详情"
width="920px"
align-center
class="dialog-form-layout"
>
<template v-if="detail">
<el-button @click="historyDialog?.open(kind, detail)"
><el-icon><Document /></el-icon>额度流水与总量更正</el-button
>
<div class="view-hero"
><h2>{{ detail.certNo }}</h2
><el-tag :type="tagType(detail.status)">{{ statusLabels[detail.status] }}</el-tag></div
>
<section class="view-section"
><div class="view-kv-grid"
><dl v-for="field in fields" :key="field[0]" class="view-kv"
><dt>{{ field[1] }}</dt
><dd>{{ detail[field[0] as keyof QuotaCert] ?? '—' }}</dd></dl
></div
></section
>
<section class="view-section"
><h3>共享额度</h3
><p v-for="column in qtyColumns" :key="column.key"
>{{ column.label }}:{{ detail[column.key as keyof QuotaCert] }}
{{ detail.quantityUnit }}</p
><AttachmentUploadField :model-value="detail.attachmentUrls || ''" disabled
/></section>
<section v-if="kind === 'transport'" class="view-section">
<h3>车号及证面引用</h3>
<p>车牌号:{{ detail.vehicleNumbersJson?.join('、') || '未填写' }}</p>
<el-table :data="detail.purchaseRefs || []" stripe row-key="id">
<el-table-column label="引用类型" min-width="160"
><template #default="{ row }"
><el-tag size="small" type="info">{{
refLabels[row.referenceType as keyof typeof refLabels]
}}</el-tag></template
></el-table-column
>
<el-table-column prop="referenceNo" label="证面引用号" min-width="180" />
<el-table-column label="对应购买证档案" min-width="180"
><template #default="{ row }">{{
purchases.find((p) => p.id === row.purchaseCertId)?.certNo ||
(row.purchaseCertId ? '无查看权限或已失效' : '未关联')
}}</template></el-table-column
>
</el-table>
</section>
<section class="view-section"
><h3>关联的山峡合同</h3
><el-empty v-if="!linkedContracts.length" description="尚未关联合同" :image-size="64" /><p
v-for="c in linkedContracts"
:key="c.id"
><el-link type="primary" @click="scopeDialog?.open(c.id)"
>{{ c.contractNo }} · {{ c.contractName }} — 查看适用产品</el-link
></p
><el-button v-if="canEdit" @click="scopeDialog?.open()"
><el-icon><Connection /></el-icon>关联另一合同</el-button
></section
>
</template>
<template #footer
><el-button @click="detailVisible = false"
><el-icon><Close /></el-icon>关闭</el-button
></template
>
</el-dialog>
<el-dialog
v-model="confirmVisible"
:title="confirmRow?.openingConfirmed ? '复核证件' : '期初核对'"
width="640px"
align-center
class="dialog-form-layout"
>
<el-form label-position="top">
<template v-if="!confirmRow?.openingConfirmed"
><el-form-item label="历史已用量(证面单位)"
><el-input v-model="opening.usedQty" /></el-form-item
><el-form-item label="一次有效证是否已有使用记录"
><el-switch v-model="opening.singleUseConsumed" /></el-form-item
><el-form-item label="历史来源说明"
><el-input v-model="opening.sourceNote" type="textarea" /></el-form-item
><el-form-item label="核对凭据"
><AttachmentUploadField
v-model="opening.evidenceUrls"
folder="pl/quota-cert" /></el-form-item
></template>
<el-form-item label="核对说明(必填)"
><el-input v-model="opening.reason" type="textarea"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="confirmVisible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button type="primary" :loading="saving" @click="confirm"
><el-icon><Check /></el-icon>确认启用</el-button
></template
>
</el-dialog>
<ContractScopeDialog ref="scopeDialog" :can-edit="canEdit" @saved="refreshDetails" />
</div>
</template>
<script setup lang="ts">
import QuotaHistoryDialog from './QuotaHistoryDialog.vue'
const historyDialog = ref<InstanceType<typeof QuotaHistoryDialog>>()
import {
Check,
Close,
Connection,
Delete,
Document,
Edit,
Plus,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import { fetchGetMenuCodes } from '@/api/auth'
import {
quotaCertApi,
type CertType,
type QuotaCert,
type ContractOption
} from '@/api/pl/quota-cert'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ContractScopeDialog from './ContractScopeDialog.vue'
import {
commonFields,
purchaseFields,
transportFields,
newCert,
certPayload,
mergeCandidates
} from './model'
const route = useRoute()
const kind = ref<CertType>('purchase')
const keyword = ref(''),
status = ref(''),
page = ref(1)
const rows = ref<QuotaCert[]>([]),
purchases = ref<QuotaCert[]>([])
const loading = ref(false),
saving = ref(false),
recognizing = ref(false),
canEdit = ref(false)
const formVisible = ref(false),
detailVisible = ref(false),
confirmVisible = ref(false)
const form = ref<Record<string, any>>(newCert('purchase'))
const formRef = ref(),
editingId = ref(''),
recognitionImage = ref('')
const certAttachments = ref<InstanceType<typeof AttachmentUploadField>>()
const edited = reactive(new Set<string>())
const detail = ref<QuotaCert>(),
linkedContracts = ref<ContractOption[]>([]),
confirmRow = ref<QuotaCert>()
const scopeDialog = ref<InstanceType<typeof ContractScopeDialog>>()
const opening = reactive({
operationId: '',
usedQty: '0',
singleUseConsumed: false,
reason: '',
sourceNote: '',
evidenceUrls: ''
})
const fields = computed(() => [
...commonFields,
...(kind.value === 'purchase' ? purchaseFields : transportFields)
])
const pageRows = computed(() => rows.value.slice((page.value - 1) * 20, page.value * 20))
const statusLabels = { draft: '待核对', active: '已启用', suspended: '已停用', void: '已作废' }
const refLabels = {
purchase_permit: '购买许可证',
purchase_filing: '购买备案证明',
export_permit: '出口许可证',
unknown: '待确认'
}
const qtyColumns = [
{ key: 'totalQty', label: '核定量' },
{ key: 'reservedQty', label: '已预占' },
{ key: 'usedQty', label: '已使用' },
{ key: 'availableQty', label: '可用余额' }
]
const tagType = (s: string) =>
s === 'active' ? 'success' : s === 'void' ? 'danger' : s === 'draft' ? 'warning' : 'info'
const isDate = (key: string) => ['issueDate', 'validFrom', 'validTo'].includes(key)
const rules = computed(() =>
Object.fromEntries(
[
'certNo',
'productNameOnCert',
'totalQty',
'quantityUnit',
'validUseText',
...(kind.value === 'purchase' ? ['buyerName', 'sellerName'] : ['shipperName'])
].map((k) => [k, [{ required: true, message: '请填写此项', trigger: 'blur' }]])
)
)
async function load() {
loading.value = true
try {
rows.value = await quotaCertApi.list(kind.value, {
keyword: keyword.value,
status: status.value || undefined
})
page.value = 1
} finally {
loading.value = false
}
}
function reset() {
keyword.value = ''
status.value = ''
load()
}
let editSession = 0
watch(
formVisible,
(visible) => {
if (!visible) {
editSession++
recognizing.value = false
}
},
{ flush: 'sync' }
)
watch(kind, () => {
editSession++
formVisible.value = false
})
async function edit(row?: QuotaCert) {
if (saving.value) return
const session = ++editSession
const targetKind = kind.value
const value = row
? certPayload(targetKind, await quotaCertApi.get(targetKind, row.id))
: newCert(targetKind)
const options = targetKind === 'transport' ? await quotaCertApi.list('purchase') : []
if (session !== editSession || kind.value !== targetKind) return
editingId.value = row?.id || ''
edited.clear()
recognitionImage.value = ''
form.value = value
purchases.value = options
recognizing.value = false
formVisible.value = true
}
async function save() {
if (saving.value) return
if (certAttachments.value?.hasUploading()) {
ElMessage.warning('请等待附件上传完成')
return
}
const session = editSession
saving.value = true
if (!(await formRef.value.validate().catch(() => false)) || session !== editSession) {
saving.value = false
return
}
try {
await quotaCertApi.save(
kind.value,
certPayload(kind.value, form.value),
editingId.value || undefined
)
formVisible.value = false
ElMessage.success('证件已保存')
await load()
} finally {
saving.value = false
}
}
async function recognize() {
if (recognizing.value || saving.value) return
const session = editSession,
targetKind = kind.value,
targetImage = recognitionImage.value
recognizing.value = true
try {
const result = await quotaCertApi.recognize(targetKind, targetImage)
if (
session !== editSession ||
!formVisible.value ||
kind.value !== targetKind ||
recognitionImage.value !== targetImage ||
!(form.value.attachmentUrls || '').split(',').includes(targetImage)
)
return
mergeCandidates(form.value as any, result.candidates, edited)
ElMessage.success(
Object.keys(result.invalidFields).length
? '部分字段未识别,请手工核对补全'
: '候选已填入,请核对后保存'
)
} finally {
if (session === editSession) recognizing.value = false
}
}
function addReference() {
form.value.purchaseRefs.push({
referenceType: 'unknown',
referenceNo: '',
purchaseCertId: null
})
edited.add('purchaseRefs')
}
function removeReference(index: number) {
form.value.purchaseRefs.splice(index, 1)
edited.add('purchaseRefs')
}
async function view(row: QuotaCert) {
if (kind.value === 'transport') purchases.value = await quotaCertApi.list('purchase')
const [cert, links] = await Promise.all([
quotaCertApi.get(kind.value, row.id),
quotaCertApi.certificateContracts(kind.value, row.id)
])
detail.value = cert
linkedContracts.value = links
detailVisible.value = true
}
const showContracts = view
async function refreshDetails() {
if (detail.value && detailVisible.value) await view(detail.value)
}
function openConfirm(row: QuotaCert) {
confirmRow.value = row
Object.assign(opening, {
operationId: crypto.randomUUID(),
usedQty: '0',
singleUseConsumed: false,
reason: '',
sourceNote: '',
evidenceUrls: ''
})
confirmVisible.value = true
}
async function confirm() {
if (!confirmRow.value || !opening.reason.trim()) {
ElMessage.warning('请填写核对说明')
return
}
saving.value = true
try {
if (confirmRow.value.openingConfirmed)
await quotaCertApi.verify(kind.value, confirmRow.value.id, {
operationId: opening.operationId,
reason: opening.reason
})
else await quotaCertApi.confirm(kind.value, confirmRow.value.id, opening)
confirmVisible.value = false
await load()
} finally {
saving.value = false
}
}
onMounted(async () => {
const p = await fetchGetMenuCodes()
canEdit.value =
!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit')
await load()
if (typeof route.query.contractId === 'string') scopeDialog.value?.open(route.query.contractId)
})
</script>
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
bindingPayload,
mergeCandidates,
newCert,
certPayload,
bindingProductEnabled
} from './model'
test('绑定保存保留主子ID及明确适用产品,剥离只读字段', () => {
assert.deepEqual(
bindingPayload([
{
id: 'l',
certType: 'purchase',
certId: 'g',
scopeRevision: 2,
products: [{ id: 's', contractProductId: 'p', scopeStatus: 'inactive' }]
}
]),
[
{
id: 'l',
certType: 'purchase',
certId: 'g',
linkStatus: 'active',
products: [{ id: 's', contractProductId: 'p', scopeStatus: 'inactive' }]
}
]
)
})
test('候选不覆盖手填、清空、零数值,新增只填未修改的空字段', () => {
const form = newCert('purchase')
form.totalQty = '0'
form.buyerName = ''
form.sellerName = '手填'
mergeCandidates(
form,
{ buyerName: '候选买方', sellerName: '候选卖方', totalQty: 30, certNo: 'G1' },
new Set(['buyerName'])
)
assert.equal(form.buyerName, '')
assert.equal(form.sellerName, '手填')
assert.equal(form.totalQty, '0')
assert.equal(form.certNo, 'G1')
})
test('编辑剥离余额和状态active,保留手工证和运输引用ID', () => {
const form = {
...newCert('transport'),
id: 'g',
status: 'active',
reservedQty: 20,
purchaseRefs: [
{ id: 'r', referenceNo: 'G1', referenceType: 'purchase_permit', purchaseCertId: 'g1' }
]
}
const payload = certPayload('transport', form)
assert.equal('id' in payload, false)
assert.equal('status' in payload, false)
assert.equal('reservedQty' in payload, false)
assert.equal(payload.purchaseRefs?.[0].id, 'r')
})
test('停用产品范围和停用主关联均不展示为可用', () => {
const binding = {
certType: 'purchase' as const,
certId: 'g',
linkStatus: 'active' as const,
products: [{ id: 's', contractProductId: 'p', scopeStatus: 'inactive' as const }]
}
assert.equal(bindingProductEnabled(binding, 'p'), false)
assert.equal(
bindingProductEnabled(
{ ...binding, products: [{ contractProductId: 'p', scopeStatus: 'active' }] },
'p'
),
true
)
assert.equal(bindingProductEnabled({ ...binding, linkStatus: 'inactive' }, 'p'), false)
})
+113
View File
@@ -0,0 +1,113 @@
import type { CertBinding, CertBody, CertType } from '@/api/pl/quota-cert'
export const commonFields = [
['certNo', '证件编号'],
['issueDate', '发证日期', 'date'],
['productNameOnCert', '证面品名'],
['totalQty', '核定数量'],
['quantityUnit', '数量单位'],
['validFrom', '有效期起', 'date'],
['validTo', '有效期止', 'date'],
['validUseText', '有效次数原文']
] as const
export const purchaseFields = [
['buyerName', '购买单位'],
['buyerAddress', '购买单位地址'],
['buyerLegalRepresentative', '购买方法定代表人'],
['buyerPhone', '购买方电话'],
['sellerName', '销售单位'],
['sellerAddress', '销售单位地址'],
['sellerLegalRepresentative', '销售方法定代表人'],
['sellerPhone', '销售方电话'],
['purchasePurpose', '购买用途']
] as const
export const transportFields = [
['shipperName', '托运单位'],
['shipperAddress', '托运单位地址'],
['shipperLegalRepresentative', '托运方法定代表人'],
['shipperPhone', '托运方电话'],
['packagingText', '包装原文'],
['carrierName', '承运单位'],
['carrierAddressPhone', '承运地址及电话'],
['transportMode', '运输方式'],
['routeText', '运输路线原文'],
['consigneeName', '收货单位'],
['applicantName', '申请单位']
] as const
export function newCert(kind: CertType): CertBody {
const body: Record<string, unknown> = {
entryMode: 'manual',
attachmentUrls: '',
remark: '',
orderNum: null
}
for (const field of [
...commonFields,
...(kind === 'purchase' ? purchaseFields : transportFields)
]) {
body[field[0]] =
field[0].endsWith('Date') || field[0].startsWith('validF') || field[0] === 'validTo'
? null
: ''
}
if (kind === 'transport') Object.assign(body, { vehicleNumbersJson: [], purchaseRefs: [] })
return body as unknown as CertBody
}
export function certPayload(kind: CertType, form: Record<string, any>): CertBody {
const result = newCert(kind) as unknown as Record<string, any>
for (const key of Object.keys(result)) if (key in form) result[key] = form[key]
if (['draft', 'suspended', 'void'].includes(form.status)) result.status = form.status
if (kind === 'transport')
result.purchaseRefs = (form.purchaseRefs || []).map((r: Record<string, unknown>) =>
Object.fromEntries(
['id', 'referenceType', 'referenceNo', 'purchaseCertId', 'remark', 'orderNum']
.filter((k) => k in r)
.map((k) => [k, r[k]])
)
)
return result as CertBody
}
export function mergeCandidates(
form: CertBody,
candidates: Partial<CertBody>,
edited: Set<string>
) {
const target = form as unknown as Record<string, unknown>
for (const [key, value] of Object.entries(candidates)) {
const current = target[key]
if (
!edited.has(key) &&
(current == null || current === '' || (Array.isArray(current) && current.length === 0))
) {
target[key] = value
}
}
}
export function bindingPayload(bindings: CertBinding[]): CertBinding[] {
return bindings.map((b) => ({
...(b.id ? { id: b.id } : {}),
certType: b.certType,
certId: b.certId,
linkStatus: b.linkStatus || 'active',
...(b.remark !== undefined ? { remark: b.remark } : {}),
...(b.orderNum !== undefined ? { orderNum: b.orderNum } : {}),
products: b.products.map((p) => ({
...(p.id ? { id: p.id } : {}),
contractProductId: p.contractProductId,
scopeStatus: p.scopeStatus || 'active',
...(p.remark !== undefined ? { remark: p.remark } : {}),
...(p.orderNum !== undefined ? { orderNum: p.orderNum } : {})
}))
}))
}
export function bindingProductEnabled(binding: CertBinding, productId: string): boolean {
return (
binding.linkStatus !== 'inactive' &&
binding.products.some((p) => p.contractProductId === productId && p.scopeStatus !== 'inactive')
)
}