在这里插入图片描述
在这里插入图片描述

实例:客户管理 CRM(Crm)|技术:客户表 + 跟进记录表、状态字典、CrmDao

一、业务需求分析:CRM 的数据形态

客户管理(CRM,Customer Relationship Management)是销售场景的核心系统。它的数据形态结合了前面实例的多种能力:

  1. 客户主数据:客户姓名、公司、电话、状态、等级——类似通讯录但更「商务化」;
  2. 跟进记录:每个客户的多条跟进历史(电话、面谈、邮件)——一对多(客户 → 跟进),复用订单实例的建模;
  3. 状态流转:潜在 → 意向 → 成交(或流失)——销售漏斗的状态机;
  4. 最近跟进:列表显示「最近跟进内容 + 时间」——提升销售效率的关键信息;
  5. 状态统计:每种状态的客户数——销售漏斗可视化。

核心业务需求:

需求 数据层实现 页面呈现
客户列表 queryWithFollow(含最近跟进) 商务列表
状态统计 GROUP BY status 统计栏
状态流转 部分更新 status 流转按钮
新增跟进 insert follow_up 跟进弹窗
删除客户 级联删跟进 + 客户 删除确认

二、双表字段设计

客户表 customer

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
name TEXT NOT NULL 客户姓名
company TEXT DEFAULT ‘’ 公司
phone TEXT DEFAULT ‘’ 电话
status INTEGER NOT NULL DEFAULT 0 0 潜在 / 1 意向 / 2 成交 / 3 流失
level INTEGER NOT NULL DEFAULT 0 0 普通 / 1 重要 / 2 VIP
remark TEXT DEFAULT ‘’ 备注
created_time INTEGER NOT NULL 创建时间戳

跟进记录表 follow_up

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
customer_id INTEGER NOT NULL 关联客户(逻辑外键)
content TEXT NOT NULL 跟进内容
follow_time INTEGER NOT NULL 跟进时间戳

设计要点拆解

1. status 销售漏斗四态。潜在(刚接触)→ 意向(有兴趣)→ 成交(签约)→ 流失(没谈成)。数字编码可参与过滤与统计。状态流转规则:潜在→意向→成交 逐级推进,流失可重置回潜在(重新跟进)。

2. level 客户等级。普通/重要/VIP——与状态正交的「客户价值维度」。VIP 客户(如年合同 200 万的李秀英)需要重点维护,UI 用等级标签展示。

3. follow_up 一对多。每个客户有多条跟进记录——customer_id 逻辑外键 + 索引。跟进表只存「内容 + 时间」——极简设计,一条跟进就是「什么时候、说了什么」。

4. 最近跟进的设计。客户列表要显示「最近跟进内容」,两种方案:

  • 子查询(SELECT content FROM follow_up WHERE customer_id = c.id ORDER BY follow_time DESC LIMIT 1)——列表查询时顺便取最近一条(本实例采用,见 12-3);
  • 冗余字段:customer 表存 last_follow_time——写入时维护,读取 O(1)。

本实例用子查询(数据量小,一次 JOIN 式查询搞定),理解两种方案的取舍即可。

三、建表 SQL 与索引

