zhangwencui
5 天以前 d98085cbb38531dc2c9cb9b01af3672c462ce32d
Merge branch 'dev_NEW_pro' of http://114.132.189.42:9002/r/product-inventory-management into dev_NEW_pro
已添加1个文件
已修改3个文件
481 ■■■■■ 文件已修改
src/components/AIChatSidebar/assistants/index.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AIChatSidebar/assistants/salesAssistant.js 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AIChatSidebar/index.vue 378 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/Detail/index.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AIChatSidebar/assistants/index.js
@@ -1,13 +1,15 @@
import { generalAssistant } from './generalAssistant'
import { purchaseAssistant } from './purchaseAssistant'
import { productionAssistant } from './productionAssistant'
import { salesAssistant } from './salesAssistant'
export { generalAssistant, purchaseAssistant, productionAssistant }
export { generalAssistant, purchaseAssistant, productionAssistant, salesAssistant }
export const assistantRegistry = {
  general: generalAssistant,
  sales: salesAssistant,
  purchase: purchaseAssistant,
  production: productionAssistant
}
export const builtInAssistants = [generalAssistant, purchaseAssistant, productionAssistant]
export const builtInAssistants = [generalAssistant, salesAssistant, purchaseAssistant, productionAssistant]
src/components/AIChatSidebar/assistants/salesAssistant.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
import { TrendCharts } from '@element-plus/icons-vue'
export const salesAssistant = {
  key: 'sales',
  label: '销售助手',
  title: '销售智能助手',
  tooltip: '销售智能助手',
  icon: TrendCharts,
  apiBase: '/sales-ai',
  storageKey: 'sales_ai_chat_uuid',
  placeholder: '请输入销售相关问题... (Enter å‘送 / Shift+Enter æ¢è¡Œ)',
  welcomeMessage: '你好',
  description: '我可以协助你查询客户档案、销售报价、销售台账、销售退货、客户往来、发货台账,并重点分析客户流失风险及回款/报价策略。',
  allowFileUpload: false,
  emptySessionText: '暂无销售会话',
  quickPrompts: [
    '查询私海客户档案前10条',
    '查询公海客户档案',
    '查询本月销售报价',
    '查询本月销售台账',
    '查询近30天销售退货',
    '查询近30天客户回款往来',
    '查询本月发货台账',
    '查看销售指标统计',
    '帮我做客户流失风险分析,近30天,前20条',
    '生成回款与报价策略建议,优先高风险客户'
  ]
}
src/components/AIChatSidebar/index.vue
@@ -359,6 +359,136 @@
                  </div>
                </div>
                <div v-if="message.salesData" class="sales-structured-card">
                  <div class="sales-structured-card__title">{{ getSalesTypeLabel(message.type) }}</div>
                  <div v-if="message.salesData.summaryEntries?.length" class="sales-summary-grid">
                    <div
                        v-for="(entry, entryIndex) in message.salesData.summaryEntries"
                        :key="`sales-summary-${entry.key}-${entryIndex}`"
                        class="sales-summary-item"
                    >
                      <span class="sales-summary-label">{{ entry.label }}</span>
                      <strong class="sales-summary-value">{{ entry.value }}</strong>
                    </div>
                  </div>
                  <div v-if="message.type === 'sales_customer_churn_risk' && message.salesData.listItems?.length" class="sales-focus-list">
                    <div
                        v-for="(item, itemIndex) in message.salesData.listItems"
                        :key="`risk-${item.customerName || itemIndex}`"
                        class="sales-focus-item"
                    >
                      <div class="sales-focus-item__head">
                        <strong>{{ formatStructuredValue(item.customerName) }}</strong>
                        <div class="sales-focus-tags">
                          <el-tag size="small" :type="getSalesLevelTagType(item.riskLevel)">
                            {{ getSalesLevelLabel(item.riskLevel, 'risk') }}
                          </el-tag>
                          <el-tag size="small" type="warning">风险分 {{ formatStructuredValue(item.riskScore) }}</el-tag>
                        </div>
                      </div>
                      <div class="sales-focus-metrics">
                        <span>待回款:{{ formatStructuredValue(item.pendingAmount) }}</span>
                        <span>待回款占比:{{ formatStructuredValue(item.pendingRate) }}</span>
                        <span>距上次下单:{{ formatStructuredValue(item.daysSinceLastOrder) }}</span>
                      </div>
                      <div v-if="toStructuredStringArray(item.riskReasons).length" class="sales-focus-reasons">
                        <el-tag
                            v-for="(reason, reasonIndex) in toStructuredStringArray(item.riskReasons)"
                            :key="`${item.customerName || itemIndex}-reason-${reasonIndex}`"
                            size="small"
                            type="danger"
                            effect="plain"
                        >
                          {{ reason }}
                        </el-tag>
                      </div>
                    </div>
                  </div>
                  <div v-if="message.type === 'sales_collection_quote_strategy' && message.salesData.listItems?.length" class="sales-focus-list">
                    <div
                        v-for="(item, itemIndex) in message.salesData.listItems"
                        :key="`strategy-${item.customerName || itemIndex}`"
                        class="sales-focus-item sales-focus-item--strategy"
                    >
                      <div class="sales-focus-item__head">
                        <strong>{{ formatStructuredValue(item.customerName) }}</strong>
                        <div class="sales-focus-tags">
                          <el-tag size="small" :type="getSalesLevelTagType(item.priority)">
                            {{ getSalesLevelLabel(item.priority, 'priority') }}
                          </el-tag>
                          <el-tag size="small" type="success">转化率 {{ formatStructuredValue(item.quoteConversionRate) }}</el-tag>
                        </div>
                      </div>
                      <div class="sales-focus-metrics">
                        <span>待回款:{{ formatStructuredValue(item.pendingAmount) }}</span>
                        <span v-if="item.nextAction">下一步:{{ formatStructuredValue(item.nextAction) }}</span>
                      </div>
                      <p v-if="item.collectionStrategy" class="sales-strategy-line">
                        <strong>回款策略:</strong>{{ formatStructuredValue(item.collectionStrategy) }}
                      </p>
                      <p v-if="item.quotationStrategy" class="sales-strategy-line">
                        <strong>报价策略:</strong>{{ formatStructuredValue(item.quotationStrategy) }}
                      </p>
                    </div>
                  </div>
                  <div
                      v-if="message.salesData.listItems?.length && message.salesData.columns?.length && !isSalesFocusType(message.type)"
                      class="table-wrapper manufacturing-table-wrapper"
                  >
                    <el-table :data="message.salesData.listItems" border stripe size="small" style="width: 100%">
                      <el-table-column
                          v-for="col in message.salesData.columns"
                          :key="col"
                          :label="getStructuredFieldLabel(col)"
                          min-width="140"
                          show-overflow-tooltip
                      >
                        <template #default="{ row }">
                          {{ formatStructuredValue(row[col]) }}
                        </template>
                      </el-table-column>
                    </el-table>
                  </div>
                  <div v-if="message.salesData.topCustomers?.length && message.salesData.topCustomerColumns?.length" class="table-wrapper manufacturing-table-wrapper">
                    <div class="sales-section-title">重点客户</div>
                    <el-table :data="message.salesData.topCustomers" border stripe size="small" style="width: 100%">
                      <el-table-column
                          v-for="col in message.salesData.topCustomerColumns"
                          :key="`top-customer-${col}`"
                          :label="getStructuredFieldLabel(col)"
                          min-width="120"
                          show-overflow-tooltip
                      >
                        <template #default="{ row }">
                          {{ formatStructuredValue(row[col]) }}
                        </template>
                      </el-table-column>
                    </el-table>
                  </div>
                  <div v-if="message.salesData.contractTrend?.length && message.salesData.contractTrendColumns?.length" class="table-wrapper manufacturing-table-wrapper">
                    <div class="sales-section-title">合同趋势</div>
                    <el-table :data="message.salesData.contractTrend" border stripe size="small" style="width: 100%">
                      <el-table-column
                          v-for="col in message.salesData.contractTrendColumns"
                          :key="`contract-trend-${col}`"
                          :label="getStructuredFieldLabel(col)"
                          min-width="120"
                          show-overflow-tooltip
                      >
                        <template #default="{ row }">
                          {{ formatStructuredValue(row[col]) }}
                        </template>
                      </el-table-column>
                    </el-table>
                  </div>
                </div>
                <div v-if="message.purchaseAnalysisData" class="purchase-confirm-card">
                  <div class="purchase-confirm-header">
                    <span>{{ businessTypeLabelMap[message.purchaseAnalysisData.businessType] || message.purchaseAnalysisData.businessType || '采购业务' }}</span>
