feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、 SKU 目录等模块,以及前端相应页面与样式改版。 后端 - 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定 - 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志 - 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等) - 新增合同解析、合同文件保留策略、客户归属与访问控制 - 新增 SKU 目录、组织品牌、新客开发、系统日志等路由 前端 - 新增登录页与用户 / 权限组 / 系统日志管理页 - 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心 - 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件 - 新增英文大写金额、未保存改动拦截、抽屉与骨架屏 - 新增 stage1 样式层;产品、供应商、订单等页面改版 - EmbeddedSku 页面并入现有产品体系后删除 开源准备 - 新增 AGPL-3.0 LICENSE - README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、 补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0 - 移除历史遗留的空文件 _index_backup.txt - 文档中的项目路径更新为 happy-export
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 新客开发的「开发结果管理」。
|
||||
* 挂载点:/api/prospects
|
||||
*
|
||||
* 口径:新客开发页只做两件事 —— ① 用线索池找到新客户;② 管理开发结果。
|
||||
* 开发结果就落在 customers.deal_status 上:pending 未成交 / won 已成交 / lost 已流失。
|
||||
* 这样「我的客户」和「新客开发」看到的是同一批客户,不会出现两套数据打架。
|
||||
*
|
||||
* 归属口径(Wayne 2026-09-14):客户「谁建的谁看得见」,所以本页也按归属过滤,
|
||||
* 否则业务员在「新客开发」里能直接看到并标记同事的客户。
|
||||
*/
|
||||
const express = require('express');
|
||||
const db = require('../db/db');
|
||||
const auth = require('../lib/auth');
|
||||
const access = require('../lib/customerAccess');
|
||||
|
||||
const router = express.Router();
|
||||
const DEAL_STATUSES = ['pending', 'won', 'lost'];
|
||||
|
||||
function badRequest(message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
function fail(res, error) {
|
||||
const message = error?.message || '请求处理失败';
|
||||
const status = error?.statusCode || (/不存在|无效|格式|不能为空/.test(message) ? 400 : 500);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
function countBy(status, user) {
|
||||
const { conditions, params } = access.scopeFilter(user, 'all');
|
||||
const where = ["COALESCE(c.deal_status, 'pending') = ?", ...conditions];
|
||||
return db.prepare(`SELECT COUNT(*) AS c FROM customers c WHERE ${where.join(' AND ')}`)
|
||||
.get(status, ...params).c;
|
||||
}
|
||||
|
||||
/** GET /api/prospects?deal_status=won|lost|pending&q= */
|
||||
router.get('/', auth.requirePermission('prospect.view'), (req, res) => {
|
||||
try {
|
||||
const status = String(req.query.deal_status || '').trim();
|
||||
const keyword = String(req.query.q || '').trim();
|
||||
/* 归属过滤与本页自己的筛选条件合并 —— 归属条件务必放进来,它是硬边界 */
|
||||
const scope = access.scopeFilter(req.user, 'all');
|
||||
const conditions = [...scope.conditions];
|
||||
const params = [...scope.params];
|
||||
|
||||
if (DEAL_STATUSES.includes(status)) {
|
||||
conditions.push("COALESCE(c.deal_status, 'pending') = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push('(c.company LIKE ? OR c.company_en LIKE ? OR c.country LIKE ? OR c.email LIKE ?)');
|
||||
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
const items = db.prepare(`
|
||||
SELECT c.*,
|
||||
u.display_name AS owner_name,
|
||||
(SELECT COUNT(*) FROM contacts ct WHERE ct.customer_id = c.id) AS contact_count,
|
||||
(SELECT COUNT(*) FROM customer_quotations q WHERE q.customer_id = c.id) AS quote_count,
|
||||
(SELECT MAX(f.created_at) FROM follow_up_records f WHERE f.customer_id = c.id) AS last_follow_at
|
||||
FROM customers c
|
||||
LEFT JOIN users u ON c.owner_id = u.id
|
||||
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}
|
||||
ORDER BY COALESCE(c.updated_at, c.created_at) DESC
|
||||
LIMIT 300
|
||||
`).all(...params);
|
||||
|
||||
res.json({
|
||||
items,
|
||||
stats: {
|
||||
pending: countBy('pending', req.user),
|
||||
won: countBy('won', req.user),
|
||||
lost: countBy('lost', req.user)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** PATCH /api/prospects/:id —— 标记成交状态 */
|
||||
router.patch('/:id', auth.requirePermission('prospect.view'), (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||
if (!customer) throw badRequest('客户不存在');
|
||||
/* 标记成交 = 改客户数据,别人的客户不能改 */
|
||||
if (!access.canEdit(req.user, customer)) {
|
||||
const error = new Error('这条客户不在你名下,请先到客户公海领取');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = String(req.body?.deal_status || '').trim();
|
||||
if (!DEAL_STATUSES.includes(status)) throw badRequest('成交状态取值无效');
|
||||
|
||||
db.prepare('UPDATE customers SET deal_status = ?, updated_at = ? WHERE id = ?')
|
||||
.run(status, new Date().toISOString(), id);
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'prospect_deal_status',
|
||||
detail: `客户「${customer.company}」标记为 ${status}`,
|
||||
req
|
||||
});
|
||||
res.json({ ok: true, item: db.prepare('SELECT * FROM customers WHERE id = ?').get(id) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user