front/src/views/order/priceCheck/recommendChannel.ts
zzh c6a4484448 feat: 查价价卡展示贸易条款与产品分组,并优化 H5/登录与顾问体验
销售产品可配置 DDP/DDU;查价返回分组与贸易条款并展示在价卡上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:48:36 +08:00

476 lines
16 KiB
TypeScript
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.

import { getSalesProductDetail } from '@/api/channel/salesProduct'
import { callMcpTool } from '@/api/mcp'
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs'
export type QuoteCandidate = {
rowKey: string
productId?: number | string
productCode?: string
shippingMethod: string
cargoTypeName?: string
warehouse?: string
deliveryTime?: string
totalCostNum: number
maxDeliveryDays: number
isPriceOptimal?: boolean
isTimeOptimal?: boolean
guide?: string
hasGuide?: boolean
}
/** 对照对客说明后的承运判定 */
export type ShipVerdict = 'ok' | 'caution' | 'forbid' | 'unknown'
export type RecommendAlternative = {
rowKey: string
verdict: ShipVerdict
blurb: string
}
export type RecommendResult = {
rowKey: string
reason: string
source: 'ai' | 'local' | 'mcp'
verdict: ShipVerdict
/** 对客说明中支撑判定的原文摘录 */
evidence: string[]
cargoName: string
alternatives: RecommendAlternative[]
}
const FORBIDDEN_HINTS = ['禁运', '不收', '不可走', '不能走', '拒收', '禁止', '不得寄', '无法承运', '不接受']
const ALLOWED_HINTS = ['可走', '可寄', '可发', '接受', '适用', '支持', '允许', '可承运', '建议']
const CAUTION_HINTS = ['需确认', '需申报', '敏感', '限寄', '可能', '视情况', '提前沟通', '特殊要求']
const CARGO_KEYWORDS: Array<{ keys: string[]; tags: string[] }> = [
{ keys: ['电池', '带电', '锂电', '充电宝', '移动电源'], tags: ['带电', '电池', '锂电', '敏感'] },
{ keys: ['液体', '化妆', '护肤', '香水', '精油'], tags: ['液体', '化妆', '敏感'] },
{ keys: ['粉末', '奶粉', '蛋白粉'], tags: ['粉末', '敏感'] },
{ keys: ['食品', '零食', '茶叶', '保健'], tags: ['食品', '敏感'] },
{ keys: ['服装', '衣服', '鞋子', '箱包'], tags: ['普货', '服装'] },
{ keys: ['数码', '手机', '电脑', '耳机'], tags: ['普货', '带电', '数码'] },
]
const MCP_GUIDE_TOOL = 'oms.channel_guide'
const MCP_GUIDE_CONCURRENCY = 6
const MCP_GUIDE_MAX_PRODUCTS = 10
function normalizeGuide(detail: Record<string, any> | undefined) {
return String(detail?.customerGuideMarkdown ?? detail?.CustomerGuideMarkdown ?? '').trim()
}
function normalizeVerdict(raw: unknown): ShipVerdict {
const v = String(raw || '')
.trim()
.toLowerCase()
if (['ok', 'allow', 'allowed', 'pass', '可寄', '可走', '可发'].includes(v)) return 'ok'
if (['caution', 'warn', 'warning', '慎发', '需确认', '敏感'].includes(v)) return 'caution'
if (['forbid', 'ban', 'blocked', '禁运', '不可寄', '不收'].includes(v)) return 'forbid'
return 'unknown'
}
function extractEvidenceFromGuide(guide: string, productName: string, limit = 2): string[] {
const text = String(guide || '').trim()
if (!text) return []
const name = productName.trim()
const lines = text
.split(/\n+/)
.map((l) => l.replace(/^#+\s*/, '').replace(/^[-*•]\s*/, '').trim())
.filter((l) => l.length >= 4 && l.length <= 120)
const hits: string[] = []
const lowerName = name.toLowerCase()
for (const line of lines) {
const lower = line.toLowerCase()
const nameHit =
!!lowerName &&
(lower.includes(lowerName) ||
lowerName.split(/\s+/).some((w) => w.length > 1 && lower.includes(w)))
const hintHit = [...FORBIDDEN_HINTS, ...ALLOWED_HINTS, ...CAUTION_HINTS].some((h) =>
lower.includes(h.toLowerCase()),
)
if (nameHit || hintHit) hits.push(line)
if (hits.length >= limit) break
}
if (!hits.length && lines[0]) hits.push(lines[0])
return hits
}
function parseMcpGuideContent(content: string): { guide: string; hasGuide: boolean } {
try {
const parsed = JSON.parse(content) as Record<string, any>
const items = (parsed?.items ?? parsed?.Items ?? []) as any[]
const first = items[0]
if (!first) return { guide: '', hasGuide: false }
const guide = String(
first.customerGuideMarkdown ?? first.CustomerGuideMarkdown ?? '',
).trim()
const hasGuide = Boolean(first.hasGuide ?? first.HasGuide ?? !!guide)
return { guide, hasGuide }
} catch {
return { guide: '', hasGuide: false }
}
}
async function mapPool<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length)
let cursor = 0
async function run() {
while (cursor < items.length) {
const idx = cursor++
results[idx] = await worker(items[idx])
}
}
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
await Promise.all(runners)
return results
}
/**
* 优先走 MCP「查渠道对客说明」,失败再回落销售产品详情接口。
* 同一 productCode / productId 只拉一次。
*/
export async function loadGuidesForCandidates(
candidates: QuoteCandidate[],
cargoName: string,
): Promise<QuoteCandidate[]> {
type Key = string
const groups = new Map<Key, QuoteCandidate[]>()
for (const item of candidates) {
const code = String(item.productCode || '').trim()
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
const key = code ? `code:${code}` : id ? `id:${id}` : ''
if (!key) continue
const list = groups.get(key) ?? []
list.push(item)
groups.set(key, list)
}
const keys = [...groups.keys()].slice(0, MCP_GUIDE_MAX_PRODUCTS)
const guideMap = new Map<Key, { guide: string; hasGuide: boolean; via: 'mcp' | 'api' }>()
await mapPool(keys, MCP_GUIDE_CONCURRENCY, async (key) => {
const sample = groups.get(key)![0]
const productCode = String(sample.productCode || '').trim()
const productName = String(sample.shippingMethod || '').trim()
if (productCode || productName) {
try {
const args: Record<string, string> = { cargoName: cargoName.trim() }
if (productCode) args.productCode = productCode
else args.productName = productName
const res = await callMcpTool(MCP_GUIDE_TOOL, JSON.stringify(args))
if (res.success && res.content) {
const parsed = parseMcpGuideContent(res.content)
if (parsed.guide || parsed.hasGuide === false) {
guideMap.set(key, { ...parsed, via: 'mcp' })
return
}
}
} catch {
// fall through to detail API
}
}
const id = sample.productId == null || sample.productId === '' ? '' : String(sample.productId)
if (!id) {
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
return
}
try {
const detail = await getSalesProductDetail(id)
const guide = normalizeGuide(detail as Record<string, any>)
guideMap.set(key, { guide, hasGuide: !!guide, via: 'api' })
} catch {
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
}
})
return candidates.map((item) => {
const code = String(item.productCode || '').trim()
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
const key = code ? `code:${code}` : id ? `id:${id}` : ''
const hit = key ? guideMap.get(key) : undefined
return {
...item,
guide: hit?.guide || '',
hasGuide: hit?.hasGuide ?? false,
}
})
}
function isForbidden(guide: string, productName: string) {
const text = guide.toLowerCase()
const name = productName.toLowerCase()
if (!guide) return false
for (const hint of FORBIDDEN_HINTS) {
if (!text.includes(hint.toLowerCase())) continue
if (text.includes(name) || name.split(/\s+/).some((w) => w.length > 1 && text.includes(w))) {
return true
}
const hitTag = CARGO_KEYWORDS.some(
(group) =>
group.keys.some((k) => name.includes(k.toLowerCase())) &&
group.tags.some((tag) => text.includes(tag.toLowerCase())),
)
if (hitTag) return true
}
return false
}
function inferLocalVerdict(item: QuoteCandidate, productName: string): ShipVerdict {
const guide = String(item.guide || '')
if (!guide) return 'unknown'
if (isForbidden(guide, productName)) return 'forbid'
const lower = guide.toLowerCase()
const name = productName.toLowerCase()
const nameInGuide =
!!name &&
(lower.includes(name) || name.split(/\s+/).some((w) => w.length > 1 && lower.includes(w)))
const cautionHit = CAUTION_HINTS.some((h) => lower.includes(h.toLowerCase()))
const allowHit = ALLOWED_HINTS.some((h) => lower.includes(h.toLowerCase()))
if (nameInGuide && cautionHit) return 'caution'
if (nameInGuide || allowHit) return 'ok'
if (cautionHit) return 'caution'
return 'unknown'
}
function scoreCandidate(item: QuoteCandidate, productName: string) {
const guide = String(item.guide || '')
const name = productName.trim()
let score = 0
const reasons: string[] = []
const verdict = inferLocalVerdict(item, name)
if (verdict === 'forbid') {
return { score: -1000, reasons: ['线路说明提示该品类可能禁运/不收'], verdict }
}
if (!guide) {
score += 5
reasons.push('暂无线路说明,仅按报价择优')
} else {
score += 20
const lowerGuide = guide.toLowerCase()
const lowerName = name.toLowerCase()
if (lowerName && lowerGuide.includes(lowerName)) {
score += 40
reasons.push('对客说明中直接提到该品名')
}
for (const hint of ALLOWED_HINTS) {
if (lowerGuide.includes(hint.toLowerCase())) {
score += 8
reasons.push(`说明含“${hint}”`)
break
}
}
for (const group of CARGO_KEYWORDS) {
const nameHit = group.keys.some((k) => lowerName.includes(k.toLowerCase()))
if (!nameHit) continue
const tagHit = group.tags.some((tag) => lowerGuide.includes(tag.toLowerCase()))
if (tagHit) {
score += 25
reasons.push(`品名与对客说明品类匹配(${group.keys[0]})`)
}
}
}
if (verdict === 'ok') score += 12
if (verdict === 'caution') score += 2
if (verdict === 'unknown') score -= 4
if (item.isPriceOptimal) {
score += 30
reasons.push('价格最优')
}
if (item.isTimeOptimal) {
score += 16
reasons.push('时效最优')
}
if (Number.isFinite(item.totalCostNum)) {
score += Math.max(0, 20 - Math.min(20, item.totalCostNum / 50))
}
if (Number.isFinite(item.maxDeliveryDays)) {
score += Math.max(0, 10 - Math.min(10, item.maxDeliveryDays))
}
return { score, reasons, verdict }
}
export function recommendLocally(
productName: string,
candidates: QuoteCandidate[],
): RecommendResult | null {
if (!candidates.length) return null
const ranked = candidates
.map((item) => {
const scored = scoreCandidate(item, productName)
return { item, ...scored }
})
.filter((x) => x.score > -500)
.sort((a, b) => b.score - a.score || a.item.totalCostNum - b.item.totalCostNum)
const best = ranked[0]
if (!best) return null
const alternatives: RecommendAlternative[] = ranked
.slice(1, 4)
.filter((x) => x.verdict !== 'forbid')
.map((x) => ({
rowKey: x.item.rowKey,
verdict: x.verdict,
blurb: `${x.item.shippingMethod} · ¥${Number(x.item.totalCostNum || 0).toFixed(2)}`,
}))
const usedMcp = candidates.some((c) => c.hasGuide || c.guide)
return {
rowKey: best.item.rowKey,
reason: `推荐「${best.item.shippingMethod}」:${best.reasons.slice(0, 3).join(';') || '综合报价与对客说明择优'}`,
source: usedMcp ? 'mcp' : 'local',
verdict: best.verdict,
evidence: extractEvidenceFromGuide(String(best.item.guide || ''), productName),
cargoName: productName.trim(),
alternatives,
}
}
function extractJsonObject(text: string): Record<string, any> | null {
const raw = String(text || '').trim()
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
const match = raw.match(/\{[\s\S]*\}/)
if (!match) return null
try {
return JSON.parse(match[0])
} catch {
return null
}
}
}
function buildResultFromAi(
parsed: Record<string, any>,
withGuides: QuoteCandidate[],
productName: string,
local: RecommendResult | null,
): RecommendResult | null {
const rowKey = String(parsed?.rowKey ?? parsed?.RowKey ?? '').trim()
if (!rowKey || !withGuides.some((c) => c.rowKey === rowKey)) return null
const hit = withGuides.find((c) => c.rowKey === rowKey)!
const reason = String(parsed?.reason ?? parsed?.Reason ?? '').trim()
const verdict = normalizeVerdict(parsed?.verdict ?? parsed?.Verdict)
const evidenceRaw = parsed?.evidence ?? parsed?.Evidence
const evidence = Array.isArray(evidenceRaw)
? evidenceRaw.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 3)
: extractEvidenceFromGuide(String(hit.guide || ''), productName)
const altRaw = parsed?.alternatives ?? parsed?.Alternatives
let alternatives: RecommendAlternative[] = []
if (Array.isArray(altRaw)) {
alternatives = altRaw
.map((a: any) => ({
rowKey: String(a?.rowKey ?? a?.RowKey ?? '').trim(),
verdict: normalizeVerdict(a?.verdict ?? a?.Verdict),
blurb: String(a?.blurb ?? a?.Blurb ?? a?.reason ?? '').trim(),
}))
.filter((a) => a.rowKey && withGuides.some((c) => c.rowKey === a.rowKey) && a.rowKey !== rowKey)
.slice(0, 3)
}
if (!alternatives.length && local?.alternatives?.length) {
alternatives = local.alternatives.filter((a) => a.rowKey !== rowKey).slice(0, 3)
}
return {
rowKey,
reason:
reason ||
`AI 对照对客说明后推荐:${hit.shippingMethod || rowKey}`,
source: 'ai',
verdict: verdict === 'unknown' ? inferLocalVerdict(hit, productName) : verdict,
evidence: evidence.length ? evidence : extractEvidenceFromGuide(String(hit.guide || ''), productName),
cargoName: productName.trim(),
alternatives,
}
}
/**
* 查价页 AI 推荐:先 MCP 拉对客说明,再让模型给出可寄判定 + 理由 + 证据 + 备选。
*/
export async function recommendWithAi(
productName: string,
candidates: QuoteCandidate[],
): Promise<RecommendResult | null> {
const withGuides = await loadGuidesForCandidates(candidates, productName)
const local = recommendLocally(productName, withGuides)
try {
const providers = await listXhsAiProviders()
const list = Array.isArray(providers) ? providers : []
const provider =
list.find((p: any) => (p.isDefault || p.IsDefault) && (p.enabled ?? p.Enabled ?? true)) ||
list.find((p: any) => p.enabled ?? p.Enabled ?? true)
const providerKey = String(provider?.providerKey ?? provider?.ProviderKey ?? '').trim()
if (!providerKey) return local
const compact = withGuides.slice(0, 12).map((item) => ({
rowKey: item.rowKey,
channel: item.shippingMethod,
productCode: item.productCode || '',
cargo: item.cargoTypeName,
warehouse: item.warehouse,
deliveryTime: item.deliveryTime,
totalCost: item.totalCostNum,
priceOptimal: !!item.isPriceOptimal,
timeOptimal: !!item.isTimeOptimal,
hasGuide: !!item.hasGuide,
guide: String(item.guide || '').slice(0, 1200),
}))
const content = await chatXhsAi({
providerKey,
messages: [
{
role: 'system',
content: [
'你是国际物流对客顾问。输入是客户品名 + 各渠道报价与对客说明(已由 oms.channel_guide 拉取)。',
'任务:选出最适合该品名的一条渠道,并给出承运判定。',
'规则:',
'1) 对客说明明确禁运/不收 → verdict=forbid,不要选该渠道;',
'2) 说明未提及该品名 → verdict=unknown 或 caution,勿擅自说「一定可寄」;',
'3) 说明明确可走/可寄 → verdict=ok;敏感但可议 → caution;',
'4) 同等适合时优先更便宜,其次更快;',
'5) evidence 必须摘自 guide 原文短句(1~3条),面向客户可读;',
'6) alternatives 给 0~3 条备选(不同 rowKey),含简短 blurb;',
'7) reason 用对客口吻,一句说清「为什么推荐这条」;',
'只返回 JSON:{"rowKey":"...","verdict":"ok|caution|forbid|unknown","reason":"...","evidence":["..."],"alternatives":[{"rowKey":"...","verdict":"ok","blurb":"..."}]}',
].join('\n'),
},
{
role: 'user',
content: JSON.stringify({
cargoName: productName,
candidates: compact,
}),
},
],
})
const parsed = extractJsonObject(content?.content ?? '')
if (parsed) {
const built = buildResultFromAi(parsed, withGuides, productName, local)
if (built) return built
}
} catch {
// fallback local
}
return local
}