fix(pl): 计划详情用关联证件附件替换危化品证书区。
Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d5f3a79ea5
commit
bf47a26ba0
5 files changed
+310
-39
No files matched your search
@@ -351,7 +351,11 @@ def get_plan_quota(db, user, plan_id):
|
||||
),
|
||||
'usages': []}
|
||||
for usage in usages:
|
||||
_get_row(db, user, usage.cert_type, usage.cert_id)
|
||||
cert = _get_row(db, user, usage.cert_type, usage.cert_id)
|
||||
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
|
||||
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
|
||||
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
|
||||
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
|
||||
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
|
||||
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
|
||||
result['usages'].append(payload)
|
||||
return result
|
||||
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
|
||||
assert rows[0]['bindingsReadable'] is True
|
||||
assert rows[0]['certificates'] == []
|
||||
assert rows[1]['bindingsReadable'] is False
|
||||
|
||||
|
||||
def test_plan_quota_usages_include_cert_attachment_urls(case):
|
||||
from app.models.crm_models import CrmContract
|
||||
from app.models.pl_quota_models import (
|
||||
PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert,
|
||||
)
|
||||
from app.services.pl_quota_planning import get_plan_quota
|
||||
c = case
|
||||
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert):
|
||||
model.__table__.create(c.db.bind)
|
||||
c.db.add(PlPurchaseCert(
|
||||
id='p1', cert_no='G-BUY', product_name_on_cert='硫酸', valid_use_text='一次有效',
|
||||
buyer_name='买方', seller_name='卖方', total_qty=33, quantity_unit='吨',
|
||||
attachment_urls='https://example.com/buy.jpg', tenant_no='t', status='active',
|
||||
))
|
||||
c.db.add(PlTransportCert(
|
||||
id='t1', cert_no='Y-TR', product_name_on_cert='硫酸', valid_use_text='多次有效',
|
||||
shipper_name='货主', total_qty=70, quantity_unit='吨', vehicle_numbers_json=['皖A1'],
|
||||
attachment_urls='', tenant_no='t', status='active',
|
||||
))
|
||||
c.db.add(PlPlanCertUsage(
|
||||
id='u1', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
|
||||
cert_type='purchase', cert_id='p1', cert_no_snapshot='G-BUY', cert_revision=1,
|
||||
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
|
||||
))
|
||||
c.db.add(PlPlanCertUsage(
|
||||
id='u2', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
|
||||
cert_type='transport', cert_id='t1', cert_no_snapshot='Y-TR', cert_revision=1,
|
||||
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
|
||||
))
|
||||
c.db.flush()
|
||||
usages = {u['certId']: u for u in get_plan_quota(c.db, c.user, 'plan')['usages']}
|
||||
assert usages['p1']['attachmentUrls'] == 'https://example.com/buy.jpg'
|
||||
assert usages['p1']['certNo'] == 'G-BUY'
|
||||
assert usages['t1']['attachmentUrls'] in ('', None)
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const viewTabs = readFileSync(
|
||||
new URL('../src/components/business/pl-plan-view-tabs/index.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const h5Detail = readFileSync(
|
||||
new URL('../src/views/m/pl/plan/detail.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('Web 计划详情:关联证件在装送货后且无危化品证书信息区', () => {
|
||||
assert.doesNotMatch(viewTabs, /危化品证书信息/)
|
||||
assert.doesNotMatch(viewTabs, /PlPlanHazardCertViewTable/)
|
||||
const loadIdx = viewTabs.indexOf('装送货信息')
|
||||
const certIdx = viewTabs.indexOf('关联购买证 / 运输证')
|
||||
const freightIdx = viewTabs.indexOf('运费信息')
|
||||
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
|
||||
assert.match(viewTabs, /AttachmentUploadField/)
|
||||
assert.match(viewTabs, /attachmentUrls/)
|
||||
})
|
||||
|
||||
test('H5 计划详情:关联购买证/运输证替换危化品证书信息并展示附件', () => {
|
||||
assert.doesNotMatch(h5Detail, /危化品证书信息/)
|
||||
assert.match(h5Detail, /关联购买证 \/ 运输证/)
|
||||
assert.match(h5Detail, /关联证件/)
|
||||
const loadIdx = h5Detail.indexOf('plan-sec-load')
|
||||
const certIdx = h5Detail.indexOf('plan-sec-cert')
|
||||
const freightIdx = h5Detail.indexOf('plan-sec-freight')
|
||||
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
|
||||
assert.match(h5Detail, /AttachmentUploadField|CertAttachments/)
|
||||
assert.match(h5Detail, /attachmentUrls/)
|
||||
})
|
||||
@@ -71,23 +71,6 @@
|
||||
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
|
||||
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
|
||||
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
|
||||
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
|
||||
</el-tag>
|
||||
<span>{{ item.certNo || item.certId }}</span>
|
||||
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">装送货信息</h3>
|
||||
<PlPlanStopsSplitShell
|
||||
@@ -251,8 +234,31 @@
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
|
||||
<PlPlanHazardCertViewTable :products="hazardProducts" />
|
||||
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
|
||||
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-block">
|
||||
<div class="pl-plan-linked-cert-block__meta">
|
||||
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
|
||||
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
|
||||
</el-tag>
|
||||
<span class="pl-plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||||
<span class="pl-plan-linked-cert-block__qty"
|
||||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="pl-plan-linked-cert-block__slot">
|
||||
<span class="pl-plan-linked-cert-block__label">证件附件</span>
|
||||
<div v-if="item.attachmentUrls" class="pl-plan-linked-cert-block__attachment">
|
||||
<AttachmentUploadField :model-value="item.attachmentUrls" disabled />
|
||||
</div>
|
||||
<span v-else class="pl-plan-linked-cert-block__missing">未上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
@@ -430,7 +436,6 @@
|
||||
type BrandQtyRow
|
||||
} from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
|
||||
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
|
||||
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
|
||||
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
|
||||
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
|
||||
@@ -476,7 +481,6 @@
|
||||
} from '@/utils/pl-plan-stop-form'
|
||||
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
|
||||
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
|
||||
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { formatDate, formatDateTime } from '@/utils/format'
|
||||
import {
|
||||
@@ -1638,18 +1642,6 @@
|
||||
return fields
|
||||
})
|
||||
|
||||
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
|
||||
const hazardProducts = computed(() =>
|
||||
products.value
|
||||
.filter((item) => isHazardProduct(item))
|
||||
.map((item) => {
|
||||
const formRow = item.id ? planProductFormById.value.get(String(item.id)) : undefined
|
||||
const standardName = String(formRow?.standardProductName || '').trim()
|
||||
if (!standardName || standardName === item.productName) return item
|
||||
return { ...item, productName: standardName }
|
||||
})
|
||||
)
|
||||
|
||||
const linkedCertRows = ref<
|
||||
Array<{
|
||||
key: string
|
||||
@@ -1658,6 +1650,7 @@
|
||||
certNo?: string
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
attachmentUrls: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
@@ -1670,9 +1663,10 @@
|
||||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||||
kind: String(u.certType || u.cert_type || ''),
|
||||
certId: String(u.certId || u.cert_id || ''),
|
||||
certNo: u.certNo || u.cert_no,
|
||||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—'
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||||
}))
|
||||
} catch {
|
||||
linkedCertRows.value = []
|
||||
@@ -1932,6 +1926,93 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pl-plan-linked-certs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border, #d9e8e2);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__qty {
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__slot {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__label {
|
||||
flex-shrink: 0;
|
||||
color: var(--mint-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__attachment {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.attachment-upload) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:deep(.attachment-list) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.attachment-item) {
|
||||
width: 140px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.attachment-preview) {
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__missing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed var(--border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
background: var(--table-head, #f8fafc);
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pl-plan-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -248,7 +248,37 @@
|
||||
</section>
|
||||
|
||||
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
|
||||
<h3>危化品证书信息</h3>
|
||||
<h3>关联购买证 / 运输证</h3>
|
||||
<p v-if="plan.quotaCertPendingFulfill" class="plan-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="plan-linked-cert-block">
|
||||
<div class="plan-linked-cert-block__meta">
|
||||
<span
|
||||
class="status-tag"
|
||||
:class="
|
||||
item.kind === 'purchase' ? 'status-tag--success' : 'status-tag--warning'
|
||||
"
|
||||
>{{ item.kind === 'purchase' ? '购买证' : '运输证' }}</span
|
||||
>
|
||||
<span class="plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||||
<span class="plan-vehicle-card__meta"
|
||||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="plan-linked-cert-block__slot">
|
||||
<div class="plan-subcard__label">证件附件</div>
|
||||
<AttachmentUploadField
|
||||
v-if="item.attachmentUrls"
|
||||
:model-value="item.attachmentUrls"
|
||||
disabled
|
||||
/>
|
||||
<p v-else class="plan-linked-cert-block__missing">未上传</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="plan-empty-hint">暂无关联证件</p>
|
||||
<div class="plan-cert-actions">
|
||||
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
|
||||
<van-button
|
||||
@@ -536,10 +566,40 @@
|
||||
const errorText = ref('')
|
||||
const plan = ref<Plan | null>(null)
|
||||
const quotaOpen = ref(false)
|
||||
const linkedCertRows = ref<
|
||||
Array<{
|
||||
key: string
|
||||
kind: string
|
||||
certId: string
|
||||
certNo?: string
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
attachmentUrls: string
|
||||
}>
|
||||
>([])
|
||||
const needsLegalUseDocument = computed(() =>
|
||||
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
|
||||
)
|
||||
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`)
|
||||
|
||||
async function loadLinkedCerts(planId: string) {
|
||||
linkedCertRows.value = []
|
||||
try {
|
||||
const { planQuotaApi } = await import('@/api/pl/quota-cert')
|
||||
const data = await planQuotaApi.get(planId)
|
||||
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
|
||||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||||
kind: String(u.certType || u.cert_type || ''),
|
||||
certId: String(u.certId || u.cert_id || ''),
|
||||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||||
}))
|
||||
} catch {
|
||||
linkedCertRows.value = []
|
||||
}
|
||||
}
|
||||
const deliveryIndex = ref(0)
|
||||
const moreOpen = ref(false)
|
||||
const voidOpen = ref(false)
|
||||
@@ -568,7 +628,7 @@
|
||||
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
|
||||
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
|
||||
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' },
|
||||
{ key: 'cert', label: '危化品证', anchor: 'plan-sec-cert' },
|
||||
{ key: 'cert', label: '关联证件', anchor: 'plan-sec-cert' },
|
||||
{ key: 'freight', label: '运费', anchor: 'plan-sec-freight' },
|
||||
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
|
||||
]
|
||||
@@ -751,6 +811,7 @@
|
||||
loadVersion++
|
||||
actionVersion++
|
||||
plan.value = null
|
||||
linkedCertRows.value = []
|
||||
readDetail.value = null
|
||||
partialErrors.value = []
|
||||
errorText.value = ''
|
||||
@@ -807,9 +868,12 @@
|
||||
readDetail.value = detail
|
||||
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
|
||||
plan.value = data
|
||||
await loadLinkedCerts(id)
|
||||
if (!current()) return
|
||||
} catch {
|
||||
if (!current()) return
|
||||
plan.value = null
|
||||
linkedCertRows.value = []
|
||||
resetArrangeActions()
|
||||
errorText.value = '计划详情加载失败或记录不存在,请重试'
|
||||
} finally {
|
||||
@@ -1516,6 +1580,57 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.plan-linked-certs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9e8e2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
margin: 0;
|
||||
border: 1px dashed #d9e8e2;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mp-plan-quota-sheet {
|
||||
|
||||
Reference in new issue
Block a user