diff --git a/backend/db/db.js b/backend/db/db.js index ed09455..d663381 100644 --- a/backend/db/db.js +++ b/backend/db/db.js @@ -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)) { diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 46db6c0..bffdcb6 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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, diff --git a/backend/package.json b/backend/package.json index 95721ac..a0862bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "trade-management-backend", - "version": "1.2.0", + "version": "1.3.0", "description": "外贸供应链管理系统 - 后端", "main": "server.js", "scripts": { diff --git a/backend/server.js b/backend/server.js index 6d054cb..33aa713 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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('只有最高级别系统管理账号可以变更客户归属'); diff --git a/frontend/package.json b/frontend/package.json index 51d74cf..24d1a86 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "trade-management-frontend", - "version": "1.2.0", + "version": "1.3.0", "description": "外贸供应链管理系统 - 前端", "type": "module", "scripts": { diff --git a/frontend/src/pages/CustomerDetail.jsx b/frontend/src/pages/CustomerDetail.jsx index c81ab1d..394af43 100644 --- a/frontend/src/pages/CustomerDetail.jsx +++ b/frontend/src/pages/CustomerDetail.jsx @@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router-dom' import axios from 'axios' import { UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon, - ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon + ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon, BuildingIcon } from '../components/Icons' import { useAuth, hasPermission } from '../auth' @@ -298,6 +298,94 @@ function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) { } /** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */ +/** + * 公司简介编辑。 + * + * 跟「编辑客户信息」分成两个弹窗,因为这两组字段的性质不同: + * 基本信息是成交必须的(公司名、国家、联系方式),公司简介是了解到多少写多少 + * 的背景资料。混在一个表单里会让人以为后面这些也得填。 + */ +function CompanyProfileModal({ customer, onClose, onSaved }) { + const [form, setForm] = useState({ + founded_year: customer.founded_year || '', + employee_count: customer.employee_count || '', + annual_revenue: customer.annual_revenue || '', + address: customer.address || '', + profile: customer.profile || '' + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const set = (key, value) => setForm(current => ({ ...current, [key]: value })) + + const submit = async event => { + event.preventDefault() + setSaving(true) + setError('') + try { + /* 成立年份空字符串要转成 null,否则后端会拿 '' 去做数字校验然后报错。 + 其余字段后端会把空串当 null 处理,直接传即可。 */ + await axios.patch(`/api/customers/${customer.id}`, { + ...form, + founded_year: String(form.founded_year).trim() === '' ? null : Number(form.founded_year) + }) + onSaved() + } catch (err) { + setError(err?.response?.data?.error || '保存失败,请稍后重试') + setSaving(false) + } + } + + return ( +
{customer.notes}
} + {/* 公司简介:了解到多少写多少,全部选填。 + 也是 AI 背调结果的落点 —— 查到的成立年份/规模/营收会填进这里。 */} +了解到多少写多少,都不是必填
+{customer.profile}
} + > + ) : ( ++ 还没填公司信息。成立年份、员工规模、年营收、业务简介这些, + 在谈判和定价时常常比联系方式更有用。 +
+ )} +