CREATE TABLE IF NOT EXISTS customer (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  company TEXT DEFAULT '',
  phone TEXT DEFAULT '',
  status INTEGER NOT NULL DEFAULT 0,
  level INTEGER NOT NULL DEFAULT 0,
  remark TEXT DEFAULT '',
  created_time INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS follow_up (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  customer_id INTEGER NOT NULL,
  content TEXT NOT NULL,
  follow_time INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_follow_customer ON follow_up (customer_id);
CREATE INDEX IF NOT EXISTS idx_customer_status ON customer (status);

两个索引idx_follow_customer 服务「某客户的全部跟进」与外键子查询;idx_customer_status 服务「按状态筛选客户」与状态统计。外键索引 + 筛选索引——一对多建模的标准配置。

四、CrmDao 封装:聚合体与子查询

数据层核心 CrmDao。核心接口是客户 + 最近跟进的聚合体

export interface CustomerWithFollow {
  customer: Customer;
  lastFollow: string;    // 最近跟进内容
  lastFollowTime: number;
  followCount: number;   // 跟进总次数
}

聚合体三要素:客户本体 + 最近跟进内容 + 跟进次数——销售查看客户时最关心的信息。这个聚合体通过「子查询」在一次查询中组装(12-3 文章详解)。

实体接口

export interface Customer {
  id: number;
  name: string;
  company: string;
  phone: string;
  status: number;
  level: number;
  remark: string;
  createdTime: number;
}

export interface FollowUp {
  id: number;
  customerId: number;
  content: string;
  followTime: number;
}

五、基础查询:客户列表与跟进记录

全部客户(创建时间倒序)

static async queryCustomers(context: common.Context): Promise<Customer[]> {
  const store = await CrmDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(CrmDao.TABLE);
  predicates.orderByDesc('created_time');
  const result = await store.query(predicates);
  return CrmDao.collectCustomers(result);
}

按状态筛选

static async queryByStatus(context: common.Context, status: number): Promise<Customer[]> {
  const store = await CrmDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(CrmDao.TABLE);
  predicates.equalTo('status', status).orderByDesc('created_time');
  const result = await store.query(predicates);
  return CrmDao.collectCustomers(result);
}

某客户的全部跟进(时间倒序)

static async queryFollows(context: common.Context, customerId: number): Promise<FollowUp[]> {
  const store = await CrmDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(CrmDao.FOLLOW_TABLE);
  predicates.equalTo('customer_id', customerId).orderByDesc('follow_time');
  const result = await store.query(predicates);
  const list: FollowUp[] = [];
  while (result.goToNextRow()) {
    list.push({
      id: result.getLong(result.getColumnIndex('id')),
      customerId: customerId,
      content: result.getString(result.getColumnIndex('content')),
      followTime: result.getLong(result.getColumnIndex('follow_time')),
    });
  }
  result.close();
  return list;
}

六、技术要点对照表

技术点 实现方式 生产价值
一对多 customer + follow_up 客户与跟进解耦
状态字典 status 0~3 数字 漏斗过滤统计
等级 level 0~2 客户价值维度
外键索引 idx_follow_customer 跟进查询加速
状态索引 idx_customer_status 筛选统计加速
聚合体 CustomerWithFollow 列表零组装

七、文章小结

CRM 数据层是一对多(客户 → 跟进)+ 状态字典 + 等级体系的组合:customer 表承载客户主数据(状态/等级双维度),follow_up 表记录跟进历史(一对多),CustomerWithFollow 聚合体通过子查询把「最近跟进」并入客户列表。这是销售管理系统的标准骨架——客户资产地图的数据层。

下一篇(12-2)展示商务简约风 UI——统计栏 + 客户列表 + 跟进弹层,透着专业感。

八、双表设计详解:主外键与一对多关系

customer 与 follow_up 构成经典的「一对多」关系:一个客户对应多条跟进记录。两张表通过主外键关联:

概念 字段 说明
主键 customer id 客户唯一标识,自增
逻辑外键 follow_up customer_id 指向 customer.id
关联方向 customer → follow_up 一对多 一个客户 N 条跟进

为什么叫「逻辑外键」:HarmonyOS 关系型数据库(relationalStore)默认不强制 SQLite 的 FOREIGN KEY 约束,建表时不写 REFERENCES 子句,靠「应用层删除规则 + 索引」保证关联一致性——所以 customer_id 是逻辑外键而非物理外键。物理外键依赖数据库端约束,逻辑外键依赖应用端纪律,两者语义等价,但逻辑外键在 relationalStore 上更可控、无兼容性隐患。

九、跟进记录的级联删除设计

删除客户时若不处理 follow_up,会留下孤儿记录——customer_id 指向不存在的客户。SQLite 原生支持 ON DELETE CASCADE,但 relationalStore 默认不建外键约束,因此落地版用「两步删除」在应用层实现级联:

static async deleteCustomer(context: common.Context, id: number): Promise<void> {
  const store = await CrmDao.getStore(context);
  // 第一步:删除该客户的全部跟进记录
  const followPredicates = new relationalStore.RdbPredicates(CrmDao.FOLLOW_TABLE);
  followPredicates.equalTo('customer_id', id);
  await store.delete(followPredicates);
  // 第二步:删除客户本身
  const customerPredicates = new relationalStore.RdbPredicates(CrmDao.TABLE);
  customerPredicates.equalTo('id', id);
  await store.delete(customerPredicates);
}

级联顺序有讲究:必须先删子表(follow_up)再删主表(customer)——若先删客户,孤儿跟进会永远「悬空」在库里,后续按 customer_id 查询时得到空结果集,统计也被污染。生产上可把两步包进事务(store.beginTransaction() / commit()),保证两步同时成功或同时失败,防止删到一半断电导致数据不一致。

十、状态字段枚举:数字字典的约定

status 用 INTEGER 而非 TEXT 存「潜在/意向」,配合枚举常量让数据层与页面共用一份字典:

export enum CustomerStatus {
  POTENTIAL = 0,   // 潜在:刚接触
  INTERESTED = 1,  // 意向:有兴趣
  WON = 2,         // 成交:已签约
  LOST = 3         // 流失:没谈成
}

状态流转规则:

当前状态 可流转到 业务含义
潜在 0 意向 / 流失 新接触客户,可激活
意向 1 成交 / 流失 有兴趣,重点推进
成交 2 —(终态) 已签约,进入维护
流失 3 潜在 重置重新跟进

数字编码的好处:过滤(equalTo('status', 1))、统计(GROUP BY status)、排序都走数值比较,效率高且无中文串编码问题;显示时由页面侧 statusNames 字典映射回中文。字典只定义一次,数据层与 UI 共同引用——这是状态字段设计的关键约定。

十一、「最近一条跟进」的子查询思路预览

客户列表要显示「最近跟进内容」,核心 SQL 是相关子查询

SELECT c.*,
  (SELECT f.content FROM follow_up f
   WHERE f.customer_id = c.id
   ORDER BY f.follow_time DESC LIMIT 1) AS last_follow
FROM customer c
ORDER BY c.created_time DESC;

执行思路拆解:

  1. 外层遍历 customer 每一行,取客户基础信息;
  2. 内层子查询customer_id = c.id 过滤该客户的跟进,ORDER BY follow_time DESC LIMIT 1 取时间戳最大(最近)的一条;
  3. 子查询引用外层别名 c,形成相关子查询——每行客户都会执行一次内层查询。

由于内层查询的 WHERE 命中 idx_follow_customer 索引,且每个客户跟进记录通常只有几条,整体开销可控。这套「一行 SQL 取最近一条」的思路在 12-3 中落地为完整实现(同时取最近跟进时间与跟进次数)。

十二、索引设计复盘

索引 字段 服务场景
idx_follow_customer follow_up(customer_id) 外键子查询、某客户跟进列表
idx_customer_status customer(status) 状态筛选、状态统计

外键索引 + 筛选索引是一对多建模的标准配置:前者加速「按客户找跟进」(含最近跟进子查询),后者加速「按状态找客户」。小数据量(几十条)时索引收益不明显,但数据量上万后,全表扫描与索引扫描的差距是数量级的——索引是给未来数据量预留的

十三、FAQ

Q1:为什么用逻辑外键,不直接用 FOREIGN KEY?
relationalStore 默认不强制外键约束,建物理外键反而可能引入兼容性风险。用 customer_id + 索引 + 应用层级联删除,语义等价且更可控。

Q2:status 为什么不用 TEXT 存「潜在」?
数字枚举参与 equalTo / GROUP BY 更高效,也避免中文串的编码与输入问题;显示时由页面字典映射回中文即可。

Q3:最近跟进的子查询和冗余字段怎么选?
数据量小、读多写少用子查询(一次查询搞定,不额外维护);列表高频读写、写入频繁时用冗余字段(如 last_follow_time),写入时同步维护、读取 O(1)。本实例数据量小,选子查询。

Q4:删客户误删了跟进怎么办?
级联删除本身就是「删客户必删跟进」的业务规则。若需防误删,可改用 deleted 软删除字段(0/1)替代物理删除,列表查询统一过滤 deleted = 0。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