feat(crm): 建档一步到位 + 客户详情加公司简介

痛点:原来建客户只能填公司名等 7 个字段,联系人必须先保存、进详情页、
再加一遍,一件事拆成三步;详情页也没地方记公司背景。

建档表单
· 补上后端本来就收但表单没暴露的字段:公司英文名、地址、来源
· 新增「主要联系人」一组(姓名/职位/邮箱/电话/WhatsApp),自动标为主联系人
· 联系人与客户在**同一个事务**里创建 —— 分两个请求的话,客户写成功、
  联系人写失败会留下一条没人能联系的半成品客户
· 姓名留空则整块不创建,「只想先记个公司名」的用法完全不受影响

公司简介(customers 新增 5 列)
· founded_year / employee_count / annual_revenue / profile,外加复用 address
· employee_count 与 annual_revenue 用 TEXT 而非数字:实际拿到的都是
  「50-200 人」「€5-10M」这种带单位的区间,用途是给人看、不参与计算
· founded_year 卡 1800~今年:见过把「2024 年营收」当成立年份填进来的,
  也见过手滑打成 20225,不合理的值宁可拒掉
· 一个字段都没填时显示引导语而不是一排「未填写」—— 说明白这些信息拿来
  干什么,人才有动力填
· 跟「编辑客户信息」分成两个弹窗:基本信息是成交必须的,公司简介是
  了解多少写多少,混在一起会让人以为后者也得填

为 AI 背调预留:research_at / research_sources 两列已建,背调结果将落到
上述字段,并要求每条结论带来源 URL。

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Drsakura
2026-10-28 16:40:29 +08:00
co-authored by Claude Opus 5
parent 7281c41434
commit 241fb229c6
9 changed files with 363 additions and 27 deletions
+16 -1
View File
@@ -256,7 +256,22 @@ function ensureColumns() {
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
['created_by', 'INTEGER'],
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT'],
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。
全部选填 —— 这些是「了解到多少写多少」的东西,不是业务必填项。
employee_count / annual_revenue 用 TEXT 而不是数字:实际拿到的几乎都是
「50-200 人」「€5-10M」这种区间带单位的表述,硬塞进 INTEGER 要么丢精度
要么存不进去,还得再加个单位列。这里的用途是「给人看」,不参与计算。 */
['founded_year', 'INTEGER'],
['employee_count', 'TEXT'],
['annual_revenue', 'TEXT'],
['profile', 'TEXT'],
/* 背调留痕:什么时候查的、信息来自哪些页面。
没有来源的「AI 说这家公司有 200 人」是不能用来做决策的,
所以来源 URL 必须跟结论一起存,界面上要能点开核对。 */
['research_at', 'TEXT'],
['research_sources', 'TEXT']
]
};
for (const [table, columns] of Object.entries(columnMigrations)) {
+10
View File
@@ -207,6 +207,16 @@ CREATE TABLE IF NOT EXISTS customers (
pool_status TEXT DEFAULT 'owned',
pool_at TEXT,
pool_note TEXT,
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。全部选填。
employee_count / annual_revenue 用 TEXT 而非数字:实际拿到的几乎都是
「50-200 人」「€5-10M」这种带单位的区间表述,用途是给人看、不参与计算。 */
founded_year INTEGER,
employee_count TEXT,
annual_revenue TEXT,
profile TEXT,
/* 背调留痕:没有来源的结论不能拿来做决策,来源 URL 必须跟结论一起存 */
research_at TEXT,
research_sources TEXT,
source TEXT,
tags TEXT,
notes TEXT,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-management-backend",
"version": "1.2.0",
"version": "1.3.0",
"description": "外贸供应链管理系统 - 后端",
"main": "server.js",
"scripts": {
+53 -11
View File
@@ -364,23 +364,47 @@ app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) =
只有拿到 customer.view_all 的管理账号才允许代别人建档。 */
const requestedOwner = customerAccess.seesAll(req.user) ? numberOrNull(req.body?.owner_id) : null;
const ownerId = requestedOwner || (req.user ? req.user.id : null);
const result = db.prepare(`
INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes,
owner_id, created_by, org_id, source, status, pool_status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'owned', ?)
`).run(company, company_en || null, country || null, industry || null, website || null, address || null,
email || null, phone || null, notes || null, ownerId,
req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null,
source || null, status, nowIso());
/* 主要联系人:建档时一并填,不用「先存公司 → 进详情页 → 再加联系人」三步。
联系人在另一张表,所以必须跟客户放进**同一个事务** —— 分两次写的话,
客户写成功、联系人写失败就留下一条没人能联系的半成品客户。 */
const pc = req.body?.primary_contact || {};
const contactName = String(pc.name || '').trim();
const create = db.transaction(() => {
const result = db.prepare(`
INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes,
owner_id, created_by, org_id, source, status, pool_status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'owned', ?)
`).run(company, company_en || null, country || null, industry || null, website || null, address || null,
email || null, phone || null, notes || null, ownerId,
req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null,
source || null, status, nowIso());
const customerId = result.lastInsertRowid;
if (contactName) {
db.prepare(`
INSERT INTO contacts (customer_id, name, name_en, position, email, phone, whatsapp, wechat, is_primary, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
`).run(customerId, contactName,
pc.name_en || null, pc.position || null, pc.email || null, pc.phone || null,
pc.whatsapp || null, pc.wechat || null, pc.notes || null, nowIso());
}
return customerId;
});
const customerId = create();
auth.audit({
username: req.user?.username,
action: 'customer_create',
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}`,
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}${contactName ? `,主联系人 ${contactName}` : ''}`,
req
});
res.status(201).json({
id: result.lastInsertRowid,
id: customerId,
owner_id: ownerId,
contact_created: !!contactName,
message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)'
});
} catch (error) { jsonError(res, error); }
@@ -396,13 +420,31 @@ app.patch('/api/customers/:id', auth.requirePermission('customer.edit'), (req, r
}
const fields = [];
const values = [];
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone', 'notes', 'source']) {
/* employee_count / annual_revenue / profile 是「公司简介」卡片的字段,
走同一条通道 —— 它们跟基本信息一样是人填的文本,没有特殊校验。 */
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone',
'notes', 'source', 'employee_count', 'annual_revenue', 'profile']) {
if (req.body?.[key] === undefined) continue;
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
if (key === 'company' && !value) throw badRequest('公司名称不能为空');
fields.push(`${key} = ?`);
values.push(value || null);
}
/* 成立年份单独处理:它是数字,且范围要卡住 —— 见过 AI 背调把「2024 年营收」
当成立年份填进来的,也见过手滑打成 20225。不合理的值宁可拒掉也别存。 */
if (req.body?.founded_year !== undefined) {
const raw = req.body.founded_year;
if (raw === null || raw === '') {
fields.push('founded_year = ?'); values.push(null);
} else {
const year = Number(raw);
const thisYear = new Date().getFullYear();
if (!Number.isInteger(year) || year < 1800 || year > thisYear) {
throw badRequest(`成立年份应在 1800 ~ ${thisYear} 之间`);
}
fields.push('founded_year = ?'); values.push(year);
}
}
if (req.body?.owner_id !== undefined) {
/* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */
if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属');