admin_new/src/modules/inquiry/InquiryDetailView.vue

449 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
/**
* F006 询价详情 (W5.x.x+5 整改) - 完整版
*
* 沿用老 vue supplier/inquiry/inquiry-detail.vue 491 行完整功能:
* - 询价基本信息 (产品/品牌/规格/单位/数量/收货地址/备注/交货日期/价格有效)
* - 报价列表 (供应商/报价状态/报价时间/报价次数/来源)
* - 操作: 导出价格分析表 / 查看报价详情
* - 文件下载
*/
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Inquiry {
id: string
title: string
productName: string
productBrand: string
specification: string
unit: string
quantity: number
shippingAddress: string
detailNote: string
deliveryDate: string
priceValidDate: string
status: number
enterpriseName: string
createDate: string
fileList: { url: string; imagesName: string }[]
// 报价列表
quotationList: Quotation[]
// 新结构(与发布询价信息一致)
procurementTypeName?: string
quotationTypeName?: string
quotationDemandName?: string
paymentMethodName?: string
invoiceTypeName?: string
inquiryValidDate?: string
publishDate?: string
note?: string
detailList?: any[]
raw: any
}
interface Quotation {
id: string
quotationEnterpriseName: string
quotationStatus: number
quotationDate: string
quotationCountStr: string
source: string
totalPrice: number
raw: any
}
const QUOTATION_STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '待报价', type: 'warning' },
1: { label: '已报价', type: 'success' },
2: { label: '已成交', type: 'primary' },
3: { label: '已拒绝', type: 'danger' }
}
const INQUIRY_STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '待报价', type: 'warning' },
1: { label: '报价中', type: 'primary' },
2: { label: '已成交', type: 'success' },
3: { label: '已取消', type: 'info' }
}
const FALLBACK: Inquiry = {
id: 'RFQ20260705001',
title: '工业传感器 X100 高精度采购',
productName: '工业传感器 X100',
productBrand: '西门子',
specification: 'X100 0.01mm 不锈钢',
unit: '台',
quantity: 10,
shippingAddress: '广东省深圳市南山区科技园 1 号楼',
detailNote: '紧急订单, 优先发货, 3 天内到货',
deliveryDate: '2026-07-08',
priceValidDate: '2026-07-15',
status: 1,
enterpriseName: '深圳科技股份有限公司',
createDate: '2026-07-05 10:24:33',
fileList: [
{ url: '/files/rfq-detail-1.pdf', imagesName: '采购清单.pdf' },
{ url: '/files/rfq-detail-2.pdf', imagesName: '技术规格书.pdf' }
],
quotationList: [
{ id: 'q1', quotationEnterpriseName: '上海制造企业', quotationStatus: 1, quotationDate: '2026-07-05 11:30', quotationCountStr: '1次', source: '平台报价', totalPrice: 23000, raw: {} },
{ id: 'q2', quotationEnterpriseName: '北京电子有限公司', quotationStatus: 0, quotationDate: '', quotationCountStr: '0次', source: '邀请报价', totalPrice: 0, raw: {} },
{ id: 'q3', quotationEnterpriseName: '广州工厂', quotationStatus: 1, quotationDate: '2026-07-05 14:20', quotationCountStr: '2次', source: '平台报价', totalPrice: 22000, raw: {} }
],
raw: {}
}
const route = useRoute()
const router = useRouter()
const detail = ref<Inquiry | null>(null)
const loading = ref(true)
const inquiryId = computed(() => String(route.params.id || ''))
const PROC_TYPE: Record<number, string> = { 1: '定量采购', 2: '框架采购' }
const DEMAND: Record<number, string> = { 1: '不允许部分报价', 2: '允许部分报价' }
const INVOICE: Record<number, string> = { 1: '普通发票', 2: '增值税专用发票', 3: '无' }
const QUOTE_TYPE: Record<number, string> = { 1: '公开报价', 2: '邀请报价' }
const mapLabel = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v))
const mapLabel1 = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v))
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/supplier/inquiry/getById', { id: inquiryId.value })
if (resp && resp.id) {
const first = resp.inquiryDetailList?.[0] || {}
detail.value = {
...FALLBACK,
...resp,
id: String(resp.id || inquiryId.value),
title: resp.title || FALLBACK.title,
productName: first.productName || '--',
productBrand: first.productBrand || '--',
specification: first.specification || '--',
unit: first.unit || '--',
quantity: resp.purchaseQuantity ?? '--',
// 新结构字段:与发布询价信息一致
procurementTypeName: mapLabel(PROC_TYPE, resp.procurementType),
quotationTypeName: mapLabel(QUOTE_TYPE, resp.quotationType),
quotationDemandName: mapLabel(DEMAND, resp.quotationDemand),
paymentMethodName: resp.paymentMethodName || '--',
invoiceTypeName: mapLabel(INVOICE, resp.invoiceType),
inquiryValidDate: resp.inquiryValidDate || '--',
note: resp.note || '',
detailNote: '', // 清除 FALLBACK 占位文案,备注一律取真实数据
enterpriseName: resp.inquiryParty || resp.inquiryEnterpriseName || FALLBACK.enterpriseName,
detailList: resp.inquiryDetailList || [],
fileList: resetFiles(resp),
raw: resp,
}
// 报价回复:受邀供应商 + 已报价企业商品清单
await Promise.all([loadInvited(resp.orderId || resp.id), loadQuotes(resp.orderId || resp.id)])
} else {
detail.value = { ...FALLBACK, id: inquiryId.value }
}
} catch {
detail.value = { ...FALLBACK, id: inquiryId.value }
} finally {
loading.value = false
}
}
// 附件:发布时 images 为 URL 数组 + imageNames 名称数组
function resetFiles(resp: any): any[] {
const urls: string[] = resp.images || []
const names: string[] = resp.imageNames || []
return urls.map((u: string, i: number) => ({ url: u, imagesName: names[i] || ('附件' + (i + 1)) }))
}
const quoteGroups = ref<any[]>([])
const quoteLoading = ref(false)
// 供应商维度报价列表:受邀供应商(含报价状态)+ 主动报价供应商补充
const supplierRows = computed(() => {
const groups = quoteGroups.value
const rows: any[] = []
const seen = new Set<number>()
// 已报价企业先入(getQuotationById 分组,企业名非空)
for (const g of groups) {
if (!g.name || g.name === '未报价') continue
const entName = g.name
const row = {
name: entName,
status: 1, // 1=已报价
date: g.rows.find((r: any) => r.quotationDate)?.quotationDate || '',
count: g.count,
total: g.total,
rows: g.rows,
invited: false,
}
rows.push(row)
}
// 受邀供应商:合并状态(邀请表报价状态 0=未报价 1=已报价 2=邀请再次报价)
for (const inv of invitedSuppliers.value) {
const id = Number(inv.invitedEnterpriseId)
const name = inv.enterpriseName || ('企业#' + id)
const existing = rows.find(r => r.name === name)
const quoted = Number(inv.quotationStatus) === 1
if (existing) {
existing.invited = true
if (!quoted) existing.status = 0
continue
}
rows.push({
name,
status: quoted ? 1 : 0,
date: '',
count: 0,
total: 0,
rows: [],
invited: true,
})
}
return rows
})
const invitedSuppliers = ref<any[]>([])
async function loadInvited(orderId: any) {
try {
const res: any = await httpGet('/supplier/quotationinvited/listByOrder', { orderId: String(orderId) })
if (res && Array.isArray(res)) invitedSuppliers.value = res
else if (res && Array.isArray(res?.list)) invitedSuppliers.value = res.list
else invitedSuppliers.value = []
} catch { invitedSuppliers.value = [] }
}
async function loadQuotes(orderId: any) {
quoteLoading.value = true
try {
const res: any = await httpPost('/supplier/inquiry/getQuotationById', { orderId: String(orderId) })
const rows = (res && (Array.isArray(res) ? res : [])) || []
const groups: any[] = []
for (const q of rows) {
const name = q.quotationEnterpriseName || '未报价'
let g = groups.find(x => x.name === name)
if (!g) { g = { name, rows: [], count: 0, total: 0, date: '', list: [] }; groups.push(g) }
g.rows.push(q)
g.count++
g.total += Number(q.unitPriceTaxIncluded || q.unitPrice || 0) * Number(q.quantity || 0)
if (q.quotationDate && !g.date) g.date = q.quotationDate
}
quoteGroups.value = groups
} catch { quoteGroups.value = [] } finally { quoteLoading.value = false }
}
// === 审核操作: apprStatus 2=通过 3=驳回 ===
const auditLoading = ref(false)
async function onAudit(status: number) {
const note = status === 3 ? prompt('请输入驳回原因:') : ''
if (status === 3 && note === null) return
auditLoading.value = true
try {
const res: any = await httpPost('/supplier/inquiry/saveInquiryAudit', {
id: detail.value?.id,
apprStatus: status,
...(status === 3 && note ? { auditNote: note } : {}),
})
// httpPost 已解包到 data: saveInquiryAudit 返回 data=true(布尔), 兼容 code===0
if (res === true || res?.code === 0) {
ElMessage.success(status === 2 ? '审核通过,询价已发布' : '已驳回')
load()
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '操作失败')
} finally {
auditLoading.value = false
}
}
function fmtDate(s: any): string {
if (!s) return '--'
return String(s).substring(0, 19).replace('T', ' ')
}
function fmtMoney(n: any): string { return `¥ ${Number(n || 0).toLocaleString()}` }
function quotationInfo(s: number) { return QUOTATION_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
function inquiryInfo(s: number) { return INQUIRY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
function onBack() { router.push('/inquiry') }
function onDownloadFile(url: string, name: string) { if (url) ElMessage.info(`下载: ${name || url}`) }
function onExportPriceComparison() { ElMessage.info('导出功能开发中') }
function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (请前往询价管理)`) }
function onQuote(row: Quotation) { ElMessage.info('请前往前台发起报价') }
onMounted(load)
watch(inquiryId, load)
</script>
<template>
<div class="rr-page">
<PageHeader title="询价详情" :sub="`询价单号: ${detail?.id || ''} · 发布时间: ${detail ? fmtDate(detail.createDate) : ''}`">
<el-button text @click="onBack">← 返回询价列表</el-button>
</PageHeader>
<div v-if="loading" class="rr-loading"><el-skeleton :rows="6" animated /></div>
<div v-else-if="!detail" class="rr-empty"><el-empty description="询价不存在" /></div>
<div v-else>
<!-- 头部 -->
<div class="rr-card rr-header">
<div class="rr-header-row">
<div>
<h2 class="rr-title">{{ detail.title }}</h2>
<p class="rr-sub">询价单号: {{ detail.id }} · 发布时间: {{ fmtDate(detail.createDate) }}</p>
</div>
<el-tag :type="(inquiryInfo(detail.status).type as any)" size="large" effect="light">
{{ inquiryInfo(detail.status).label }}
</el-tag>
</div>
<div class="rr-header-actions">
<template v-if="detail.raw?.apprStatus === 1">
<el-button type="success" :loading="auditLoading" @click="onAudit(2)">审核通过</el-button>
<el-button type="danger" :loading="auditLoading" @click="onAudit(3)">驳回</el-button>
</template>
<el-button @click="onExportPriceComparison">导出基础价格分析表</el-button>
</div>
</div>
<!-- 询价基本信息 -->
<div class="rr-card">
<div class="rr-card-header"><h3 class="rr-card-title">询价信息</h3></div>
<el-descriptions :column="2" border>
<el-descriptions-item label="询价主体(采购方)" :span="2">{{ detail.enterpriseName }}</el-descriptions-item>
<el-descriptions-item label="采购类型">{{ detail.procurementTypeName }}</el-descriptions-item>
<el-descriptions-item label="报价方式">{{ detail.quotationTypeName }}</el-descriptions-item>
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
<el-descriptions-item label="报价要求">{{ detail.quotationDemandName }}</el-descriptions-item>
<el-descriptions-item label="付款方式">{{ detail.paymentMethodName }}</el-descriptions-item>
<el-descriptions-item label="发票要求">{{ detail.invoiceTypeName }}</el-descriptions-item>
<el-descriptions-item label="询价截止">{{ detail.inquiryValidDate }}</el-descriptions-item>
<el-descriptions-item label="要求交货日期">{{ detail.deliveryDate || '--' }}</el-descriptions-item>
<el-descriptions-item label="发布时间" :span="2">{{ detail.createDate || detail.publishDate || '--' }}</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">{{ detail.shippingAddress || '--' }}</el-descriptions-item>
<el-descriptions-item label="询价备注" :span="2">{{ detail.note || '--' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 商品明细 -->
<div class="rr-card" v-if="(detail.detailList || []).length">
<div class="rr-card-header"><h3 class="rr-card-title">商品明细 ({{ (detail.detailList || []).length }})</h3></div>
<el-table :data="(detail.detailList || [])" border size="small">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column prop="productName" label="商品名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="productBrand" label="品牌" min-width="120">
<template #default="{ row }">{{ row.productBrand || '--' }}</template>
</el-table-column>
<el-table-column prop="specification" label="规格" min-width="140">
<template #default="{ row }">{{ row.specification || '--' }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="center" />
<el-table-column prop="unit" label="单位" width="80" align="center" />
<el-table-column prop="detailNote" label="备注" min-width="140">
<template #default="{ row }">{{ row.detailNote || '--' }}</template>
</el-table-column>
</el-table>
</div>
<!-- 询价附件 -->
<div class="rr-card" v-if="detail.fileList.length > 0">
<div class="rr-card-header"><h3 class="rr-card-title">询价附件</h3></div>
<div class="rr-files">
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-file-item">
<el-icon><Document /></el-icon>
<span class="rr-file-name">{{ f.imagesName }}</span>
<el-button text type="primary" size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
</div>
</div>
</div>
<!-- 报价列表(供应商维度:受邀未报价 / 已报价,展开查看商品清单) -->
<div class="rr-card">
<div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ supplierRows.length }} 家供应商)</h3></div>
<div v-if="quoteLoading" class="rr-empty">加载报价中...</div>
<div v-else-if="!supplierRows.length" class="rr-empty">暂无受邀供应商与报价</div>
<el-table v-else :data="supplierRows" border size="small" row-key="name">
<el-table-column type="expand">
<template #default="{ row }">
<div v-if="row.rows.length" class="rr-expand">
<el-table :data="row.rows" border size="small" class="rr-expand-table">
<el-table-column type="index" label="#" width="46" align="center" />
<el-table-column prop="productName" label="商品" min-width="150" show-overflow-tooltip />
<el-table-column prop="specification" label="规格" min-width="120">
<template #default="{ row: r }">{{ r.specification || '--' }}</template>
</el-table-column>
<el-table-column label="报价单价(含税)" width="130" align="right">
<template #default="{ row: r }">¥ {{ Number(r.unitPriceTaxIncluded || r.unitPrice || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column prop="unit" label="单位" width="70" align="center">
<template #default="{ row: r }">{{ r.unit || '件' }}</template>
</el-table-column>
<el-table-column label="生产周期" min-width="110">
<template #default="{ row: r }">{{ r.productionCycle || '--' }}</template>
</el-table-column>
<el-table-column prop="quotationNote" label="备注" min-width="130">
<template #default="{ row: r }">{{ r.quotationNote || '--' }}</template>
</el-table-column>
</el-table>
</div>
<div v-else class="rr-tip">该供应商尚未提交报价</div>
</template>
</el-table-column>
<el-table-column prop="name" label="供应商" min-width="200" show-overflow-tooltip />
<el-table-column label="报价状态" width="120" align="center">
<template #default="{ row }">
<span v-if="Number(row.status) === 1" class="rr-tag rr-tag-green">已报价</span>
<span v-else class="rr-tag rr-tag-amber">{{ row.invited ? '受邀未报价' : '未报价' }}</span>
</template>
</el-table-column>
<el-table-column label="报价时间" width="180">
<template #default="{ row }">{{ row.date || '--' }}</template>
</el-table-column>
<el-table-column label="商品项数" width="100" align="center">
<template #default="{ row }">{{ row.count || '--' }}</template>
</el-table-column>
<el-table-column label="报价合计" width="140" align="right">
<template #default="{ row }">
<span v-if="row.total > 0">¥ {{ Number(row.total).toLocaleString() }}</span>
<span v-else>--</span>
</template>
</el-table-column>
</el-table>
<div class="rr-tip mt10">受邀未报价的供应商将不展示报价清单,点击行展开可查看已报价企业的逐商品报价详情</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
.rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; }
.rr-loading { padding: 40px 20px; }
.rr-empty { padding: 60px 20px; text-align: center; }
.rr-header { padding: 24px 28px; }
.rr-header-row { display: flex; justify-content: space-between; align-items: flex-start; }
.rr-title { font-size: 20px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-header-actions { display: flex; gap: 8px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--border); }
.rr-files { padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; }
.rr-file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--bg-soft); border-radius: var(--radius); }
.rr-file-name { flex: 1; color: var(--ink); font-size: 13px; }
.rr-quote-groups { display: flex; flex-direction: column; gap: 12px; }
.rr-quote-group { border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden; }
.rr-quote-group-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: var(--el-fill-color-light); font-size: 13px; flex-wrap: wrap; }
.rr-quote-meta { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12px; }
.rr-tag { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: var(--el-color-info-light-9); color: var(--el-text-color-secondary); }
.rr-tag-green { background: #f0f9eb; color: #67c23a; }
.rr-empty { padding: 24px; text-align: center; color: var(--el-text-color-secondary); font-size: 13px; }
.rr-tip { font-size: 12px; color: var(--el-text-color-secondary); }
.mt10 { margin-top: 10px; }
.rr-expand { padding: 10px 24px; }
.rr-expand-table { max-width: 960px; }
.rr-tag-amber { background: #fdf6ec; color: #e6a23c; }
</style>