diff --git a/backend/app/services/pl_quota_planning.py b/backend/app/services/pl_quota_planning.py index 11facf37e..de9ea2749 100644 --- a/backend/app/services/pl_quota_planning.py +++ b/backend/app/services/pl_quota_planning.py @@ -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 diff --git a/backend/tests/test_pl_quota_sources.py b/backend/tests/test_pl_quota_sources.py index 8156964c3..b45d3f484 100644 --- a/backend/tests/test_pl_quota_sources.py +++ b/backend/tests/test_pl_quota_sources.py @@ -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) diff --git a/web-admin/scripts/plan-detail-linked-certs.test.ts b/web-admin/scripts/plan-detail-linked-certs.test.ts new file mode 100644 index 000000000..c05958648 --- /dev/null +++ b/web-admin/scripts/plan-detail-linked-certs.test.ts @@ -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/) +}) diff --git a/web-admin/src/components/business/pl-plan-view-tabs/index.vue b/web-admin/src/components/business/pl-plan-view-tabs/index.vue index f1d7c282a..3502a9e8b 100644 --- a/web-admin/src/components/business/pl-plan-view-tabs/index.vue +++ b/web-admin/src/components/business/pl-plan-view-tabs/index.vue @@ -71,23 +71,6 @@

暂无车辆信息

-
-

关联购买证 / 运输证

-

- 证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭 -

-
-
- - {{ item.kind === 'purchase' ? '购买证' : '运输证' }} - - {{ item.certNo || item.certId }} - 预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }} -
-
-

暂无关联证件

-
-

装送货信息

-

危化品证书信息

- +

关联购买证 / 运输证

+

+ 证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭 +

+
+
+
+ + {{ item.kind === 'purchase' ? '购买证' : '运输证' }} + + {{ item.certNo || item.certId }} + 预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }} +
+
+ 证件附件 +
+ +
+ 未上传 +
+
+
+

暂无关联证件

@@ -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)); diff --git a/web-admin/src/views/m/pl/plan/detail.vue b/web-admin/src/views/m/pl/plan/detail.vue index 8f1633d17..c783ade7c 100644 --- a/web-admin/src/views/m/pl/plan/detail.vue +++ b/web-admin/src/views/m/pl/plan/detail.vue @@ -248,7 +248,37 @@
-

危化品证书信息

+

关联购买证 / 运输证

+

+ 证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭 +

+
+
+
+ {{ item.kind === 'purchase' ? '购买证' : '运输证' }} + {{ item.certNo || item.certId }} + 预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }} +
+
+
证件附件
+ +

未上传

+
+
+
+

暂无关联证件

查看用证 (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 {