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:
co-authored by
Claude Opus 5
parent
7281c41434
commit
241fb229c6
+53
-11
@@ -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('只有最高级别系统管理账号可以变更客户归属');
|
||||
|
||||
Reference in New Issue
Block a user