@@ -750,6 +880,32 @@
  purchase_return_order: '采购退货单',
  unknown: '未知采购业务'
}
const salesStructuredTypeSet = new Set([
  'sales_customer_profile_list',
  'sales_quotation_list',
  'sales_ledger_list',
  'sales_return_list',
  'sales_customer_interaction_list',
  'sales_shipping_list',
  'sales_dashboard',
  'sales_customer_churn_risk',
  'sales_collection_quote_strategy'
])
const salesFocusTypeSet = new Set([
  'sales_customer_churn_risk',
  'sales_collection_quote_strategy'
])
const salesTypeLabelMap = {
  sales_customer_profile_list: '客户档案',
  sales_quotation_list: '销售报价',
  sales_ledger_list: '销售台账',
  sales_return_list: '销售退货',
  sales_customer_interaction_list: '客户往来',
  sales_shipping_list: '发货台账',
  sales_dashboard: '销售指标统计',
  sales_customer_churn_risk: '客户流失风险分析',
  sales_collection_quote_strategy: '回款与报价策略建议'
}
const manufacturingStructuredTypeSet = new Set([
  'manufacturing_site_snapshot',
  'manufacturing_plan_list',
@@ -819,6 +975,24 @@
  materialName: '物料名称',
  stockQty: '库存量'
}
Object.assign(structuredFieldLabelMap, {
  customerName: '客户名称',
  riskLevel: '风险等级',
  riskScore: '风险评分',
  riskReasons: '风险原因',
  pendingAmount: '待回款金额',
  pendingRate: '待回款占比',
  daysSinceLastOrder: '距上次下单天数',
  priority: '优先级',
  quoteConversionRate: '报价转化率',
  collectionStrategy: '回款策略',
  quotationStrategy: '报价策略',
  nextAction: '下一步动作',
  contractAmountTotal: '合同总额',
  receivedAmountTotal: '已回款金额',
  pendingAmountTotal: '待回款总额',
  shipRate: '发货率'
})
const purchasePayloadFieldLabelMap = {
  purchaseLedgers: '采购台账',
  productData: '产品明细',
@@ -1203,6 +1377,77 @@
const getManufacturingTypeLabel = (type = '') => manufacturingTypeLabelMap[String(type || '')] || '制造结果'
const inferSalesColumns = (items = []) => {
  if (!Array.isArray(items) || !items.length) return []
  const fieldSet = new Set()
  items.forEach((item) => {
    if (!isPlainObject(item)) return
    Object.keys(item).forEach((key) => fieldSet.add(key))
  })
  return Array.from(fieldSet)
}
const normalizeSalesListItems = (items) => {
  if (!Array.isArray(items)) return []
  return items.filter(item => isPlainObject(item))
}
const buildSalesStructuredData = (parsedData) => {
  const type = String(parsedData?.type || '')
  if (!salesStructuredTypeSet.has(type)) return null
  const rawData = isPlainObject(parsedData?.data) ? parsedData.data : {}
  const listItems = normalizeSalesListItems(rawData.items)
  const topCustomers = normalizeSalesListItems(rawData.topCustomers)
  const contractTrend = normalizeSalesListItems(rawData.contractTrend)
  return {
    type,
    summaryEntries: normalizeManufacturingSummaryEntries(parsedData?.summary),
    listItems,
    columns: inferSalesColumns(listItems),
    topCustomers,
    topCustomerColumns: inferSalesColumns(topCustomers),
    contractTrend,
    contractTrendColumns: inferSalesColumns(contractTrend)
  }
}
const getSalesTypeLabel = (type = '') => salesTypeLabelMap[String(type || '')] || '销售查询结果'
const isSalesFocusType = (type = '') => salesFocusTypeSet.has(String(type || ''))
const getSalesLevelTagType = (level = '') => {
  const normalizedLevel = String(level || '').toLowerCase()
  if (normalizedLevel === 'high') return 'danger'
  if (normalizedLevel === 'medium') return 'warning'
  if (normalizedLevel === 'low') return 'success'
  return 'info'
}
const getSalesLevelLabel = (level = '', mode = 'risk') => {
  const normalizedLevel = String(level || '').toLowerCase()
  const suffix = mode === 'priority' ? '优先级' : '风险'
  if (normalizedLevel === 'high') return `高${suffix}`
  if (normalizedLevel === 'medium') return `中${suffix}`
  if (normalizedLevel === 'low') return `低${suffix}`
  if (!normalizedLevel) return mode === 'priority' ? '未分级' : '未评估'
  return normalizedLevel.toUpperCase()
}
const toStructuredStringArray = (value) => {
  if (Array.isArray(value)) {
    return value.map(item => String(item || '').trim()).filter(Boolean)
  }
  if (typeof value === 'string') {
    return value
      .split(/[,\uFF0C\u3001;\uFF1B\n]/)
      .map(item => item.trim())
      .filter(Boolean)
  }
  return []
}
const getManufacturingWarningLevelType = (level = '') => {
  const normalizedLevel = String(level || '').toLowerCase()
  if (normalizedLevel === 'high') return 'danger'
@@ -1581,6 +1826,7 @@
          payloadHiddenData: null,
          purchaseAnalysisData: null,
          manufacturingData: null,
          salesData: null,
          localUploadFiles: isUser ? mapHistoryFilePathsToSnapshots(msg.filePaths, uuid.value, idx) : []
        }
@@ -1825,9 +2071,15 @@
  messageObj.tableData = null
  messageObj.purchaseAnalysisData = null
  messageObj.manufacturingData = null
  messageObj.salesData = null
  if (messageObj.type === 'todo_list' && parsedData.data) {
    messageObj.tableData = parsedData.data
  }
  const salesData = buildSalesStructuredData(parsedData)
  if (salesData) {
    messageObj.salesData = salesData
  }
  const manufacturingData = buildManufacturingStructuredData(parsedData, previousManufacturingData)
@@ -1839,6 +2091,7 @@
    messageObj.type = 'purchase_analysis_confirm'
    messageObj.purchaseAnalysisData = parsedData
    messageObj.manufacturingData = null
    messageObj.salesData = null
    if (!Array.isArray(messageObj.payloadTreeData) || !messageObj.payloadTreeData.length) {
      initializePurchasePayloadTree(messageObj, parsedData.payload || {})
    }
@@ -1883,6 +2136,12 @@
const getStructuredFallbackText = (parsedData) => {
  if (!parsedData) return '正在为您展示分析结果...'
  if (parsedData.type === 'todo_list') return '已为您整理好相关数据。'
  if (salesStructuredTypeSet.has(parsedData.type)) {
    if (parsedData.type === 'sales_customer_churn_risk') return '已为您生成客户流失风险分析。'
    if (parsedData.type === 'sales_collection_quote_strategy') return '已为您生成回款与报价策略建议。'
    if (parsedData.type === 'sales_dashboard') return '已为您生成销售指标统计。'
    return '已返回销售查询结果。'
  }
  if (manufacturingStructuredTypeSet.has(parsedData.type)) {
    if (parsedData.type === 'manufacturing_action_plan') return '已为您生成办理建议,请确认动作后执行。'
    if (parsedData.type === 'manufacturing_warning') return '已为您生成制造预警看板。'
@@ -3018,7 +3277,8 @@
    payloadTreeData: null,
    payloadHiddenData: null,
    purchaseAnalysisData: null,
    manufacturingData: null
    manufacturingData: null,
    salesData: null
  })
  outputState.value[botMsgIndex] = {
@@ -3143,7 +3403,8 @@
    payloadTreeData: null,
    payloadHiddenData: null,
    purchaseAnalysisData: null,
    manufacturingData: null
    manufacturingData: null,
    salesData: null
  }
  messages.value.push(botMsg)
@@ -4519,6 +4780,119 @@
  }
}
.sales-structured-card {
  margin-top: 12px;
  width: 100%;
  background: #fff;
  border: 1px solid rgba(31, 122, 114, 0.2);
  border-radius: 12px;
  box-shadow: $shadow-card;
  padding: 14px;
}
.sales-structured-card__title {
  font-size: 14px;
  font-weight: 700;
  color: #1f5ddf;
  margin-bottom: 10px;
}
.sales-summary-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 8px;
  margin-bottom: 12px;
}
.sales-summary-item {
  border-radius: 10px;
  padding: 10px 12px;
  border: 1px solid rgba(30, 91, 255, 0.12);
  background: linear-gradient(180deg, #f7fbff, #edf6ff);
  min-height: 66px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 6px;
}
.sales-summary-label {
  font-size: 12px;
  color: #4b5563;
}
.sales-summary-value {
  font-size: 15px;
  color: #1f2937;
  line-height: 1.4;
  word-break: break-all;
}
.sales-focus-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.sales-focus-item {
  border-radius: 10px;
  border: 1px solid rgba(30, 91, 255, 0.14);
  background: #f8fbff;
  padding: 10px 12px;
}
.sales-focus-item--strategy {
  border-color: rgba(31, 122, 114, 0.22);
  background: linear-gradient(180deg, #f7fcfb, #edf9f6);
}
.sales-focus-item__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
  font-size: 13px;
  color: #1f2937;
}
.sales-focus-tags {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  justify-content: flex-end;
}
.sales-focus-metrics {
  margin-top: 8px;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  font-size: 12px;
  color: #475467;
}
.sales-focus-reasons {
  margin-top: 8px;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.sales-strategy-line {
  margin: 8px 0 0;
  font-size: 12px;
  line-height: 1.6;
  color: #334155;
}
.sales-section-title {
  margin: 4px 0 8px;
  font-size: 13px;
  font-weight: 700;
  color: $deep-blue;
}
.purchase-confirm-card {
  margin-top: 12px;
  width: 100%;
src/views/productionManagement/productStructure/Detail/index.vue
@@ -316,7 +316,39 @@
    });
  };
  const findSiblings = (items: any[], tempId: string): any[] | null => {
    if (!items || items.length === 0) return null;
    // æ£€æŸ¥å½“前层级
    if (items.some(item => item.tempId === tempId)) {
      return items;
    }
    // é€’归查找子级
    for (const item of items) {
      if (item.children && item.children.length > 0) {
        const result = findSiblings(item.children, tempId);
        if (result) return result;
      }
    }
    return null;
  };
  const handleProcessChange = (row: any, value: any) => {
    if (value) {
      const siblings = findSiblings(dataValue.dataList, row.tempId);
      if (siblings) {
        const isDuplicate = siblings.some(
          s => s.tempId !== row.tempId && s.processId === value
        );
        if (isDuplicate) {
          const option = getProcessOptionById(value);
          const processName = option?.name || "该工序";
          ElMessage.warning(`同一层级下不能选择重复的消耗工序:${processName}`);
          row.processId = "";
          syncProcessOperationFields(row);
          return;
        }
      }
    }
    row.processId = value || "";
    syncProcessOperationFields(row);
  };
