fix(customers): normalize structured ai product values
This commit is contained in:
+44
-7
@@ -759,8 +759,14 @@ async function analyzeAndPersistConversation(tenantId, conversationId) {
|
|||||||
...customer,
|
...customer,
|
||||||
company: String(result.aiProfile?.companyName || '').trim() || customer.company,
|
company: String(result.aiProfile?.companyName || '').trim() || customer.company,
|
||||||
language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language,
|
language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language,
|
||||||
products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])),
|
products: Array.from(new Set(normalizedTextList(
|
||||||
tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])),
|
[...(customer.products || []), ...(Array.isArray(result.products) ? result.products : [])],
|
||||||
|
['name', 'product', 'category'],
|
||||||
|
))),
|
||||||
|
tags: Array.from(new Set(normalizedTextList(
|
||||||
|
[...(customer.tags || []), ...(Array.isArray(result.tags) ? result.tags : [])],
|
||||||
|
['label', 'name', 'category'],
|
||||||
|
))),
|
||||||
intentScore,
|
intentScore,
|
||||||
intentLevel: result.intentLevel || customer.intentLevel || 'low',
|
intentLevel: result.intentLevel || customer.intentLevel || 'low',
|
||||||
stage: result.stage || customer.stage || 'initial',
|
stage: result.stage || customer.stage || 'initial',
|
||||||
@@ -777,7 +783,7 @@ async function analyzeAndPersistConversation(tenantId, conversationId) {
|
|||||||
? result.aiProfile.relationshipEvidence.map(String).filter(Boolean).slice(0, 3)
|
? result.aiProfile.relationshipEvidence.map(String).filter(Boolean).slice(0, 3)
|
||||||
: [],
|
: [],
|
||||||
summary: String(result.summary || ''),
|
summary: String(result.summary || ''),
|
||||||
nextActions: Array.isArray(result.nextActions) ? result.nextActions : [],
|
nextActions: normalizedTextList(result.nextActions, ['action', 'text', 'label', 'name']),
|
||||||
profileVersion: 3,
|
profileVersion: 3,
|
||||||
},
|
},
|
||||||
aiAnalyzedAt: new Date().toISOString(),
|
aiAnalyzedAt: new Date().toISOString(),
|
||||||
@@ -1125,6 +1131,18 @@ const kinds = {
|
|||||||
customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks',
|
customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks',
|
||||||
knowledge: 'knowledge',
|
knowledge: 'knowledge',
|
||||||
}
|
}
|
||||||
|
function normalizedTextList(values, preferredKeys = []) {
|
||||||
|
if (!Array.isArray(values)) return []
|
||||||
|
return values.map((item) => {
|
||||||
|
if (typeof item === 'string' || typeof item === 'number') return String(item).trim()
|
||||||
|
if (!item || typeof item !== 'object') return ''
|
||||||
|
for (const key of preferredKeys) {
|
||||||
|
const value = item[key]
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}).filter(Boolean)
|
||||||
|
}
|
||||||
function withDefaults(kind, body) {
|
function withDefaults(kind, body) {
|
||||||
if (kind === 'customers') {
|
if (kind === 'customers') {
|
||||||
const source = body && typeof body === 'object' ? body : {}
|
const source = body && typeof body === 'object' ? body : {}
|
||||||
@@ -1135,15 +1153,15 @@ function withDefaults(kind, body) {
|
|||||||
valueLevel: 'D', isKey: false, ownerId: null,
|
valueLevel: 'D', isKey: false, ownerId: null,
|
||||||
aiProfile: { summary: '', keyPoints: [], nextActions: [] },
|
aiProfile: { summary: '', keyPoints: [], nextActions: [] },
|
||||||
...source,
|
...source,
|
||||||
products: Array.isArray(source.products) ? source.products : [],
|
products: normalizedTextList(source.products, ['name', 'product', 'category']),
|
||||||
tags: Array.isArray(source.tags) ? source.tags : [],
|
tags: normalizedTextList(source.tags, ['label', 'name', 'category']),
|
||||||
aiProfile: {
|
aiProfile: {
|
||||||
summary: '',
|
summary: '',
|
||||||
keyPoints: [],
|
keyPoints: [],
|
||||||
nextActions: [],
|
nextActions: [],
|
||||||
...(source.aiProfile && typeof source.aiProfile === 'object' ? source.aiProfile : {}),
|
...(source.aiProfile && typeof source.aiProfile === 'object' ? source.aiProfile : {}),
|
||||||
keyPoints: Array.isArray(source.aiProfile?.keyPoints) ? source.aiProfile.keyPoints : [],
|
keyPoints: normalizedTextList(source.aiProfile?.keyPoints, ['text', 'label', 'name']),
|
||||||
nextActions: Array.isArray(source.aiProfile?.nextActions) ? source.aiProfile.nextActions : [],
|
nextActions: normalizedTextList(source.aiProfile?.nextActions, ['action', 'text', 'label', 'name']),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1155,10 +1173,26 @@ function withDefaults(kind, body) {
|
|||||||
}
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function normalizeLegacyCustomerResources() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT tenant_id, data FROM resources WHERE kind='customers'",
|
||||||
|
)
|
||||||
|
let updated = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
const current = row.data
|
||||||
|
const normalized = withDefaults('customers', current)
|
||||||
|
if (JSON.stringify(current) === JSON.stringify(normalized)) continue
|
||||||
|
await putResource(String(row.tenant_id), 'customers', normalized)
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
if (updated) console.log(`Normalized ${updated} legacy customer records`)
|
||||||
|
}
|
||||||
for (const [path, kind] of Object.entries(kinds)) {
|
for (const [path, kind] of Object.entries(kinds)) {
|
||||||
app.get(`/api/${path}`, async (req, res) => {
|
app.get(`/api/${path}`, async (req, res) => {
|
||||||
let list = await listResources(req.auth.tenantId, kind)
|
let list = await listResources(req.auth.tenantId, kind)
|
||||||
if (kind === 'customers') {
|
if (kind === 'customers') {
|
||||||
|
list = list.map((customer) => withDefaults('customers', customer))
|
||||||
list = await Promise.all(list.map(async (customer) => {
|
list = await Promise.all(list.map(async (customer) => {
|
||||||
const country = resolveCustomerCountry(customer, customer)
|
const country = resolveCustomerCountry(customer, customer)
|
||||||
if (
|
if (
|
||||||
@@ -5650,6 +5684,9 @@ async function runMessagingIntegrityMaintenance() {
|
|||||||
if (weeklyDue) await reconcileConnectedMessagingChannels({ full: true, refreshProfiles: true })
|
if (weeklyDue) await reconcileConnectedMessagingChannels({ full: true, refreshProfiles: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => void normalizeLegacyCustomerResources().catch((error) => {
|
||||||
|
console.error(JSON.stringify({ message: 'Customer shape normalization failed', error: error.message }))
|
||||||
|
}), 1_000).unref?.()
|
||||||
setTimeout(() => void reconcileConnectedMessagingChannels(), 15_000).unref?.()
|
setTimeout(() => void reconcileConnectedMessagingChannels(), 15_000).unref?.()
|
||||||
setInterval(
|
setInterval(
|
||||||
() => void reconcileConnectedMessagingChannels(),
|
() => void reconcileConnectedMessagingChannels(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
|
|||||||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
import { formatProductName } from '@/utils/format'
|
||||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||||
|
|
||||||
@@ -151,8 +152,8 @@ export function InsightPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{customer.products?.map((p) => (
|
{customer.products?.map((p, index) => (
|
||||||
<Badge key={p} variant="primary">{p}</Badge>
|
<Badge key={`${formatProductName(p)}-${index}`} variant="primary">{formatProductName(p)}</Badge>
|
||||||
))}
|
))}
|
||||||
{customer.tags?.map((t) => (
|
{customer.tags?.map((t) => (
|
||||||
<CustomerSemanticTag key={t} tag={t} />
|
<CustomerSemanticTag key={t} tag={t} />
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
INTENT_LEVELS,
|
INTENT_LEVELS,
|
||||||
} from '@/config/constants'
|
} from '@/config/constants'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
import { formatProductName } from '@/utils/format'
|
||||||
import type { Customer } from '@/types'
|
import type { Customer } from '@/types'
|
||||||
import type { ChannelType } from '@/types/messaging'
|
import type { ChannelType } from '@/types/messaging'
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ export function CustomersPage() {
|
|||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{formatProductName(customer.products?.[0]) || '—'}</span></Td>
|
||||||
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
||||||
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
||||||
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
||||||
@@ -333,8 +334,8 @@ export function CustomersPage() {
|
|||||||
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
||||||
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
||||||
</div>
|
</div>
|
||||||
{customer.products.length > 0 && (
|
{customer.products?.length > 0 && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
|
<p className="mt-2 text-xs text-muted-foreground truncate">{formatProductName(customer.products[0])}</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { useAuthStore } from '@/app/store/auth'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { getPlan } from '@/config/pricingPlans'
|
import { getPlan } from '@/config/pricingPlans'
|
||||||
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
||||||
import { formatDateTime, formatRelative } from '@/utils/format'
|
import { formatDateTime, formatProductName, formatRelative } from '@/utils/format'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||||||
import { Card, CardHeader } from '@/components/ui/card'
|
import { Card, CardHeader } from '@/components/ui/card'
|
||||||
@@ -235,7 +235,7 @@ export function DashboardPage() {
|
|||||||
<CountryFlag code={c.country} withName={false} />
|
<CountryFlag code={c.country} withName={false} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
{c.products?.[0] ?? '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
|
{formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
|||||||
@@ -71,3 +71,15 @@ export function localTimeByOffset(offsetHours: number): string {
|
|||||||
const target = new Date(utc + offsetHours * 3600000)
|
const target = new Date(utc + offsetHours * 3600000)
|
||||||
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatProductName(value: unknown): string {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
|
||||||
|
if (!value || typeof value !== 'object') return ''
|
||||||
|
const product = value as Record<string, unknown>
|
||||||
|
for (const key of ['name', 'product', 'category']) {
|
||||||
|
if (typeof product[key] === 'string' || typeof product[key] === 'number') {
|
||||||
|
return String(product[key]).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user