241 lines
7.8 KiB
TypeScript
241 lines
7.8 KiB
TypeScript
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
|
||
}
|