import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import axios from 'axios' import Drawer from '../components/Drawer' import SkuPicker from '../components/SkuPicker' import { SkuThumb, useSkuThumbs } from '../components/SkuThumb' import { PlusCircleIcon, PencilIcon, TrashIcon, PackageIcon, ClockIcon, InquiryIcon, DollarIcon, RefreshIcon } from '../components/Icons' import { useAuth, hasPermission } from '../auth' const STATUS_LABELS = { draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭' } const STATUS_TONE = { draft: 'mute', quoting: 'warn', quoted: 'ok', won: 'ok', lost: 'danger', closed: 'mute' } const CURRENCIES = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD'] /** * 报价合计。 * 口径说明:inquiry_items.currency 存的是「采购币种」,而 EXW 是拿采购价除以汇率算出来的, * 所以 EXW / 小计 / 合计都落在「报价币种」上,也就是 inquiry.currency。 * 这里不做跨币种换算 —— 一张询盘的报价币种是固定的(汇率那一栏的分母就是它)。 */ function summarize(items) { return items.reduce((sum, item) => sum + Number(item.exw || 0) * Number(item.quantity || 0), 0) } function money(value, digits = 2) { const number = Number(value) if (!Number.isFinite(number)) return '—' return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }) } /** 包装快照读出来做「一行摘要」,让报价行不用展开就知道箱规 */ function packagingBrief(snapshot) { if (!snapshot) return null let data = null try { data = JSON.parse(snapshot) } catch { return null } if (!data) return null const parts = [] if (data.carton_qty) parts.push(`${data.carton_qty} pcs/箱`) if (data.carton_length && data.carton_width && data.carton_height) { parts.push(`${data.carton_length}×${data.carton_width}×${data.carton_height} cm`) } if (data.carton_weight) parts.push(`毛重 ${data.carton_weight} kg`) if (data.packaging_material) parts.push(data.packaging_material) return parts.length ? parts : null } /** * 询盘明细编辑器。 * 两种输入方式: * 自动 —— 填加价率,EXW 由「采购价 ÷ 汇率 ×(1+加价率)」算出(这是报价的标准姿势) * 手动 —— 直接给 EXW,反算加价率(客户先给目标价时用) * 两者互斥,切换时保留当前能推导的量,避免把用户已经填好的数字清掉。 */ function ItemEditor({ inquiry, item, existingSkus = [], onClose, onSaved, canEdit }) { const isNew = !item const [skuChoice, setSkuChoice] = useState(isNew ? null : { sku: item.sku, display_sku: item.sku, source: 'existing' }) const [pickerOpen, setPickerOpen] = useState(isNew) const [mode, setMode] = useState('auto') const [form, setForm] = useState(() => ({ purchase_price: item ? String(item.purchase_price ?? '') : '', // 采购币种默认取人民币 —— 供应商报价绝大多数是 CNY,拿询盘的报价币种当默认值会把人带偏 currency: item?.currency || 'CNY', exchange_rate: item ? String(item.exchange_rate ?? '') : '', markup_rate: item ? String(((Number(item.markup_rate) || 0) * 100).toFixed(2).replace(/\.?0+$/, '')) : '25', manual_exw: '', quantity: item ? String(item.quantity ?? '') : '1' })) const [error, setError] = useState('') const [saving, setSaving] = useState(false) const purchasePrice = Number(form.purchase_price) const exchangeRate = Number(form.exchange_rate) const markupRate = Number(form.markup_rate) / 100 const manualExw = Number(form.manual_exw) const base = Number.isFinite(purchasePrice) && Number.isFinite(exchangeRate) && exchangeRate > 0 ? purchasePrice / exchangeRate : null const preview = useMemo(() => { if (base === null) return null if (mode === 'manual') { if (!Number.isFinite(manualExw) || manualExw <= 0) return null return { exw: manualExw, markup: manualExw / base - 1 } } if (!Number.isFinite(markupRate) || markupRate < -1) return null return { exw: base * (1 + markupRate), markup: markupRate } }, [mode, base, manualExw, markupRate]) const pickSku = async (choice) => { setSkuChoice(choice) setPickerOpen(false) setError('') // 命中 SKU 服务时返回的是完整货号,顺手把采购价/币种带出来,省一次手输 if (choice.source === 'manual') return setForm(current => ({ ...current, purchase_price: choice.price === null || choice.price === undefined ? current.purchase_price : String(choice.price), currency: choice.currency || current.currency })) } const submit = async (event) => { event.preventDefault() if (!skuChoice?.sku) { setError('请先选择货号'); return } const quantity = Number(form.quantity) if (!Number.isInteger(quantity) || quantity <= 0) { setError('数量必须是大于 0 的整数'); return } if (!Number.isFinite(purchasePrice) || purchasePrice <= 0) { setError('采购价必须是大于 0 的数字'); return } if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { setError('汇率必须是大于 0 的数字'); return } const payload = { sku: skuChoice.sku, purchase_price: purchasePrice, currency: form.currency, exchange_rate: exchangeRate, quantity } if (mode === 'manual') { if (!Number.isFinite(manualExw) || manualExw <= 0) { setError('手动 EXW 必须是大于 0 的数字'); return } payload.manual_exw = manualExw } else { payload.markup_rate = markupRate } setSaving(true) setError('') try { if (isNew) await axios.post(`/api/inquiries/${inquiry.id}/items`, payload) else await axios.patch(`/api/inquiry-items/${item.id}`, payload) onSaved() } catch (saveError) { setError(saveError?.response?.data?.error || '保存失败,请稍后重试') } finally { setSaving(false) } } const brief = item ? packagingBrief(item.packaging_snapshot) : null return ( <> {canEdit && ( )} )} >
货号
{skuChoice ? ( <>
{skuChoice.display_sku || skuChoice.sku} {skuChoice.name || skuChoice.spec || (skuChoice.source === 'manual' ? '手工输入货号' : '')}
{canEdit && ( )} ) : (

还没有选择货号。

)}
报价参数
setForm({ ...form, purchase_price: event.target.value })} />
setForm({ ...form, exchange_rate: event.target.value })} />
setForm({ ...form, quantity: event.target.value })} />
定价方式
{mode === 'auto' ? (
setForm({ ...form, markup_rate: event.target.value })} /> %
) : (
setForm({ ...form, manual_exw: event.target.value })} />
)}
{preview ? (preview.markup + 1).toFixed(4) : '—'}
EXW = 采购价 ÷ 汇率 ×(1 + 加价率)
EXW 单价 报价币种 {inquiry?.currency || '—'} {preview ? `${money(preview.exw, 4)} ${inquiry?.currency || ''}` : '—'}
{base === null ? ( 填好采购价和汇率后自动计算。 ) : ( <> 采购价折报价币 {money(base, 4)} 加价率 {preview ? `${(preview.markup * 100).toFixed(2)}%` : '—'} 小计 {preview ? `${money(preview.exw * Number(form.quantity || 0), 2)} ${inquiry?.currency || ''}` : '—'} )}
{brief && ( <>
包装(本次报价时的快照)

快照在报价那一刻定格,之后产品包装信息再改也不会动这行报价。

)} {error &&

{error}

}
{pickerOpen && (
(isNew ? onClose() : setPickerOpen(false))}>
event.stopPropagation()}> (isNew ? onClose() : setPickerOpen(false))} onPick={pickSku} />
)} ) } function InquiryDetail() { const { id } = useParams() const { user } = useAuth() const canEdit = hasPermission(user, 'inquiry.edit') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [editor, setEditor] = useState(null) // { item } 或 { item: null } 表示新增 const [expandedPackaging, setExpandedPackaging] = useState(null) /* 明细里的货号只在本地库里,图片得按货号批量问 SKU 服务要 */ const itemThumbs = useSkuThumbs((data?.items || []).map(row => row.sku)) const load = useCallback(async () => { setLoading(true) setError('') try { const response = await axios.get(`/api/inquiries/${id}`) setData(response.data) } catch (loadError) { setError(loadError?.response?.data?.error || '读取询盘失败') } finally { setLoading(false) } }, [id]) useEffect(() => { load() }, [load]) const updateStatus = async (status) => { try { await axios.patch(`/api/inquiries/${id}`, { status }) await load() } catch (statusError) { setError(statusError?.response?.data?.error || '更新状态失败') } } const removeItem = async (item) => { if (!window.confirm(`删除明细「${item.product_name || item.sku}」?删除后不可恢复。`)) return try { await axios.delete(`/api/inquiry-items/${item.id}`) await load() } catch (removeError) { setError(removeError?.response?.data?.error || '删除失败,请稍后重试') } } const inquiry = data?.inquiry const items = data?.items || [] const totals = useMemo(() => summarize(items), [items]) if (loading) return
加载询盘…
if (!inquiry) { return (

{error || '询盘不存在'}

返回询盘列表
) } return (
询盘管理 / {inquiry.inquiry_no}

{inquiry.title || '未命名询盘'}

{inquiry.customer_name || '未知客户'} {' · '} {inquiry.inquiry_date} {' · '} {STATUS_LABELS[inquiry.status] || inquiry.status}

{canEdit && ( )}
报价明细{items.length}
总数量 {items.reduce((sum, item) => sum + Number(item.quantity || 0), 0).toLocaleString('zh-CN')}
报价合计 {items.length ? `${money(totals, 2)} ${inquiry.currency || ''}` : '—'}
{error &&

{error}

}

报价明细

{canEdit ? ( ) : ( {STATUS_LABELS[inquiry.status] || inquiry.status} )}
{items.length === 0 ? (

这次询盘还没有报价行

点右上角「添加货号」,从已有 SKU 里选,或直接敲完整货号。

) : (
{items.map(item => { const brief = packagingBrief(item.packaging_snapshot) const open = expandedPackaging === item.id const subtotal = Number(item.exw || 0) * Number(item.quantity || 0) return ( {open && brief && ( )} ) })}
货号 产品 / 供应商 采购价 汇率 加价率 加价系数 EXW{inquiry.currency} 数量 小计{inquiry.currency} 包装 操作
{item.sku}
{item.product_name || '—'}
{item.supplier_name || '未指派供应商'}
{money(item.purchase_price, 2)} {item.currency} {money(item.exchange_rate, 4)} {(Number(item.markup_rate || 0) * 100).toFixed(2)}% {money(item.markup_factor, 4)} {money(item.exw, 4)} {Number(item.quantity || 0).toLocaleString('zh-CN')} {money(subtotal, 2)} {brief ? ( ) : ( 未维护 )} {canEdit && ( )} {canEdit && ( )}
报价时的包装快照
    {brief.map(text =>
  • {text}
  • )}
)}

跟进记录

去客户详情记录跟进

跟进记录统一挂在客户身上,记录时可以在「关联询盘」里选中本次询盘({inquiry.inquiry_no}), 这样客户时间线和工作台待跟进都能对上。

{editor && ( row.id !== editor.item?.id).map(row => row.sku)} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); load() }} canEdit={canEdit} /> )}
) } export default InquiryDetail