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
+16
-1
@@ -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)) {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "trade-management-backend",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "外贸供应链管理系统 - 后端",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+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