@@ -431,8 +463,37 @@
  const validateAll = () => {
    let isValid = true;
    // æ ¡éªŒä¸€ç»„兄弟节点的工序是否唯一
    const checkProcessUniqueness = (items: any[]) => {
      if (!items || items.length === 0 || !isValid) return;
      const processIds = new Set();
      for (const item of items) {
        if (item.processId) {
          if (processIds.has(item.processId)) {
            const option = getProcessOptionById(item.processId);
            const processName = option?.name || item.processName || "未知工序";
            ElMessage.error(
              `产品「${item.productName}」的消耗工序「${processName}」在当前层级已存在,请勿重复设置`
            );
            isValid = false;
            return;
          }
          processIds.add(item.processId);
        }
      }
      // é€’归校验子级的兄弟节点
      for (const item of items) {
        if (item.children && item.children.length > 0) {
          checkProcessUniqueness(item.children);
        }
      }
    };
    // æ ¡éªŒå‡½æ•°
    const validateItem = (item: any, isTopLevel = false) => {
      if (!isValid) return;
      // æ ¡éªŒå½“前项的必填字段
      if (!item.model) {
        ElMessage.error("请选择规格");
@@ -460,7 +521,7 @@
      //   return;
      // }
      // é€’归校验子项
      // é€’归校验子项字段
      if (item.children && item.children.length > 0) {
        item.children.forEach(child => {
          validateItem(child, false);
@@ -468,7 +529,11 @@
      }
    };
    // éåŽ†æ‰€æœ‰é¡¶å±‚é¡¹
    // 1. é¦–先校验同一父级下的同层消耗工序是否唯一
    checkProcessUniqueness(dataValue.dataList);
    if (!isValid) return false;
    // 2. ç„¶åŽéåŽ†æ ¡éªŒæ‰€æœ‰é¡¶å±‚é¡¹çš„å­—æ®µå¿…å¡«æƒ…å†µ
    dataValue.dataList.forEach(item => {
      validateItem(item, true);
    });