|
@@ -0,0 +1,381 @@
|
|
|
|
|
+const pool = require('../config/database');
|
|
|
|
|
+const { logOperation } = require('../middleware/logger');
|
|
|
|
|
+const stringSimilarity = require('string-similarity');
|
|
|
|
|
+const crypto = require('crypto');
|
|
|
|
|
+const moment = require('moment');
|
|
|
|
|
+
|
|
|
|
|
+// 客户查重
|
|
|
|
|
+exports.checkDuplicate = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { customer_name } = req.query;
|
|
|
|
|
+
|
|
|
|
|
+ if (!customer_name) {
|
|
|
|
|
+ return res.status(400).json({ success: false, message: '客户名称不能为空' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 精确匹配
|
|
|
|
|
+ const [exactMatches] = await pool.query(
|
|
|
|
|
+ `SELECT c.*, u.real_name as owner_name, u.team
|
|
|
|
|
+ FROM customers c
|
|
|
|
|
+ LEFT JOIN users u ON c.sales_owner = u.id
|
|
|
|
|
+ WHERE c.customer_name = ? AND c.status != 'released'`,
|
|
|
|
|
+ [customer_name]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (exactMatches.length > 0) {
|
|
|
|
|
+ return res.json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ has_conflict: true,
|
|
|
|
|
+ conflict_type: 'exact',
|
|
|
|
|
+ data: exactMatches[0],
|
|
|
|
|
+ message: '发现完全重复的客户'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 模糊匹配
|
|
|
|
|
+ const [allCustomers] = await pool.query(
|
|
|
|
|
+ `SELECT c.*, u.real_name as owner_name, u.team
|
|
|
|
|
+ FROM customers c
|
|
|
|
|
+ LEFT JOIN users u ON c.sales_owner = u.id
|
|
|
|
|
+ WHERE c.status != 'released'`
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const similarities = allCustomers.map(customer => ({
|
|
|
|
|
+ ...customer,
|
|
|
|
|
+ similarity: stringSimilarity.compareTwoStrings(customer_name, customer.customer_name)
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ const threshold = parseFloat(process.env.SIMILARITY_THRESHOLD) || 0.8;
|
|
|
|
|
+ const similarCustomers = similarities.filter(c => c.similarity >= threshold);
|
|
|
|
|
+
|
|
|
|
|
+ if (similarCustomers.length > 0) {
|
|
|
|
|
+ return res.json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ has_conflict: true,
|
|
|
|
|
+ conflict_type: 'similar',
|
|
|
|
|
+ data: similarCustomers[0],
|
|
|
|
|
+ similarity: similarCustomers[0].similarity,
|
|
|
|
|
+ message: `发现相似度${(similarCustomers[0].similarity * 100).toFixed(1)}%的客户`
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ has_conflict: false,
|
|
|
|
|
+ message: '未发现重复客户,可以报备'
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('客户查重失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '客户查重失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 创建客户报备
|
|
|
|
|
+exports.createCustomer = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const {
|
|
|
|
|
+ customer_name,
|
|
|
|
|
+ industry,
|
|
|
|
|
+ region,
|
|
|
|
|
+ contact_person,
|
|
|
|
|
+ contact_phone,
|
|
|
|
|
+ demand_description,
|
|
|
|
|
+ source
|
|
|
|
|
+ } = req.body;
|
|
|
|
|
+
|
|
|
|
|
+ if (!customer_name || !industry || !region || !demand_description || !source) {
|
|
|
|
|
+ return res.status(400).json({
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: '客户名称、行业、地区、需求概况、来源渠道为必填项'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 再次检查重复
|
|
|
|
|
+ const [existing] = await pool.query(
|
|
|
|
|
+ 'SELECT id FROM customers WHERE customer_name = ? AND status != ?',
|
|
|
|
|
+ [customer_name, 'released']
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (existing.length > 0) {
|
|
|
|
|
+ return res.status(400).json({
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: '该客户已被报备,无法重复报备'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const customerId = crypto.randomUUID();
|
|
|
|
|
+ const protectionDays = parseInt(process.env.DEFAULT_PROTECTION_DAYS) || 30;
|
|
|
|
|
+ const protectedEndDate = moment().add(protectionDays, 'days').format('YYYY-MM-DD HH:mm:ss');
|
|
|
|
|
+
|
|
|
|
|
+ await pool.query(
|
|
|
|
|
+ `INSERT INTO customers
|
|
|
|
|
+ (id, customer_name, industry, region, contact_person, contact_phone,
|
|
|
|
|
+ demand_description, source, sales_owner, protected_end_date, status)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
+ [
|
|
|
|
|
+ customerId, customer_name, industry, region, contact_person, contact_phone,
|
|
|
|
|
+ demand_description, source, req.user.id, protectedEndDate, 'following'
|
|
|
|
|
+ ]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 记录操作日志
|
|
|
|
|
+ await logOperation(
|
|
|
|
|
+ req.user.id,
|
|
|
|
|
+ 'create_customer',
|
|
|
|
|
+ 'customer',
|
|
|
|
|
+ customerId,
|
|
|
|
|
+ { customer_name, industry, region },
|
|
|
|
|
+ req.ip
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ res.status(201).json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ message: '客户报备成功',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ id: customerId,
|
|
|
|
|
+ customer_name,
|
|
|
|
|
+ protected_end_date: protectedEndDate
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('客户报备失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '客户报备失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 获取客户列表
|
|
|
|
|
+exports.getCustomers = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { status, page = 1, limit = 20, keyword } = req.query;
|
|
|
|
|
+ const offset = (page - 1) * limit;
|
|
|
|
|
+ const userId = req.user.id;
|
|
|
|
|
+ const userRole = req.user.role;
|
|
|
|
|
+
|
|
|
|
|
+ let query = `
|
|
|
|
|
+ SELECT c.*, u.real_name as owner_name, u.team,
|
|
|
|
|
+ CASE
|
|
|
|
|
+ WHEN c.protected_end_date < NOW() THEN 1
|
|
|
|
|
+ ELSE 0
|
|
|
|
|
+ END as is_expired
|
|
|
|
|
+ FROM customers c
|
|
|
|
|
+ LEFT JOIN users u ON c.sales_owner = u.id
|
|
|
|
|
+ WHERE 1=1
|
|
|
|
|
+ `;
|
|
|
|
|
+ const params = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 根据角色过滤数据
|
|
|
|
|
+ if (userRole === 'sales') {
|
|
|
|
|
+ query += ' AND c.sales_owner = ?';
|
|
|
|
|
+ params.push(userId);
|
|
|
|
|
+ } else if (userRole === 'sales_manager') {
|
|
|
|
|
+ query += ' AND u.team = ?';
|
|
|
|
|
+ params.push(req.user.team);
|
|
|
|
|
+ }
|
|
|
|
|
+ // admin 和 sales_director 可以查看所有客户
|
|
|
|
|
+
|
|
|
|
|
+ // 状态过滤
|
|
|
|
|
+ if (status) {
|
|
|
|
|
+ query += ' AND c.status = ?';
|
|
|
|
|
+ params.push(status);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 关键词搜索
|
|
|
|
|
+ if (keyword) {
|
|
|
|
|
+ query += ' AND (c.customer_name LIKE ? OR c.contact_person LIKE ? OR c.contact_phone LIKE ?)';
|
|
|
|
|
+ const searchPattern = `%${keyword}%`;
|
|
|
|
|
+ params.push(searchPattern, searchPattern, searchPattern);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取总数
|
|
|
|
|
+ const countQuery = query.replace(/SELECT.*FROM/, 'SELECT COUNT(*) as total FROM');
|
|
|
|
|
+ const [countResult] = await pool.query(countQuery, params);
|
|
|
|
|
+ const total = countResult[0].total;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取数据
|
|
|
|
|
+ query += ' ORDER BY c.created_at DESC LIMIT ? OFFSET ?';
|
|
|
|
|
+ params.push(parseInt(limit), parseInt(offset));
|
|
|
|
|
+
|
|
|
|
|
+ const [customers] = await pool.query(query, params);
|
|
|
|
|
+
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ customers,
|
|
|
|
|
+ pagination: {
|
|
|
|
|
+ page: parseInt(page),
|
|
|
|
|
+ limit: parseInt(limit),
|
|
|
|
|
+ total,
|
|
|
|
|
+ total_pages: Math.ceil(total / limit)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('获取客户列表失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '获取客户列表失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 获取客户详情
|
|
|
|
|
+exports.getCustomerById = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { id } = req.params;
|
|
|
|
|
+
|
|
|
|
|
+ const [customers] = await pool.query(
|
|
|
|
|
+ `SELECT c.*, u.real_name as owner_name, u.team, u.phone as owner_phone,
|
|
|
|
|
+ CASE
|
|
|
|
|
+ WHEN c.protected_end_date < NOW() THEN 1
|
|
|
|
|
+ ELSE 0
|
|
|
|
|
+ END as is_expired
|
|
|
|
|
+ FROM customers c
|
|
|
|
|
+ LEFT JOIN users u ON c.sales_owner = u.id
|
|
|
|
|
+ WHERE c.id = ?`,
|
|
|
|
|
+ [id]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (customers.length === 0) {
|
|
|
|
|
+ return res.status(404).json({ success: false, message: '客户不存在' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取跟进记录
|
|
|
|
|
+ const [followups] = await pool.query(
|
|
|
|
|
+ `SELECT f.*, u.real_name as user_name
|
|
|
|
|
+ FROM followup_records f
|
|
|
|
|
+ LEFT JOIN users u ON f.user_id = u.id
|
|
|
|
|
+ WHERE f.customer_id = ?
|
|
|
|
|
+ ORDER BY f.created_at DESC
|
|
|
|
|
+ LIMIT 10`,
|
|
|
|
|
+ [id]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 获取附件
|
|
|
|
|
+ const [attachments] = await pool.query(
|
|
|
|
|
+ `SELECT id, original_name, file_size, mime_type, created_at
|
|
|
|
|
+ FROM attachments
|
|
|
|
|
+ WHERE customer_id = ?
|
|
|
|
|
+ ORDER BY created_at DESC`,
|
|
|
|
|
+ [id]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ customer: customers[0],
|
|
|
|
|
+ followups,
|
|
|
|
|
+ attachments
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('获取客户详情失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '获取客户详情失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 更新客户信息
|
|
|
|
|
+exports.updateCustomer = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { id } = req.params;
|
|
|
|
|
+ const allowedFields = [
|
|
|
|
|
+ 'industry', 'region', 'contact_person', 'contact_phone',
|
|
|
|
|
+ 'demand_description', 'source'
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ const updates = {};
|
|
|
|
|
+ allowedFields.forEach(field => {
|
|
|
|
|
+ if (req.body[field] !== undefined) {
|
|
|
|
|
+ updates[field] = req.body[field];
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (Object.keys(updates).length === 0) {
|
|
|
|
|
+ return res.status(400).json({ success: false, message: '没有要更新的字段' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const setClause = Object.keys(updates).map(key => `${key} = ?`).join(', ');
|
|
|
|
|
+ const values = [...Object.values(updates), id];
|
|
|
|
|
+
|
|
|
|
|
+ await pool.query(
|
|
|
|
|
+ `UPDATE customers SET ${setClause} WHERE id = ?`,
|
|
|
|
|
+ values
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 记录操作日志
|
|
|
|
|
+ await logOperation(req.user.id, 'update_customer', 'customer', id, updates, req.ip);
|
|
|
|
|
+
|
|
|
|
|
+ res.json({ success: true, message: '客户信息更新成功' });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('更新客户信息失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '更新客户信息失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 更新客户状态
|
|
|
|
|
+exports.updateCustomerStatus = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { id } = req.params;
|
|
|
|
|
+ const { status, release_reason } = req.body;
|
|
|
|
|
+
|
|
|
|
|
+ const validStatuses = ['following', 'won', 'lost', 'released'];
|
|
|
|
|
+ if (!validStatuses.includes(status)) {
|
|
|
|
|
+ return res.status(400).json({ success: false, message: '无效的状态值' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const updates = { status };
|
|
|
|
|
+ if (status === 'released' && release_reason) {
|
|
|
|
|
+ updates.release_reason = release_reason;
|
|
|
|
|
+ updates.is_in_pool = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const setClause = Object.keys(updates).map(key => `${key} = ?`).join(', ');
|
|
|
|
|
+ const values = [...Object.values(updates), id];
|
|
|
|
|
+
|
|
|
|
|
+ await pool.query(
|
|
|
|
|
+ `UPDATE customers SET ${setClause} WHERE id = ?`,
|
|
|
|
|
+ values
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 记录操作日志
|
|
|
|
|
+ await logOperation(req.user.id, 'update_customer_status', 'customer', id, { status, release_reason }, req.ip);
|
|
|
|
|
+
|
|
|
|
|
+ res.json({ success: true, message: '客户状态更新成功' });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('更新客户状态失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '更新客户状态失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 添加跟进记录
|
|
|
|
|
+exports.addFollowup = async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { customer_id, followup_type, content, next_plan } = req.body;
|
|
|
|
|
+
|
|
|
|
|
+ if (!customer_id || !content) {
|
|
|
|
|
+ return res.status(400).json({ success: false, message: '客户ID和跟进内容不能为空' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const followupId = crypto.randomUUID();
|
|
|
|
|
+
|
|
|
|
|
+ await pool.query(
|
|
|
|
|
+ `INSERT INTO followup_records (id, customer_id, user_id, followup_type, content, next_plan)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
+ [followupId, customer_id, req.user.id, followup_type || 'call', content, next_plan]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 更新客户最后跟进时间
|
|
|
|
|
+ await pool.query(
|
|
|
|
|
+ 'UPDATE customers SET last_followup = NOW() WHERE id = ?',
|
|
|
|
|
+ [customer_id]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 记录操作日志
|
|
|
|
|
+ await logOperation(req.user.id, 'add_followup', 'followup', followupId, { customer_id, followup_type }, req.ip);
|
|
|
|
|
+
|
|
|
|
|
+ res.status(201).json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ message: '跟进记录添加成功',
|
|
|
|
|
+ data: { id: followupId }
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('添加跟进记录失败:', error);
|
|
|
|
|
+ res.status(500).json({ success: false, message: '添加跟进记录失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+};
|