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