feat(pl): 展示各装送段用证配置与独立占用明细并保留历史计量口径。

This commit is contained in:
wuzilong committed 2026-11-01 22:54:51 +08:00
1 parent 706260ff7a
commit b764eb02a7
10 files changed
+497 -40

No files matched your search

+11
View File
@@ -167,7 +167,15 @@ export const legalDocumentApi = {
request.patch(`${legalBase}/${id}`, { products }).then(pickBxData<LegalDocument>)
}
export type MeasurementVersion = 'per_stop_v2' | 'legacy_single_side'
export interface QuotaSourceOption {
sourceStopId?: string
stopOrder?: number
contractNo?: string | null
bindingsReadable?: boolean
certificates?: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[]
isToxic?: boolean
isExplosive?: boolean
sourceStopType: 'load' | 'delivery'
sourceStopProductId: string
planProductId: string
@@ -202,6 +210,7 @@ export interface QuotaCondition {
note: string
}
export interface QuotaConfiguration {
measurementVersion?: MeasurementVersion
sourceMapping?: QuotaSourcePair[] | null
purchaseCertIds: string[]
transportCertIds: string[]
@@ -209,6 +218,8 @@ export interface QuotaConfiguration {
conditions: QuotaCondition[]
}
export interface PlanQuota {
measurementVersion?: MeasurementVersion
stopProducts?: QuotaSourceOption[]
planId: string
planNo: string
licensePlate?: string
@@ -28,9 +28,23 @@
>已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。</p
>
<section class="view-section">
<h3>1. 明确唯一计量侧与装送配对</h3>
<h3>{{ isPerStop ? '1. 全部装送段用证需求' : '1. 原口径:唯一计量侧与装送配对' }}</h3>
<el-table v-if="isPerStop" :data="measured" stripe>
<el-table-column label="段产品" min-width="200"
><template #default="{ row }">{{
sourceLabel(`${row.sourceStopType}:${row.sourceStopProductId}`, measured)
}}</template></el-table-column
>
<el-table-column prop="contractNo" label="关联合同" min-width="160" />
<el-table-column prop="partyName" label="单位" min-width="160" />
<el-table-column label="需用量" min-width="120"
><template #default="{ row }"
>{{ row.quantity }} {{ row.unit }}</template
></el-table-column
>
</el-table>
<p v-if="!groups.length">当前计划没有易制毒产品,无需购买证、运输证额度。</p>
<div v-for="group in groups" :key="group.id" class="view-section">
<div v-for="group in isPerStop ? [] : groups" :key="group.id" class="view-section">
<strong>{{ group.name }}</strong>
<el-radio-group
:model-value="sides[group.id]"
@@ -62,7 +76,7 @@
v-for="other in opposite(group)"
:key="other.sourceStopProductId"
:value="other.sourceStopProductId"
:label="`${other.partyName || '未填写单位'} · ${other.quantity} ${other.unit} · ${other.sourceStopProductId.slice(-6)}`"
:label="`${other.partyName || '未填写单位'} · ${other.quantity} ${other.unit} · ${other.productName}`"
/>
</el-select>
</div>
@@ -215,7 +229,7 @@
: 'warning'
"
>{{ statusLabels[result.status] || result.status }}</el-tag
><p v-for="reason in result.reasons" :key="reason">{{ reason }}</p>
><p v-for="reason in result.reasons" :key="reason">{{ readableReason(reason) }}</p>
<div
v-for="row in result.diagnostics || []"
:key="row.certType + row.unit"
@@ -229,14 +243,17 @@
适用余额至多 {{ row.availableUpperBound }} {{ row.unit }};缺口至少
{{ row.gapLowerBound }} {{ row.unit }}
</p>
<p>{{ row.reason }}</p>
<p>{{ readableReason(row.reason) }}</p>
<p>{{
row.sourceIds.map((id) => sourceLabel(id, plan?.sourceOptions || [])).join(';')
}}</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"
prop="sourceLabel"
label="计量来源"
min-width="190" /><el-table-column
prop="sourceQuantity"
@@ -258,7 +275,7 @@
<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
><el-table-column prop="certNoSnapshot" label="证件" min-width="180" /><el-table-column
prop="usageStatus"
label="状态"
min-width="120"
@@ -293,6 +310,7 @@
</el-dialog>
</template>
<script setup lang="ts">
import { perStop, measuredSources, configurationPayload, sourceLabel } from './stop-model'
import { useUserStore } from '@/store/modules/user'
import { Check, Close, Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
@@ -329,6 +347,7 @@
const sides = reactive<Record<string, string>>({}),
pairs = reactive<Record<string, string[]>>({})
let previewVehicle: QuotaPreviewVehicle = {}
let revision = 0
let epoch = 0,
resolve: ((value: QuotaConfiguration | undefined) => void) | undefined
const groups = computed(() => {
@@ -348,9 +367,8 @@
}
return [...values.values()]
})
const measured = computed(() =>
groups.value.flatMap((g) => g[sides[g.id] as 'load' | 'delivery'] || [])
)
const isPerStop = computed(() => perStop(plan.value))
const measured = computed(() => measuredSources(plan.value, sides))
const selected = computed(() => [
...purchases.value
.filter((c) => config.value.purchaseCertIds.includes(c.id))
@@ -381,10 +399,11 @@
result.value?.[kind === 'purchase' ? 'purchaseAllocations' : 'transportAllocations'] || []
).map((row) => ({
...row,
sourceLabel: sourceLabel(row.sourceId, plan.value?.sourceOptions || []),
kind: kind === 'purchase' ? '购买证' : '运输证',
certNo:
selected.value.find((i) => i.kind === kind && i.cert.id === row.certId)?.cert.certNo ||
row.certId
'证号未提供'
}))
)
)
@@ -395,6 +414,7 @@
return group[sides[group.id] === 'load' ? 'delivery' : 'load']
}
function invalidate() {
revision++
context.value = undefined
result.value = undefined
config.value.conditions = []
@@ -404,14 +424,12 @@
invalidate()
}
function payload() {
return {
...config.value,
sourceMapping: measured.value.map((s) => ({
sourceStopType: s.sourceStopType,
sourceStopProductId: s.sourceStopProductId,
relatedStopProductIds: pairs[s.sourceStopProductId] || []
}))
}
return configurationPayload(plan.value, config.value, measured.value, pairs)
}
function readableReason(reason: string) {
return reason.replace(/(?:load|delivery):[A-Za-z0-9_-]+/g, (id) =>
sourceLabel(id, plan.value?.sourceOptions || [])
)
}
function condition(kind: CertType, cert: QuotaCert) {
let c = config.value.conditions.find((i) => i.certType === kind && i.certId === cert.id)
@@ -437,13 +455,14 @@
async function loadContext() {
if (!plan.value || busy.value) return
busy.value = true
const current = epoch
const current = epoch,
inputRevision = revision
try {
const value = await planQuotaApi.context(plan.value.planId, {
...payload(),
...previewVehicle
})
if (current !== epoch) return
if (current !== epoch || inputRevision !== revision) return
context.value = value
config.value.conditions = config.value.conditions.filter(
(c) =>
@@ -461,13 +480,14 @@
async function preview() {
if (!plan.value || busy.value) return
busy.value = true
const current = epoch
const current = epoch,
inputRevision = revision
try {
const value = await planQuotaApi.preview(plan.value.planId, {
...payload(),
...previewVehicle
})
if (current === epoch) result.value = value
if (current === epoch && inputRevision === revision) result.value = value
} finally {
if (current === epoch) busy.value = false
}
@@ -589,10 +609,12 @@
}
}
watch(
() => JSON.stringify(config.value.manualAllocations),
() => JSON.stringify([config.value.manualAllocations, config.value.conditions]),
() => {
revision++
result.value = undefined
}
},
{ flush: 'sync' }
)
function invalidateSession() {
epoch++
@@ -0,0 +1,95 @@
import type { PlanQuota, QuotaConfiguration, QuotaSourceOption } from '@/api/pl/quota-cert'
export const perStop = (plan?: PlanQuota) =>
(plan?.measurementVersion || plan?.configuration.measurementVersion) === 'per_stop_v2'
export function measuredSources(plan: PlanQuota | undefined, sides: Record<string, string>) {
return (plan?.sourceOptions || []).filter(
(s) => perStop(plan) || sides[s.planProductId] === s.sourceStopType
)
}
export function configurationPayload(
plan: PlanQuota | undefined,
config: QuotaConfiguration,
sources: QuotaSourceOption[],
pairs: Record<string, string[]>
) {
return {
...config,
sourceMapping: perStop(plan)
? null
: sources.map((s) => ({
sourceStopType: s.sourceStopType,
sourceStopProductId: s.sourceStopProductId,
relatedStopProductIds: pairs[s.sourceStopProductId] || []
}))
}
}
export function sourceLabel(id: string, sources: QuotaSourceOption[]) {
const source = sources.find((s) => `${s.sourceStopType}:${s.sourceStopProductId}` === id)
return source
? `${source.sourceStopType === 'load' ? '装货' : '送货'}段${source.stopOrder || 1} · ${source.productName}${source.partyName ? ` · ${source.partyName}` : ''}`
: '来源已不可用'
}
export function stopUsageRows(plan: PlanQuota) {
return (plan.stopProducts || plan.sourceOptions.map((s) => ({ ...s, isToxic: true }))).map(
(source) => {
const text = (kind: string) => {
if (!source.isToxic) return '无需两证'
if (
!perStop(plan) &&
plan.configuration.sourceMapping?.some(
(s) =>
s.sourceStopType !== source.sourceStopType &&
s.relatedStopProductIds.includes(source.sourceStopProductId)
)
)
return '原口径:关联追溯,不另计量'
const lines = plan.usages
.filter((u) => u.certType === kind)
.flatMap((u) =>
(u.items || [])
.filter(
(i: any) =>
i.sourceStopType === source.sourceStopType &&
i.sourceStopProductId === source.sourceStopProductId
)
.map((i: any) => {
const unit = i.quantityUnit || u.quantityUnit || '—'
return `${u.certNoSnapshot || '证号未提供'} · 预占 ${i.reservedQty ?? '—'} ${unit} · 已用 ${i.usedQty ?? '—'} ${unit}`
})
)
const bound = (source.certificates || []).filter(
(c) =>
c.certType === kind &&
!plan.usages.some(
(u) =>
u.certType === kind &&
u.certId === c.certId &&
(u.items || []).some(
(i: any) =>
i.sourceStopType === source.sourceStopType &&
i.sourceStopProductId === source.sourceStopProductId
)
)
)
lines.push(
...bound.map(
(c) => `${c.certNo} · 已绑定 · 预占 0 ${c.quantityUnit} · 已用 0 ${c.quantityUnit}`
)
)
if (source.bindingsReadable === false) lines.push('合同或证件不可读取')
return lines.join(';') || '缺配'
}
return {
...source,
label: sourceLabel(`${source.sourceStopType}:${source.sourceStopProductId}`, [source]),
purchase: text('purchase'),
transport: text('transport')
}
}
)
}
+100
View File
@@ -64,8 +64,64 @@
table-key="pl-plan-quota"
v-loading="loading"
:data="rows"
row-key="id"
:expand-row-keys="expandedIds"
@expand-change="expand"
stripe
class="list-table"
><el-table-column type="expand"
><template #default="{ row }">
<div
class="quota-expansion"
:style="expansionStyle"
v-loading="details[row.id]?.loading"
>
<el-alert
v-if="details[row.id]?.error"
title="段次用证加载失败"
type="error"
:closable="false"
><el-button @click="loadDetail(row.id)"
><el-icon><RefreshLeft /></el-icon>重试</el-button
></el-alert
>
<template v-else-if="details[row.id]?.data">
<el-tag
size="small"
:type="perStop(details[row.id].data) ? 'info' : 'warning'"
>{{
perStop(details[row.id].data) ? '各装送段分别计量' : '原口径:单侧计量'
}}</el-tag
>
<p class="form-tip"
>绑定不代表校验通过;购买证与运输证分别显示各自证面单位的预占和已用量。</p
>
<el-table :data="stopUsageRows(details[row.id].data!)" stripe>
<el-table-column prop="label" label="段产品" min-width="210" />
<el-table-column label="合同" min-width="150"
><template #default="{ row: item }">{{
item.contractNo || '未关联或不可读取'
}}</template></el-table-column
>
<el-table-column prop="partyName" label="单位" min-width="160" />
<el-table-column label="需用量" min-width="110"
><template #default="{ row: item }"
>{{ item.quantity }} {{ item.unit }}</template
></el-table-column
>
<el-table-column
prop="purchase"
label="购买证 · 预占 / 已用"
min-width="300"
/>
<el-table-column
prop="transport"
label="运输证 · 预占 / 已用"
min-width="300"
/>
</el-table>
</template>
</div> </template></el-table-column
><el-table-column
v-for="column in visibleDefs"
:key="column.prop"
@@ -132,6 +188,8 @@
</div>
</template>
<script setup lang="ts">
import { planQuotaApi, type PlanQuota } from '@/api/pl/quota-cert'
import { perStop, stopUsageRows } from '@/components/business/pl-plan-quota-dialog/stop-model'
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
@@ -162,10 +220,38 @@
loading = ref(false),
rows = ref<(Plan & { quotaStatus: string })[]>([])
const dialog = ref<InstanceType<typeof PlPlanQuotaDialog>>()
const expandedIds = ref<string[]>([])
const details = reactive<Record<string, { loading: boolean; data?: PlanQuota; error?: boolean }>>(
{}
)
function clearDetails() {
expandedIds.value = []
Object.keys(details).forEach((id) => delete details[id])
}
async function loadDetail(id: string) {
const current = epoch
const state = reactive<{ loading: boolean; data?: PlanQuota; error?: boolean }>({
loading: true
})
details[id] = state
try {
const value = await planQuotaApi.get(id)
if (current === epoch && details[id] === state) state.data = value
} catch {
if (current === epoch && details[id] === state) state.error = true
} finally {
if (current === epoch && details[id] === state) state.loading = false
}
}
function expand(row: { id: string }, expanded: { id: string }[]) {
expandedIds.value = expanded.map((r) => r.id)
if (expanded.some((r) => r.id === row.id) && !details[row.id]) void loadDetail(row.id)
}
let epoch = 0
async function load() {
const current = ++epoch
loading.value = true
clearDetails()
try {
const value = pickBxData<{ list: (Plan & { quotaStatus: string })[]; total: number }>(
await request.get('/transport/quota-certs/plans', {
@@ -196,6 +282,7 @@
() => JSON.stringify([user.accessToken, user.getUserInfo]),
() => {
epoch++
clearDetails()
rows.value = []
total.value = 0
page.value = 1
@@ -255,6 +342,10 @@
const tableWrapRef = ref<HTMLElement | null>(null)
const pagerRef = ref<HTMLElement | null>(null)
const tableRef = ref()
const { width: tableViewportWidth } = useElementSize(tableWrapRef)
const expansionStyle = computed(() => ({
width: tableViewportWidth.value > 0 ? `${tableViewportWidth.value}px` : undefined
}))
const { tableHeight } = useListTableViewportHeight({
tableEl: tableWrapRef,
pagerEl: pagerRef,
@@ -262,3 +353,12 @@
enabled: computed(() => !props.mobile)
})
</script>
<style scoped>
.quota-expansion {
box-sizing: border-box;
max-width: 100%;
padding: 16px;
text-align: left;
}
</style>