54页CRM客户管理PRD拆解:从数据建模到接口落地的避坑指南
简介:这份PDF是蜂网供应链管理(上海)有限公司于2017年8月发布的客户关系管理CRM客户管理软件需求说明书V1.0,面向产品经理、需求分析师及CRM系统开发测试人员,用于指导客户管理模块从需求定义到落地实施的完整过程。文档共54页,系统覆盖项目背景与目标、平台政策与竞品分析、名词解释与业务流程、系统及角色用例、功能列表与基本流程等章节,并详细展开客户信息录入与管理、联系人跟踪、销售预测、市场营销自动化、客户服务及报表分析等模块的业务流程、界面设计与性能指标,同时涉及数据集成、移动端支持、安全合规与变更控制等要求。资源包仅含1个PDF文件,大小约3.11MB,结构完整、目录清晰,便于按章节检索。已有316人学习,适合需要撰写CRM需求文档、梳理客户管理业务逻辑或对照搭建系统原型的读者参考借鉴。
1. 一份 54 页的 CRM 客户管理 PRD,拆开看它到底能落地什么
拿到一份 2017 年的 CRM 客户管理 PRD,很多人的第一反应是“过时了”。但真把这份 54 页的文档逐节拆完,你会发现它把客户主数据从列表、新增、编辑、详情、合并、导入导出到查重规则整条链路都写死了,字段类型、取值范围、可空性、来源(手工输入还是系统自动)一项不落。这类文档的价值不在技术栈新旧,而在于它是一份可以直接照着建表、照着写校验、照着画原型的业务底稿。适合谁?正在做 CRM 客户模块的产品、需要把 PRD 翻译成数据库设计的后端、以及要评估一份需求文档完整度的技术负责人。下面按“它定义了什么 → 怎么落成模型和接口 → 哪些地方会翻车”的顺序拆。
2. 客户主数据建模:从关键数据项表反推表结构与校验
这份 PRD 最硬的部分是 3.1.3.1.2.3 和 3.1.3.2.1 两张关键数据项表,它把每个字段的类型、含义、取值范围、合法性、来源都列了出来。做落地时不要跳过这张表直接凭经验建表,因为里面藏着不少“看起来能空、实际有约束”的细节。
2.1 字段类型与可空性怎么映射成 DDL
先看文档里几类典型字段的写法:客户名称是字符型、不超过 200 字符、非空、可编辑、手工输入;上级客户是参照型、参照 CRM 客户、可空、有“不允许互为上下级”的合法性约束;工商注册是布尔型、系统判定、非空、不可编辑;所属组织是参照型、默认当前操作员所属组织、不可编辑、系统自动。这四类基本覆盖了整张客户表的所有字段形态。
按这个映射关系,客户主表可以这样起:
CREATE TABLE crm_customer ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', customer_name VARCHAR(200) NOT NULL COMMENT '客户名称,非空,<=200字符', parent_id BIGINT NULL COMMENT '上级客户,自参照,可空', industry VARCHAR(64) NULL COMMENT '行业,枚举取自数据字典', employee_count VARCHAR(32) NULL COMMENT '公司人数,字符型非数值', phone VARCHAR(64) NULL COMMENT '电话', postcode VARCHAR(16) NULL COMMENT '邮政编码', fax VARCHAR(64) NULL COMMENT '传真', region_code VARCHAR(32) NULL COMMENT '省市区,参照行政区划', address VARCHAR(255) NULL COMMENT '地址', biz_type VARCHAR(128) NULL COMMENT '业务类型,多选,逗号分隔存储', customer_level VARCHAR(32) NULL COMMENT '客户级别,枚举', reg_status TINYINT(1) NOT NULL DEFAULT 0 COMMENT '工商注册,系统判定,不可编辑', remark VARCHAR(500) NULL COMMENT '备注', owner_id BIGINT NOT NULL COMMENT '负责人,参照人员,非空', dept_id BIGINT NOT NULL COMMENT '所属部门,默认当前用户部门', org_id BIGINT NOT NULL COMMENT '所属组织,系统自动,不可编辑', deal_status VARCHAR(16) NOT NULL DEFAULT 'UNSIGNED' COMMENT '成交状态:UNSIGNED/SIGNED', created_by BIGINT NOT NULL COMMENT '创建人,系统自动', created_at DATETIME NOT NULL COMMENT '创建时间,系统自动', updated_by BIGINT NULL COMMENT '最后修改人', updated_at DATETIME NULL COMMENT '最后修改时间', last_active_at DATETIME NULL COMMENT '最新活动记录时间', PRIMARY KEY (id), KEY idx_owner (owner_id), KEY idx_org (org_id), KEY idx_name (customer_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM客户主表';几个参数要单独说。employee_count文档明确写的是字符型而不是数值型,别自作主张改成 INT,因为实际录入里“50-100人”这种区间写法很常见,改成数值型会在导入时批量报错。biz_type是多选枚举,文档没规定存储形态,常见做法是逗号分隔存一列,查询时用 FIND_IN_SET,量大了再拆关联表,这个取舍要在设计阶段就定下来,后期改成本高。reg_status是系统判定字段,意味着它不由前端表单提交,而是由工商信息核验任务回写,接口层要把它从入参里排除掉,否则会出现前端传什么就存什么的问题。
2.2 自参照的上级客户与“不允许互为上下级”
上级客户这个字段是整张表里最容易埋雷的。文档写了两条约束:参照 CRM 客户、不允许互为上下级。第一条好办,下拉数据源查本组织可见客户即可;第二条是典型的环检测问题,A 的上级是 B、B 的上级是 A,或者更长的 A→B→C→A,都会让客户层级树在渲染时死循环。
校验逻辑放在服务层,新增和编辑都要走:
def validate_parent(customer_id, new_parent_id, repo): """校验上级客户合法性:不能是自己,不能形成环""" if new_parent_id is None: return True if customer_id is not None and int(new_parent_id) == int(customer_id): raise ValueError("上级客户不能是自己") # 从拟设置的上级往上追溯,若回到当前客户则说明成环 cursor = new_parent_id visited = set() while cursor is not None: if cursor in visited: raise ValueError("客户层级数据已存在环,请先修复历史数据") visited.add(cursor) if customer_id is not None and int(cursor) == int(customer_id): raise ValueError("不允许互为上下级") parent = repo.get_parent_id(cursor) cursor = parent return True这段的关键在visited集合。只判断“新上级的祖先里有没有自己”是不够的,因为历史数据本身可能已经成环,追溯过程会无限循环。加 visited 之后,遇到历史环会直接抛错而不是把服务拖死。参数上customer_id在新增时传 None,此时只做“不能选自己”之外的环检测意义不大,但保留统一入口能让新增和编辑共用一套逻辑,减少分支。实际项目里我一般还会在客户层级查询接口上加一个最大深度限制,比如 10 层,超过就截断并打日志,防止某个脏数据把整棵树查询拖垮。
2.3 查重设置:文档留白但落地绕不开
PRD 在 3.1.4.2 只写了“查重设置”四个字,没有展开规则。这是这份文档的典型留白,落地时必须自己补。CRM 客户查重的常见做法是按“客户名称 + 电话”做组合判重,名称做全等而非模糊,电话做去空格去横线归一化后比对。查重时机有两个:新增保存前同步校验,导入时逐行校验并输出失败清单。
import re def normalize_phone(phone: str) -> str: if not phone: return "" return re.sub(r"[\s\-()]", "", phone) def find_duplicate(name: str, phone: str, repo): """返回疑似重复客户列表,名称全等 + 电话归一化后相等""" candidates = repo.find_by_name(name) norm = normalize_phone(phone) if not norm: return candidates return [c for c in candidates if normalize_phone(c.phone) == norm]这里有个血泪经验:查重不能做成硬拦截。同名客户在集团型业务里非常正常,硬拦截会让销售直接放弃录入、转去线下表格,数据反而更脏。合理做法是软提示——命中重复时弹确认框,允许“仍然保存”但记录一条重复标记,后续由数据治理任务统一合并。文档里“客户-合并”功能的存在也印证了这个思路,合并才是重复数据的最终出口,查重只是入口提示。
3. 列表、查询方案与导入导出:把交互描述翻译成接口契约
PRD 的 3.1.3.1.2.1 到 3.1.3.1.2.5 把客户列表的交互写得非常细:默认按创建时间倒序、默认查询方案是“全部客户”、显示列可自定义且客户名称必选、客户名称搜索对全部可见数据模糊搜索、高级查询支持客户表字段含自定义字段。这些描述直接决定了列表接口的参数设计。
3.1 五个预置查询方案的接口表达
文档列了五个预置方案:全部客户(所属组织=我所在组织)、我负责的客户(负责人=我)、我参与的客户(团队成员包含我且负责人不是我)、我关注的客户(已加关注)、七日未跟进客户(未跟进天数>7)。前四个是固定条件,第五个是时间计算,实现上建议统一成一个scheme参数加服务端条件拼装,而不是让前端传一堆零散条件,否则前端一改方案服务端就得跟着改。
// 列表查询入参契约 const listQuery = { scheme: 'ALL', // ALL | MINE | JOINED | FOLLOWED | STALE_7D keyword: '', // 客户名称模糊搜索,仅作用于可见数据 page: 1, pageSize: 20, sortBy: 'created_at', // 默认创建时间 sortOrder: 'desc', columns: ['customer_name', 'owner_id', 'deal_status'], // 客户名称必选 advanced: {} // 高级查询条件,键为字段名 };columns这个参数对应“显示列可自定义”,服务端要做白名单校验,只允许客户表字段和已启用的自定义字段,客户名称强制加入且不允许移除。keyword的“对全部可见数据模糊搜索”意味着搜索范围要先过数据权限再模糊匹配,不能先模糊匹配再过滤权限,否则会泄露不可见客户的存在性——这是权限设计里很常见的翻车点。
七日未跟进的计算依赖last_active_at,但文档同时定义了“最新活动记录时间=最后活动记录保存时系统时间”。注意这里有个口径问题:如果客户从未有过活动记录,last_active_at是空的,按“未跟进天数>7”算不算命中?我的处理是把空值视为“自创建起未跟进”,用COALESCE(last_active_at, created_at)参与计算,否则新录入的客户永远不会出现在待跟进列表里,销售会以为系统坏了。
3.2 导入导出的模板与失败清单
文档 3.1.3.1.2.27 和 3.1.3.1.2.28 只写了“按照导入模板批量导入”“将当前页面数据导出到 EXCEL”,没有模板字段清单。落地时模板列要和关键数据项表对齐,必填列只放客户名称和负责人,其余可空,枚举列在模板里附数据字典说明页。导入流程建议做成异步任务:上传文件→解析校验→生成成功/失败两份结果→前端下载失败清单。失败清单必须带行号和失败原因,只给一个“导入失败”的提示等于没给。
# 导入任务的状态流转(伪命令,示意接口调用顺序) POST /api/crm/customer/import/task # 上传文件,返回 taskId GET /api/crm/customer/import/task/{id} # 轮询:PENDING -> PARSING -> DONE/FAILED GET /api/crm/customer/import/task/{id}/result # 下载成功明细与失败清单导出要注意“当前页面数据”这个措辞。它指的是当前查询条件下的全量结果还是当前分页?文档没写死。我的做法是导出走独立接口、复用列表查询条件但不带分页,同时加一个导出行数上限(比如 5 万行),超限提示缩小查询范围。直接按当前页导出会让用户以为导全了,按全量导出又可能把库拖慢,上限是折中。
3.3 列表内联编辑与批量操作的边界
文档写了两条容易被忽略的规则:编辑保存后“更新修改的行,不重新排序”,以及“不可批量编辑”,但删除和更改负责人“支持批量操作”。不重新排序意味着列表的排序键在编辑后不能变,如果排序键恰好是被编辑的字段(比如按客户名称排序时改了名称),要么禁止该字段内联编辑,要么接受顺序错乱并在下次刷新时纠正。我一般选择后者并在交互上不做额外提示,因为强制重排会让用户正在看的行突然跳走,体验更差。
批量更改负责人要特别注意权限:文档说“参照选人”,但没说要校验操作者是否有权把客户转给目标负责人。实际落地时至少要校验目标负责人属于同一组织且在职,否则会出现客户被转给已离职账号、后续无人能接手的情况。
4. 客户详情、合并与团队成员:关联数据的坑集中区
客户详情页是文档里篇幅最大的部分,从 3.1.3.1.2.13 到 3.1.3.1.2.26 覆盖了详情页按钮、成交状态、新建操作、合并、发起讨论、基本信息、动态、相关、团队成员、客户层级关系、生成档案。这一块的功能点密度高,关联表多,是排错成本最高的区域。
4.1 客户合并的字段取舍与关联迁移
文档 3.1.3.1.2.17 只写了“客户-合并”,没有规则。合并是 CRM 里最容易做出数据事故的功能,核心要定三件事:主客户怎么选、冲突字段取谁、关联数据怎么迁。常见做法是以选中记录中创建时间最早或负责人当前用户的那条为主,冲突字段默认取主客户、允许逐字段选择,关联数据(联系人、商机、动态、合同、订单、团队成员)全部迁到主客户,被合并客户标记为已合并并保留指向主客户的指针,不做物理删除。
-- 合并时关联迁移的核心语句(示意) UPDATE crm_contact SET customer_id = :masterId WHERE customer_id = :slaveId; UPDATE crm_opportunity SET customer_id = :masterId WHERE customer_id = :slaveId; UPDATE crm_activity SET customer_id = :masterId WHERE customer_id = :slaveId; UPDATE crm_customer SET merged_into = :masterId, status = 'MERGED' WHERE id = :slaveId;保留merged_into指针是为了后悔药:合并错了还能追溯原客户去过哪里,历史动态里的客户名也不会变成空白。物理删除是绝对不行的,一旦删了,所有以客户 ID 为外键的历史单据都会变成孤儿数据。
4.2 团队成员与“我参与的客户”口径一致性
“我参与的客户”定义为“团队成员包含我且负责人不等于我”,这要求团队成员是一张独立关联表,而不是在客户表上存一个逗号分隔的成员列表。用关联表才能让这个查询走索引,也才能在成员变动时留下操作记录。
CREATE TABLE crm_customer_member ( id BIGINT NOT NULL AUTO_INCREMENT, customer_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role_code VARCHAR(32) NULL COMMENT '团队角色', joined_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_customer_user (customer_id, user_id), KEY idx_user (user_id) ) COMMENT='客户团队成员';口径一致性是个隐蔽的坑:列表的“我参与的客户”用团队成员表判断,详情页的团队成员展示也用同一张表,但客户合并时如果只迁了主表没迁成员表,合并后的客户就会从“我参与的客户”里消失。合并脚本必须把成员表也纳入迁移,并且对uk_customer_user做去重处理,否则主从双方有同一成员时会撞唯一键。
4.3 客户层级关系的展示与性能
文档 3.1.3.1.2.25 提到客户层级关系,配合上级客户字段,本质是一棵树。展示时常见两种做法:递归查询拼树,或者维护一个物化的层级路径字段。前者实现简单但深度大时慢,后者查询快但每次改上级都要批量更新子树路径。客户层级一般不会太深,我倾向递归查询加缓存,缓存键带上组织 ID,客户变更时按组织失效。如果一定要物化路径,路径字段用/1/5/12/这种格式,改上级时用前缀匹配批量更新,注意事务范围别太大。
5. 避坑与排查:这份 PRD 落地时最容易翻车的五处
5.1 现象:导入后大量客户“所属组织”为空,列表查不到
原因:文档规定所属组织“默认为当前操作员所属组织、不可编辑、系统自动”,但导入是异步任务,执行时没有当前登录用户上下文,取不到操作员组织。解决:导入任务创建时把操作员的 org_id 和 dept_id 快照进任务记录,解析阶段从任务记录取值,不要在执行线程里依赖会话。
5.2 现象:客户名称搜索搜不到刚建的客户
原因:文档要求“对全部可见数据模糊搜索”,实现时先按关键词查全表再过滤权限,或者反过来只查当前页再过滤,两种都会漏。正确顺序是先按数据权限圈定可见集合,再在集合内做名称模糊匹配,且模糊匹配要用LIKE '%kw%'并接受无法走索引的代价,量大的组织考虑加搜索引擎或名称倒排。
5.3 现象:编辑客户后列表顺序乱了,用户投诉
原因:列表默认按创建时间倒序,但用户可能切到了按客户名称排序,此时内联编辑名称会改变排序键。文档只承诺“更新修改的行,不重新排序”,没承诺排序键不变时顺序稳定。解决:内联编辑只开放非当前排序键的字段,或者编辑后局部更新该行但不重排,下次手动刷新再纠正,并在交互上不做“已保存并重排”的暗示。
5.4 现象:合并客户后,原客户的动态记录点进去 404
原因:动态表存了 customer_id,合并时只改了主表没改动态表,或者改了动态表但前端缓存了旧 ID。解决:合并事务内把所有以 customer_id 为外键的关联表列全并逐一迁移,合并完成后清相关缓存;被合并客户保留merged_into,详情接口遇到 MERGED 状态时重定向到主客户而不是报错。
5.5 现象:高级查询选了自定义字段,接口报字段不存在
原因:文档说高级查询支持“客户表字段(包含自定义字段)”,自定义字段是运行期配置的,接口层如果按固定 DTO 接收就会丢字段。解决:高级查询入参用动态键值对,服务端按字段元数据做白名单和类型转换,未知字段直接拒绝而不是静默忽略,避免用户以为查了实际没查。
6. 把 PRD 变成可验收的字段级检查清单
这份文档最值得学的地方,是它把“可空性、来源、可编辑性”当成一等公民写进了数据项表。落地时我习惯把这张表再加工成一份字段级检查清单,作为开发和测试的共同验收依据。做法很简单:从关键数据项表导出四列——字段名、是否可空、来源(手工/系统)、是否可编辑,然后对每个字段问三个问题:接口入参是否排除了系统自动字段?前端是否禁用了不可编辑字段?数据库约束是否和可空性一致?三个问题都过,这个字段才算落地完成。
| 字段 | 可空 | 来源 | 可编辑 | 落地检查点 |
|---|---|---|---|---|
| 客户名称 | 否 | 手工 | 是 | 接口必填校验 + DB NOT NULL + 长度 200 |
| 上级客户 | 是 | 手工 | 是 | 环检测 + 自参照外键 |
| 工商注册 | 否 | 系统 | 否 | 入参排除 + 核验任务回写 |
| 所属组织 | 否 | 系统 | 否 | 入参排除 + 默认取会话组织 |
| 成交状态 | 否 | 手工 | 是 | 枚举校验 + 默认未成交 |
| 最新活动记录时间 | 是 | 系统 | 否 | 活动保存时联动更新 |
这张表还有个用法:变更评审时拿它对照新需求。任何新增字段都要能填满这四列,填不满说明需求没想清楚,先打回而不是先开发。我吃过一次亏,某次需求只写了“增加客户标签字段”,没定义可空性和来源,结果前端按可空做、后端按非空做,上线当天新增客户全部失败,回滚花了两小时。从那以后我每次接需求文档,都强制先把关键数据项表过一遍、把留白项列成问题清单发给产品确认,确认完再动手。希望这份拆解能帮你在拿到类似 PRD 时少走几步弯路。
本文还有配套的精品资源,点击获取