front/src/views/order/priceCheck/recommendChannel.ts

241 lines
7.8 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 { chatXhsAi, listXhsAiProviders } from '@/api/xhs'
export type QuoteCandidate = {
rowKey: string
productId?: number | string
shippingMethod: string
cargoTypeName?: string
warehouse?: string
deliveryTime?: string
totalCostNum: number
maxDeliveryDays: number
isPriceOptimal?: boolean
isTimeOptimal?: boolean
guide?: string
}
export type RecommendResult = {
rowKey: string
reason: string
source: 'ai' | 'local'
}
const FORBIDDEN_HINTS = ['禁运', '不收', '不可走', '不能走', '拒收', '禁止', '不得寄', '无法承运', '不接受']
const ALLOWED_HINTS = ['可走', '可寄', '可发', '接受', '适用', '支持', '允许', '可承运', '建议']
const CARGO_KEYWORDS: Array<{ keys: string[]; tags: string[] }> = [
{ keys: ['电池', '带电', '锂电', '充电宝', '移动电源'], tags: ['带电', '电池', '锂电', '敏感'] },
{ keys: ['液体', '化妆', '护肤', '香水', '精油'], tags: ['液体', '化妆', '敏感'] },
{ keys: ['粉末', '奶粉', '蛋白粉'], tags: ['粉末', '敏感'] },
{ keys: ['食品', '零食', '茶叶', '保健'], tags: ['食品', '敏感'] },
{ keys: ['服装', '衣服', '鞋子', '箱包'], tags: ['普货', '服装'] },
{ keys: ['数码', '手机', '电脑', '耳机'], tags: ['普货', '带电', '数码'] },
]
function normalizeGuide(detail: Record<string, any> | undefined) {
return String(detail?.customerGuideMarkdown ?? detail?.CustomerGuideMarkdown ?? '').trim()
}
export async function loadGuidesForCandidates(candidates: QuoteCandidate[]) {
const idSet = new Map<string, QuoteCandidate[]>()
candidates.forEach((item) => {
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
if (!id) return
const list = idSet.get(id) ?? []
list.push(item)
idSet.set(id, list)
})
const guideMap = new Map<string, string>()
await Promise.all(
[...idSet.keys()].map(async (id) => {
try {
const detail = await getSalesProductDetail(id)
guideMap.set(id, normalizeGuide(detail as Record<string, any>))
} catch {
guideMap.set(id, '')
}
}),
)
return candidates.map((item) => ({
...item,
guide: item.productId == null ? '' : guideMap.get(String(item.productId)) || '',
}))
}
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 scoreCandidate(item: QuoteCandidate, productName: string) {
const guide = String(item.guide || '')
const name = productName.trim()
let score = 0
const reasons: string[] = []
if (isForbidden(guide, name)) {
return { score: -1000, reasons: ['线路说明提示该品类可能禁运/不收'] }
}
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 (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 }
}
export function recommendLocally(productName: string, candidates: QuoteCandidate[]): RecommendResult | null {
if (!candidates.length) return null
const ranked = candidates
.map((item) => {
const { score, reasons } = scoreCandidate(item, productName)
return { item, score, reasons }
})
.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 reason = `推荐「${best.item.shippingMethod}」:${best.reasons.slice(0, 3).join(';') || '综合报价与线路说明择优'}`
return { rowKey: best.item.rowKey, reason, source: 'local' }
}
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
}
}
}
export async function recommendWithAi(
productName: string,
candidates: QuoteCandidate[],
): Promise<RecommendResult | null> {
const withGuides = await loadGuidesForCandidates(candidates)
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,
cargo: item.cargoTypeName,
warehouse: item.warehouse,
deliveryTime: item.deliveryTime,
totalCost: item.totalCostNum,
priceOptimal: !!item.isPriceOptimal,
timeOptimal: !!item.isTimeOptimal,
guide: String(item.guide || '').slice(0, 1200),
}))
const content = await chatXhsAi({
providerKey,
messages: [
{
role: 'system',
content:
'你是国际物流渠道顾问。根据用户品名与各渠道线路说明,选出最合适的一条渠道。只返回 JSON:{"rowKey":"...","reason":"..."},不要其它文字。若说明明确禁运则不要选该渠道。同等适合时优先更便宜、其次更快。',
},
{
role: 'user',
content: JSON.stringify({
productName,
candidates: compact,
}),
},
],
})
const parsed = extractJsonObject(content?.content ?? '')
const rowKey = String(parsed?.rowKey ?? parsed?.RowKey ?? '').trim()
const reason = String(parsed?.reason ?? parsed?.Reason ?? '').trim()
if (rowKey && withGuides.some((c) => c.rowKey === rowKey)) {
return {
rowKey,
reason: reason || `AI 推荐渠道:${withGuides.find((c) => c.rowKey === rowKey)?.shippingMethod || rowKey}`,
source: 'ai',
}
}
} catch {
// fallback local
}
return local
}