两个端点都只有 requirePermission('customer.edit'),而那只说明「这个人有权
编辑客户」,没说明「有权编辑**这一条**客户」。实测一个 sales_rep 账号:
GET /api/customers/4 → 404(读是隔离的)
POST /api/customers/4/contacts → 201(写进去了)
PATCH /api/customers/4/follow-ups/12 → 200
把「客户确认 3000 套订单」改成了「客户取消了订单」
也就是说一个销售能往看不见的客户里插联系人(还能把自己设成主联系人),
还能改写另一个销售客户的跟进历史。
两处都有对照组:紧挨着的 PATCH /contacts 和 POST /follow-ups 一直有这道
检查,所以是漏了而不是设计如此。补上后重放两个利用都是 403,归属人自己
仍然 201/200。
Co-Authored-By: Claude Opus 5 <[email protected]>
1538 lines
86 KiB
JavaScript
1538 lines
86 KiB
JavaScript
const express = require('express');
|
||
const path = require('path');
|
||
const cors = require('cors');
|
||
const multer = require('multer');
|
||
const db = require('./db/db');
|
||
const updater = require('./lib/updater');
|
||
const systemRoutes = require('./routes/system');
|
||
const fxRoutes = require('./routes/fx');
|
||
const authRoutes = require('./routes/auth');
|
||
const accessRoutes = require('./routes/access');
|
||
const auth = require('./lib/auth');
|
||
const customerAccess = require('./lib/customerAccess');
|
||
const aiProviders = require('./lib/aiProviders');
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 4300;
|
||
const HOST = '127.0.0.1';
|
||
|
||
// 中间件
|
||
app.use(cors());
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 登录门(多用户阶段)
|
||
// attachUser 只读 cookie 与请求头、不消费请求体,所以可以放在最前面 ——
|
||
// 必须早于下面的 SKU 代理,否则 /api/sku/* 与 /product-images 会绕过鉴权。
|
||
// 免登录的只有健康检查、登录相关和系统信息(前端要能在登录页拿到版本号)。
|
||
// ---------------------------------------------------------------------------
|
||
app.use(auth.attachUser);
|
||
|
||
const PUBLIC_API_PATTERN = /^\/api\/(health|auth\/|system\/)/;
|
||
app.use('/api', (req, res, next) => {
|
||
if (PUBLIC_API_PATTERN.test(String(req.originalUrl || '').split('?')[0])) return next();
|
||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||
next();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 产品目录(SKU 模块)已并入本服务 —— 原先是把 /api/sku/* 与 /product-images/* 转发给
|
||
// 独立的 sku-manager(3300)。现在由 routes/sku.js 在本库直接执行,端口少一个,
|
||
// 路径一个字没改(前端零改动)。挂载点见下面「API 路由」段,必须在登录门之后。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 产品目录解析。
|
||
// 原先是「先请求 SKU 服务拿实时价、失败回退本地惰性镜像」—— 因为产品数据在另一个库。
|
||
// 合并后数据就在本库同一张表,直接查即可,实现见 lib/skuCatalog.js 的 resolveProduct。
|
||
// ---------------------------------------------------------------------------
|
||
const { resolveProduct } = require('./lib/skuCatalog');
|
||
/* 合同清洗:认列规则引擎 + 原件磁盘留存(7 天自动清理) */
|
||
const contractParser = require('./lib/contractParser');
|
||
const contractFiles = require('./lib/contractFiles');
|
||
|
||
/** SKU 相关接口的错误出口:找不到 SKU 给 404,其余交给通用错误处理。 */
|
||
function skuError(res, error) {
|
||
if (/SKU 不存在|SKU 不能为空/.test(error.message)) return res.status(404).json({ error: error.message });
|
||
return jsonError(res, error);
|
||
}
|
||
|
||
app.use(express.json());
|
||
app.use(express.urlencoded({ extended: true }));
|
||
|
||
const upload = multer({ storage: multer.memoryStorage(), limits: { files: 20, fileSize: 10 * 1024 * 1024 } });
|
||
|
||
/**
|
||
* 修正上传文件名的编码。
|
||
*
|
||
* multipart 里的文件名是裸字节,multer/busboy 为了兼容老客户端默认按 latin1 解码,
|
||
* 于是「腾达(扭矩扳手).xlsx」会变成「è¾¾ï¼ææï¼.xlsx」——
|
||
* UTF-8 的「腾达」= E8 85 BE E8 BE BE,被逐字节当成了 latin1 字符。
|
||
*
|
||
* 这里判断:只要出现 U+0080–U+00FF 区间的字符,就把它按 latin1 取回原始字节、
|
||
* 再用 UTF-8 解一次。纯 ASCII 名不会命中;本来就正确的 UTF-8 中文名也不会命中
|
||
* (汉字在 U+4E00 以上,不在该区间),所以不会误伤。
|
||
* 回转后若出现替换字符 U+FFFD,说明原串并不是「UTF-8 被 latin1 解码」的产物,保持原样。
|
||
*/
|
||
function decodeOriginalName(name) {
|
||
const raw = String(name || '');
|
||
if (!raw || !/[\u0080-\u00ff]/.test(raw)) return raw;
|
||
const restored = Buffer.from(raw, 'latin1').toString('utf8');
|
||
return restored.includes('\ufffd') ? raw : restored;
|
||
}
|
||
|
||
/** 在任何用到文件名之前把 req.files / req.file 的 originalname 就地修正。 */
|
||
function fixUploadFilenames(req, res, next) {
|
||
for (const file of req.files || []) file.originalname = decodeOriginalName(file.originalname);
|
||
if (req.file) req.file.originalname = decodeOriginalName(req.file.originalname);
|
||
next();
|
||
}
|
||
|
||
const nowIso = () => new Date().toISOString();
|
||
const isDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
|
||
const numberOrNull = value => value === undefined || value === null || value === '' ? null : Number(value);
|
||
function validPositiveNumber(value, field) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number) || number <= 0) throw new Error(`${field} 必须是大于 0 的数字`);
|
||
return number;
|
||
}
|
||
function getCustomer(id) {
|
||
const customerId = Number(id);
|
||
if (!Number.isInteger(customerId) || customerId <= 0) return null;
|
||
return db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId);
|
||
}
|
||
/**
|
||
* 统一错误响应。
|
||
*
|
||
* 状态码优先取调用方显式给的值(第三个参数,或 error.statusCode)——
|
||
* 早先只靠消息里的中文关键词猜 400/500,一旦新写一句不含「必须/无效/不存在」的
|
||
* 校验提示(例如「单价无法确定」),就会把业务校验错误错报成 500。
|
||
* 关键词判断保留为兜底,别再往这个正则里堆词。
|
||
*/
|
||
function jsonError(res, error, statusCode) {
|
||
const message = (error && error.message) || '请求处理失败';
|
||
const status = statusCode || (error && error.statusCode)
|
||
|| (/必须|不能为空|格式|不存在|无效|没有需要/.test(message) ? 400 : 500);
|
||
return res.status(status).json({ error: message });
|
||
}
|
||
|
||
/** 业务校验错误:明确回 400,不依赖 jsonError 的关键词兜底 */
|
||
function badRequest(message) {
|
||
const error = new Error(message);
|
||
error.statusCode = 400;
|
||
return error;
|
||
}
|
||
/* 合同/报价单的认列逻辑已迁到 lib/contractParser.js(通用字符规则引擎)。
|
||
原来这里有个 parseImportText:写死 5 组别名、只支持 CSV/TSV/TXT、认不出就整列丢掉,
|
||
且表头必须正好在第一行。现在换成「关键词/正则 + 权重打分 + 表头行探测 + 数值列体检」,
|
||
并支持 xlsx/xls —— 换行业只需改配置,不用改代码。 */
|
||
|
||
// 静态文件服务
|
||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||
|
||
// API 路由
|
||
app.get('/api/health', (req, res) => {
|
||
res.json({
|
||
status: 'ok',
|
||
version: updater.currentVersion(),
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
});
|
||
|
||
// 系统信息与版本更新检查
|
||
app.use('/api/system', systemRoutes);
|
||
|
||
// 账号、个人资料与改密
|
||
app.use('/api', authRoutes);
|
||
|
||
// 组织与权限组
|
||
app.use('/api', accessRoutes);
|
||
|
||
// 系统日志(审计轨迹):列表 / 筛选 / 统计 / 导出
|
||
// ⚠️ 挂在 /api/logs 而不是 /api/system/logs —— 后者在免登录白名单里,会把全公司的操作记录公开。
|
||
app.use('/api', require('./routes/logs'));
|
||
|
||
// 产品目录(SKU):检索 / 产品组 / 货号 / 小推车 / 图片 / 供应商
|
||
// 必须放在 express.json() 与登录门之后 —— 写操作要记操作人(上传者 / 调价人)。
|
||
const skuRoutes = require('./routes/sku');
|
||
app.use('/api/sku', skuRoutes.api);
|
||
app.use('/product-images', skuRoutes.images);
|
||
|
||
// 智能配置:多模型接入、连通测试、用途分配
|
||
app.use('/api/ai', require('./routes/ai'));
|
||
|
||
// 新客开发:开发结果管理(已成交 / 未成交 / 已流失)
|
||
app.use('/api/prospects', require('./routes/prospects'));
|
||
|
||
// 汇率(顶栏汇率计算器用)
|
||
app.use('/api/fx', fxRoutes);
|
||
|
||
// 合同清洗中心:解析规则 / AI 兜底解析 / 批量入库 / 原件下载(上传与单条复核仍在下面)
|
||
app.use('/api/contracts', require('./routes/contracts'));
|
||
|
||
// 获取工作台数据
|
||
app.get('/api/dashboard', (req, res) => {
|
||
try {
|
||
/* 工作台上的「客户」口径必须和客户模块一致,否则业务员能在这里看到别人的客户名。
|
||
用 'all' 取范围:有 customer.view_all 的管理账号不限归属,其余人只看自己名下。
|
||
待办(todos)不跟着过滤 —— 它允许没有关联客户,套上归属条件会把这类待办整片抹掉。 */
|
||
const custScope = customerAccess.scopeFilter(req.user, 'all');
|
||
const custWhere = custScope.conditions.length ? ' AND ' + custScope.conditions.join(' AND ') : '';
|
||
|
||
// 统计数据
|
||
const stats = {
|
||
suppliers: db.prepare('SELECT COUNT(*) as count FROM suppliers').get().count,
|
||
products: db.prepare('SELECT COUNT(*) as count FROM products').get().count,
|
||
customers: db.prepare(`SELECT COUNT(*) as count FROM customers c WHERE 1 = 1${custWhere}`).get(...custScope.params).count,
|
||
orders: db.prepare(`SELECT COUNT(*) as count FROM orders WHERE status != 'cancelled'`).get().count,
|
||
activeDeals: db.prepare(`SELECT COUNT(*) as count FROM deals WHERE status = 'open'`).get().count,
|
||
pendingTodos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count,
|
||
pendingContractJobs: db.prepare(`SELECT COUNT(*) as count FROM contract_import_jobs WHERE status IN ('queued', 'parsing', 'pending_review')`).get().count
|
||
};
|
||
|
||
const todayFollowUps = db.prepare(`
|
||
SELECT f.*, c.company AS customer_name
|
||
FROM follow_up_records f JOIN customers c ON c.id = f.customer_id
|
||
WHERE substr(f.next_action_date, 1, 10) = date('now')${custWhere}
|
||
ORDER BY f.next_action_date ASC LIMIT 20
|
||
`).all(...custScope.params);
|
||
const overdueFollowUps = db.prepare(`
|
||
SELECT f.*, c.company AS customer_name
|
||
FROM follow_up_records f JOIN customers c ON c.id = f.customer_id
|
||
WHERE f.next_action_date IS NOT NULL AND substr(f.next_action_date, 1, 10) < date('now')${custWhere}
|
||
ORDER BY f.next_action_date ASC LIMIT 20
|
||
`).all(...custScope.params);
|
||
const recentInquiries = db.prepare(`
|
||
SELECT i.*, c.company AS customer_name FROM inquiries i
|
||
LEFT JOIN customers c ON c.id = i.customer_id ORDER BY i.created_at DESC LIMIT 10
|
||
`).all();
|
||
const recentQuotes = db.prepare(`
|
||
SELECT q.*, c.company AS customer_name FROM customer_quotations q
|
||
LEFT JOIN customers c ON c.id = q.customer_id ORDER BY q.created_at DESC LIMIT 10
|
||
`).all();
|
||
const pendingContractJobs = db.prepare(`
|
||
SELECT * FROM contract_import_jobs WHERE status IN ('queued', 'parsing', 'pending_review')
|
||
ORDER BY created_at DESC LIMIT 10
|
||
`).all();
|
||
|
||
// 最近活动
|
||
const recentActivities = db.prepare(`
|
||
SELECT id, activity_type, subject, content, created_at
|
||
FROM activities
|
||
ORDER BY created_at DESC
|
||
LIMIT 10
|
||
`).all();
|
||
|
||
// 待办事项
|
||
const todos = db.prepare(`
|
||
SELECT t.*, c.company as customer_name
|
||
FROM todos t
|
||
LEFT JOIN customers c ON t.customer_id = c.id
|
||
WHERE t.status = 'pending'
|
||
ORDER BY t.due_date ASC, t.priority DESC
|
||
LIMIT 10
|
||
`).all();
|
||
|
||
res.json({
|
||
stats,
|
||
recentActivities,
|
||
todos,
|
||
todayFollowUps,
|
||
overdueFollowUps,
|
||
recentInquiries,
|
||
recentQuotes,
|
||
pendingContractJobs
|
||
});
|
||
} catch (error) {
|
||
console.error('获取工作台数据失败:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 供应商路由
|
||
app.get('/api/suppliers', (req, res) => {
|
||
try {
|
||
const suppliers = db.prepare('SELECT * FROM suppliers ORDER BY name').all();
|
||
res.json(suppliers);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/suppliers', auth.requirePermission('supplier.edit'), (req, res) => {
|
||
try {
|
||
const { name, contact_person, phone, email, address, payment_terms, notes } = req.body;
|
||
const result = db.prepare(`
|
||
INSERT INTO suppliers (name, contact_person, phone, email, address, payment_terms, notes, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(name, contact_person, phone, email, address, payment_terms, notes, new Date().toISOString());
|
||
|
||
res.json({ id: result.lastInsertRowid, message: '供应商创建成功' });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 产品路由
|
||
app.get('/api/products', (req, res) => {
|
||
try {
|
||
const products = db.prepare(`
|
||
SELECT p.*, s.name as supplier_name, pg.name as group_name
|
||
FROM products p
|
||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||
LEFT JOIN product_groups pg ON p.group_id = pg.id
|
||
ORDER BY p.name
|
||
`).all();
|
||
res.json(products);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 客户路由
|
||
/* ---------------------------------------------------------------------------
|
||
* 客户归属与可见性(Wayne 2026-09-14 定)
|
||
* ① 客户有「所属」:谁建的归谁,默认只有归属人自己看得见;
|
||
* ② 创建人主动丢进公海后,同组织成员都能看见、都能领取;
|
||
* ③ 其余情况只有拿到 customer.view_all 的最高级别管理账号能查看。
|
||
* 口径都收在 lib/customerAccess.js,这里只负责拼查询与守卫。
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** 列表用的公共 SELECT —— 归属人与创建人名字都要带出来,前端不用再打一次 */
|
||
const CUSTOMER_SELECT = `
|
||
SELECT c.*,
|
||
u.display_name AS owner_name,
|
||
cb.display_name AS created_by_name
|
||
FROM customers c
|
||
LEFT JOIN users u ON c.owner_id = u.id
|
||
LEFT JOIN users cb ON c.created_by = cb.id
|
||
`;
|
||
|
||
app.get('/api/customers', (req, res, next) => {
|
||
/* 公海看的是别人的资源池,权限点跟「我的客户」不同,按 scope 选守卫 */
|
||
const scope = String(req.query.scope || 'mine').trim();
|
||
const key = scope === 'pool' ? 'pool.view' : 'customer.view';
|
||
return auth.requirePermission(key)(req, res, next);
|
||
}, (req, res) => {
|
||
try {
|
||
const scope = String(req.query.scope || 'mine').trim();
|
||
if (scope === 'all' && !customerAccess.seesAll(req.user)) {
|
||
return res.status(403).json({ error: '只有最高级别系统管理账号可以查看全部客户' });
|
||
}
|
||
const { conditions, params } = customerAccess.scopeFilter(req.user, scope);
|
||
const customers = db.prepare(`
|
||
${CUSTOMER_SELECT}
|
||
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}
|
||
ORDER BY c.created_at DESC
|
||
`).all(...params);
|
||
res.json(customers);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const company = String(req.body?.company || '').trim();
|
||
if (!company) return res.status(400).json({ error: '公司名称不能为空' });
|
||
const { country, industry, website, email, phone, notes, company_en, address, source } = req.body;
|
||
/* 只认 active / inactive;不传时保持建表默认的 active,
|
||
这样老的调用方(客户管理页的简易表单)行为完全不变。 */
|
||
const status = ['active', 'inactive'].includes(req.body?.status) ? req.body.status : 'active';
|
||
/* 归属默认就是创建人自己 —— 「谁建的谁看得见」从建这一条起就成立。
|
||
只有拿到 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());
|
||
auth.audit({
|
||
username: req.user?.username,
|
||
action: 'customer_create',
|
||
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}`,
|
||
req
|
||
});
|
||
res.status(201).json({
|
||
id: result.lastInsertRowid,
|
||
owner_id: ownerId,
|
||
message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)'
|
||
});
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
// 编辑客户基本信息(客户详情页「基本信息」的编辑按钮)
|
||
app.patch('/api/customers/:id', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
if (!customerAccess.canEdit(req.user, customer)) {
|
||
return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' });
|
||
}
|
||
const fields = [];
|
||
const values = [];
|
||
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone', 'notes', 'source']) {
|
||
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);
|
||
}
|
||
if (req.body?.owner_id !== undefined) {
|
||
/* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */
|
||
if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属');
|
||
fields.push('owner_id = ?'); values.push(numberOrNull(req.body.owner_id));
|
||
}
|
||
if (req.body?.status !== undefined) {
|
||
/* 与新建保持一致:只认 active / inactive */
|
||
if (!['active', 'inactive'].includes(req.body.status)) throw badRequest('status 只能是 active 或 inactive');
|
||
fields.push('status = ?'); values.push(req.body.status);
|
||
}
|
||
if (!fields.length) throw badRequest('没有需要更新的字段');
|
||
fields.push('updated_at = ?'); values.push(nowIso(), customer.id);
|
||
db.prepare(`UPDATE customers SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||
res.json({ customer: getCustomer(customer.id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
/* 丢入公海:归属人主动放弃,客户变成组织共享资源 */
|
||
app.post('/api/customers/:id/to-pool', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
if (!customerAccess.canEdit(req.user, customer)) {
|
||
return res.status(403).json({ error: '只有客户归属人可以把客户丢进公海' });
|
||
}
|
||
if (customer.pool_status === 'public') throw badRequest('该客户已经在公海里了');
|
||
const note = req.body?.note ? String(req.body.note).trim().slice(0, 200) : null;
|
||
const now = nowIso();
|
||
db.prepare('UPDATE customers SET pool_status = ?, pool_at = ?, pool_note = ?, updated_at = ? WHERE id = ?')
|
||
.run('public', now, note, now, customer.id);
|
||
db.prepare(`INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)`)
|
||
.run(customer.id, '丢入公海', `由 ${req.user?.display_name || '—'} 丢入公海${note ? `:${note}` : ''}`, now);
|
||
auth.audit({
|
||
username: req.user?.username,
|
||
action: 'customer_to_pool',
|
||
detail: `客户「${customer.company}」丢入公海${note ? `(${note})` : ''}`,
|
||
req
|
||
});
|
||
res.json({ ok: true, customer: getCustomer(customer.id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
/* 从公海领取:谁先领归谁 */
|
||
app.post('/api/customers/:id/claim', auth.requirePermission('pool.claim'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
if (customer.pool_status !== 'public') throw badRequest('该客户不在公海,无法领取');
|
||
/* 已被列表过滤掉的「别组织的公海客户」,详情接口也要挡住 ——
|
||
否则知道 id 就能直接领走,组织隔离形同虚设 */
|
||
if (!customerAccess.inSameOrg(req.user, customer)) {
|
||
return res.status(403).json({ error: '这条客户属于其他组织的公海,你无法领取' });
|
||
}
|
||
/* 已经在自己名下就别走这条,免得把 pool_at 抹了还以为发生了什么 */
|
||
if (Number(customer.owner_id) === Number(req.user?.id)) throw badRequest('这条客户本来就归你');
|
||
const now = nowIso();
|
||
const claimed = db.prepare(`
|
||
UPDATE customers
|
||
SET owner_id = ?, pool_status = 'owned', pool_at = NULL, pool_note = NULL, updated_at = ?
|
||
WHERE id = ? AND pool_status = 'public'
|
||
`).run(req.user.id, now, customer.id);
|
||
/* 并发下可能已经被别人抢走,changes = 0 就明确回话,不要假装成功 */
|
||
if (!claimed.changes) return res.status(409).json({ error: '这条客户刚被同事领走了,刷新看看' });
|
||
const orgId = req.user.org_id || customer.org_id || null;
|
||
if (orgId && !customer.org_id) db.prepare('UPDATE customers SET org_id = ? WHERE id = ?').run(orgId, customer.id);
|
||
db.prepare(`INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)`)
|
||
.run(customer.id, '公海领取', `由 ${req.user?.display_name || '—'} 从公海领取`, now);
|
||
auth.audit({
|
||
username: req.user?.username,
|
||
action: 'customer_claim',
|
||
detail: `从公海领取客户「${customer.company}」`,
|
||
req
|
||
});
|
||
res.json({ ok: true, customer: getCustomer(customer.id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
// 客户详情、联系人与跟进
|
||
app.get('/api/customers/:id', (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
/* 看不见的客户一律按「不存在」处理:不泄露它是否存在,也省得前端区分两种错 */
|
||
if (!customerAccess.canView(req.user, customer)) return res.status(404).json({ error: '客户不存在' });
|
||
const customerId = customer.id;
|
||
res.json({
|
||
customer,
|
||
contacts: db.prepare('SELECT * FROM contacts WHERE customer_id = ? ORDER BY is_primary DESC, created_at DESC').all(customerId),
|
||
activities: db.prepare('SELECT * FROM activities WHERE customer_id = ? ORDER BY created_at DESC LIMIT 100').all(customerId),
|
||
followups: db.prepare(`SELECT f.*, ct.name AS contact_name FROM follow_up_records f LEFT JOIN contacts ct ON ct.id = f.contact_id WHERE f.customer_id = ? ORDER BY f.created_at DESC LIMIT 100`).all(customerId),
|
||
inquiries: db.prepare('SELECT * FROM inquiries WHERE customer_id = ? ORDER BY created_at DESC').all(customerId),
|
||
quoteOverview: db.prepare(`SELECT COUNT(*) AS count, COALESCE((SELECT SUM(qi.total_price) FROM quotation_items qi JOIN customer_quotations q2 ON q2.id = qi.quotation_id WHERE q2.customer_id = ?), 0) AS total_amount FROM customer_quotations WHERE customer_id = ?`).get(customerId, customerId),
|
||
orderOverview: db.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(total_amount), 0) AS total_amount FROM orders WHERE customer_id = ? AND status != 'cancelled'`).get(customerId),
|
||
quoteItems: db.prepare(`SELECT q.id, q.quote_no, q.quote_date, q.valid_until, q.currency, q.status, COALESCE((SELECT SUM(qi.total_price) FROM quotation_items qi WHERE qi.quotation_id = q.id), 0) AS total_amount FROM customer_quotations q WHERE q.customer_id = ? ORDER BY q.created_at DESC LIMIT 50`).all(customerId),
|
||
orderItems: db.prepare(`SELECT o.id, o.order_no, o.order_date, o.delivery_date, o.currency, o.status, o.total_amount FROM orders o WHERE o.customer_id = ? AND o.status != 'cancelled' ORDER BY o.order_date DESC LIMIT 50`).all(customerId)
|
||
});
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.post('/api/customers/:id/contacts', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
/* customer.edit 只说明「这个人有权编辑客户」,没说明「有权编辑**这一条**客户」。
|
||
少了这道归属校验,任何销售业务员猜个 id 就能往别人名下、甚至别组织的客户里
|
||
插联系人(GET 同一条客户是 404,写却成功),还能把 is_primary 设成 1
|
||
把自己变成主联系人。隔壁的 PATCH 一直有这道检查,这里是漏了。 */
|
||
if (!customerAccess.canEdit(req.user, customer)) {
|
||
return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' });
|
||
}
|
||
const name = String(req.body?.name || '').trim();
|
||
if (!name) return res.status(400).json({ error: '联系人姓名不能为空' });
|
||
const fields = ['customer_id', 'name', 'name_en', 'position', 'department', 'email', 'phone', 'whatsapp', 'wechat', 'is_primary', 'notes', 'created_at'];
|
||
const values = [customer.id, name, req.body.name_en || null, req.body.position || null, req.body.department || null, req.body.email || null, req.body.phone || null, req.body.whatsapp || null, req.body.wechat || null, req.body.is_primary ? 1 : 0, req.body.notes || null, nowIso()];
|
||
const result = db.prepare(`INSERT INTO contacts (${fields.join(',')}) VALUES (${fields.map(() => '?').join(',')})`).run(...values);
|
||
res.status(201).json({ id: result.lastInsertRowid, contact: db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
// 编辑联系人(客户详情页「联系人」卡片上的编辑按钮)
|
||
app.patch('/api/customers/:id/contacts/:contactId', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
if (!customerAccess.canEdit(req.user, customer)) {
|
||
return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' });
|
||
}
|
||
const contact = db.prepare('SELECT * FROM contacts WHERE id = ? AND customer_id = ?').get(Number(req.params.contactId), customer.id);
|
||
if (!contact) return res.status(404).json({ error: '联系人不存在' });
|
||
const fields = [];
|
||
const values = [];
|
||
if (req.body?.name !== undefined) {
|
||
const name = String(req.body.name || '').trim();
|
||
if (!name) throw badRequest('联系人姓名不能为空');
|
||
fields.push('name = ?'); values.push(name);
|
||
}
|
||
for (const key of ['name_en', 'position', 'department', 'email', 'phone', 'whatsapp', 'wechat', 'notes']) {
|
||
if (req.body?.[key] === undefined) continue;
|
||
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
|
||
fields.push(`${key} = ?`); values.push(value || null);
|
||
}
|
||
if (req.body?.is_primary !== undefined) { fields.push('is_primary = ?'); values.push(req.body.is_primary ? 1 : 0); }
|
||
if (!fields.length) throw badRequest('没有需要更新的字段');
|
||
db.prepare(`UPDATE contacts SET ${fields.join(', ')} WHERE id = ?`).run(...values, contact.id);
|
||
/* 设为主要联系人时把同客户的其他联系人降级,保证「主要」唯一 */
|
||
if (req.body?.is_primary) {
|
||
db.prepare('UPDATE contacts SET is_primary = 0 WHERE customer_id = ? AND id != ?').run(customer.id, contact.id);
|
||
}
|
||
res.json({ contact: db.prepare('SELECT * FROM contacts WHERE id = ?').get(contact.id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.get('/api/customers/:id/follow-ups', (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
if (!customerAccess.canView(req.user, customer)) return res.status(404).json({ error: '客户不存在' });
|
||
res.json(db.prepare(`SELECT f.*, ct.name AS contact_name FROM follow_up_records f LEFT JOIN contacts ct ON ct.id = f.contact_id WHERE f.customer_id = ? ORDER BY COALESCE(f.next_action_date, f.created_at) DESC`).all(Number(req.params.id)));
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.post('/api/customers/:id/follow-ups', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
if (!customerAccess.canEdit(req.user, customer)) {
|
||
return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' });
|
||
}
|
||
const activityType = String(req.body?.activity_type || '').trim();
|
||
const content = String(req.body?.content || '').trim();
|
||
if (!activityType || !content) return res.status(400).json({ error: 'activity_type 和 content 不能为空' });
|
||
const nextDate = req.body.next_action_date ? String(req.body.next_action_date).slice(0, 10) : null;
|
||
if (nextDate && !isDate(nextDate)) return res.status(400).json({ error: 'next_action_date 格式应为 YYYY-MM-DD' });
|
||
const contactId = numberOrNull(req.body.contact_id);
|
||
if (contactId && !db.prepare('SELECT id FROM contacts WHERE id = ? AND customer_id = ?').get(contactId, customer.id)) return res.status(400).json({ error: '联系人不属于该客户' });
|
||
const inquiryId = numberOrNull(req.body.inquiry_id);
|
||
if (inquiryId && !db.prepare('SELECT id FROM inquiries WHERE id = ? AND customer_id = ?').get(inquiryId, customer.id)) return res.status(400).json({ error: '询盘不属于该客户' });
|
||
const now = nowIso();
|
||
const result = db.prepare(`INSERT INTO follow_up_records (customer_id, activity_type, content, next_action, next_action_date, contact_id, inquiry_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(customer.id, activityType, content, req.body.next_action || null, nextDate, contactId, inquiryId, now, now);
|
||
db.prepare(`INSERT INTO activities (customer_id, activity_type, content, next_action, next_action_date, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(customer.id, activityType, content, req.body.next_action || null, nextDate, now);
|
||
db.prepare('UPDATE customers SET last_contact_at = ?, updated_at = ? WHERE id = ?').run(now, now, customer.id);
|
||
res.status(201).json({ id: result.lastInsertRowid, followUp: db.prepare('SELECT * FROM follow_up_records WHERE id = ?').get(result.lastInsertRowid) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
// 编辑跟进记录(时间线上每条记录的编辑按钮)
|
||
app.patch('/api/customers/:id/follow-ups/:followUpId', auth.requirePermission('customer.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.params.id);
|
||
if (!customer) return res.status(404).json({ error: '客户不存在' });
|
||
/* 同 POST 联系人:校验了「记录属于这条客户」不等于校验了「这条客户属于我」。
|
||
漏了这道,别人能把我客户的跟进记录改写掉 —— 比如把
|
||
「客户确认 3000 套订单」改成「客户取消了订单」,CRM 历史就被篡改了。
|
||
POST /follow-ups 一直有这道检查,这里是漏了。 */
|
||
if (!customerAccess.canEdit(req.user, customer)) {
|
||
return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' });
|
||
}
|
||
const record = db.prepare('SELECT * FROM follow_up_records WHERE id = ? AND customer_id = ?').get(Number(req.params.followUpId), customer.id);
|
||
if (!record) return res.status(404).json({ error: '跟进记录不存在' });
|
||
const fields = [];
|
||
const values = [];
|
||
if (req.body?.activity_type !== undefined) {
|
||
const type = String(req.body.activity_type || '').trim();
|
||
if (!type) throw badRequest('activity_type 不能为空');
|
||
fields.push('activity_type = ?'); values.push(type);
|
||
}
|
||
if (req.body?.content !== undefined) {
|
||
const content = String(req.body.content || '').trim();
|
||
if (!content) throw badRequest('content 不能为空');
|
||
fields.push('content = ?'); values.push(content);
|
||
}
|
||
if (req.body?.next_action !== undefined) {
|
||
fields.push('next_action = ?'); values.push(req.body.next_action ? String(req.body.next_action).trim() : null);
|
||
}
|
||
if (req.body?.next_action_date !== undefined) {
|
||
const nextDate = req.body.next_action_date ? String(req.body.next_action_date).slice(0, 10) : null;
|
||
if (nextDate && !isDate(nextDate)) throw badRequest('next_action_date 格式应为 YYYY-MM-DD');
|
||
fields.push('next_action_date = ?'); values.push(nextDate);
|
||
}
|
||
if (req.body?.contact_id !== undefined) {
|
||
const contactId = numberOrNull(req.body.contact_id);
|
||
if (contactId && !db.prepare('SELECT id FROM contacts WHERE id = ? AND customer_id = ?').get(contactId, customer.id)) throw badRequest('联系人不属于该客户');
|
||
fields.push('contact_id = ?'); values.push(contactId);
|
||
}
|
||
if (req.body?.inquiry_id !== undefined) {
|
||
const inquiryId = numberOrNull(req.body.inquiry_id);
|
||
if (inquiryId && !db.prepare('SELECT id FROM inquiries WHERE id = ? AND customer_id = ?').get(inquiryId, customer.id)) throw badRequest('询盘不属于该客户');
|
||
fields.push('inquiry_id = ?'); values.push(inquiryId);
|
||
}
|
||
if (!fields.length) throw badRequest('没有需要更新的字段');
|
||
fields.push('updated_at = ?'); values.push(nowIso(), record.id);
|
||
db.prepare(`UPDATE follow_up_records SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||
/* activities 是当时的流水快照,编辑跟进不回写它,历史记录保持原样 */
|
||
res.json({
|
||
followUp: db.prepare(`SELECT f.*, ct.name AS contact_name FROM follow_up_records f LEFT JOIN contacts ct ON ct.id = f.contact_id WHERE f.id = ?`).get(record.id)
|
||
});
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
// 询盘与报价计算
|
||
function inquiryNo() {
|
||
return `INQ-${new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)}-${Math.floor(Math.random() * 900 + 100)}`;
|
||
}
|
||
function calculateInquiryItem(body, product, packaging, existing) {
|
||
const purchasePrice = body.purchase_price !== undefined ? validPositiveNumber(body.purchase_price, '采购价') : Number(existing?.purchase_price ?? product.price);
|
||
const exchangeRate = body.exchange_rate !== undefined ? validPositiveNumber(body.exchange_rate, '汇率') : Number(existing?.exchange_rate ?? 1);
|
||
const quantity = body.quantity !== undefined ? Number(body.quantity) : Number(existing?.quantity ?? 1);
|
||
if (!Number.isInteger(quantity) || quantity <= 0) throw new Error('数量必须是大于 0 的整数');
|
||
const currency = String(body.currency !== undefined ? body.currency : (existing?.currency || product.currency || 'CNY')).trim();
|
||
if (!currency) throw new Error('币种不能为空');
|
||
let exw;
|
||
let markupRate;
|
||
if (body.manual_exw !== undefined && body.manual_exw !== null && body.manual_exw !== '') {
|
||
exw = validPositiveNumber(body.manual_exw, 'EXW');
|
||
markupRate = exw / (purchasePrice / exchangeRate) - 1;
|
||
if (!Number.isFinite(markupRate) || markupRate < -1) throw new Error('手动 EXW 反算出的加价率无效');
|
||
} else {
|
||
markupRate = body.markup_rate !== undefined ? Number(body.markup_rate) : Number(existing?.markup_rate ?? 0);
|
||
if (!Number.isFinite(markupRate) || markupRate < -1) throw new Error('加价率必须是大于等于 -1 的数字');
|
||
exw = purchasePrice / exchangeRate * (1 + markupRate);
|
||
}
|
||
if (!Number.isFinite(exw) || exw <= 0) throw new Error('EXW 计算结果无效');
|
||
return { purchasePrice, exchangeRate, quantity, currency, markupRate, markupFactor: 1 + markupRate, exw,
|
||
packagingSnapshot: JSON.stringify(packaging || null), productSnapshot: JSON.stringify(product), supplierId: product.supplier_id || null, supplierName: product.supplier_name || null };
|
||
}
|
||
|
||
app.get('/api/inquiries', (req, res) => {
|
||
try {
|
||
const conditions = [];
|
||
const params = [];
|
||
if (req.query.customer_id !== undefined) { const id = Number(req.query.customer_id); if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'customer_id 无效' }); conditions.push('i.customer_id = ?'); params.push(id); }
|
||
if (req.query.status) { conditions.push('i.status = ?'); params.push(String(req.query.status)); }
|
||
const rows = db.prepare(`SELECT i.*, c.company AS customer_name, COUNT(ii.id) AS item_count FROM inquiries i LEFT JOIN customers c ON c.id = i.customer_id LEFT JOIN inquiry_items ii ON ii.inquiry_id = i.id ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''} GROUP BY i.id ORDER BY i.created_at DESC`).all(...params);
|
||
res.json(rows);
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.post('/api/inquiries', auth.requirePermission('inquiry.edit'), (req, res) => {
|
||
try {
|
||
const customerId = Number(req.body?.customer_id);
|
||
if (!Number.isInteger(customerId) || customerId <= 0 || !getCustomer(customerId)) return res.status(400).json({ error: 'customer_id 无效' });
|
||
const now = nowIso();
|
||
const result = db.prepare(`INSERT INTO inquiries (inquiry_no, customer_id, inquiry_date, title, status, currency, source, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(inquiryNo(), customerId, isDate(req.body.inquiry_date) ? req.body.inquiry_date : now.slice(0, 10), req.body.title || null, req.body.status || 'draft', req.body.currency || 'USD', req.body.source || null, req.body.notes || null, now, now);
|
||
res.status(201).json({ inquiry: db.prepare('SELECT * FROM inquiries WHERE id = ?').get(result.lastInsertRowid) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.get('/api/inquiries/:id', (req, res) => {
|
||
try {
|
||
const inquiry = db.prepare(`SELECT i.*, c.company AS customer_name FROM inquiries i LEFT JOIN customers c ON c.id = i.customer_id WHERE i.id = ?`).get(Number(req.params.id));
|
||
if (!inquiry) return res.status(404).json({ error: '询盘不存在' });
|
||
res.json({ inquiry, items: db.prepare('SELECT * FROM inquiry_items WHERE inquiry_id = ? ORDER BY id').all(inquiry.id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.patch('/api/inquiries/:id', auth.requirePermission('inquiry.edit'), (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!db.prepare('SELECT id FROM inquiries WHERE id = ?').get(id)) return res.status(404).json({ error: '询盘不存在' });
|
||
const allowed = ['title', 'status', 'currency', 'source', 'notes', 'inquiry_date'];
|
||
const fields = []; const params = [];
|
||
for (const field of allowed) if (req.body[field] !== undefined) { if (field === 'inquiry_date' && !isDate(req.body[field])) return res.status(400).json({ error: 'inquiry_date 格式应为 YYYY-MM-DD' }); fields.push(`${field} = ?`); params.push(req.body[field]); }
|
||
if (!fields.length) return res.status(400).json({ error: '没有需要更新的字段' });
|
||
fields.push('updated_at = ?'); params.push(nowIso(), id);
|
||
db.prepare(`UPDATE inquiries SET ${fields.join(', ')} WHERE id = ?`).run(...params);
|
||
res.json({ inquiry: db.prepare('SELECT * FROM inquiries WHERE id = ?').get(id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.post('/api/inquiries/:id/items', auth.requirePermission('inquiry.edit'), async (req, res) => {
|
||
try {
|
||
const inquiry = db.prepare('SELECT * FROM inquiries WHERE id = ?').get(Number(req.params.id));
|
||
if (!inquiry) return res.status(404).json({ error: '询盘不存在' });
|
||
const { product, packaging } = await resolveProduct(req.body?.sku);
|
||
const values = calculateInquiryItem(req.body || {}, product, packaging);
|
||
const now = nowIso();
|
||
const result = db.prepare(`INSERT INTO inquiry_items (inquiry_id, sku, product_name, supplier_id, supplier_name, purchase_price, currency, exchange_rate, markup_rate, markup_factor, exw, quantity, packaging_snapshot, product_snapshot, quoted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(inquiry.id, product.sku, product.name || product.name_en || product.sku, values.supplierId, values.supplierName, values.purchasePrice, values.currency, values.exchangeRate, values.markupRate, values.markupFactor, values.exw, values.quantity, values.packagingSnapshot, values.productSnapshot, now, now, now);
|
||
res.status(201).json({ item: db.prepare('SELECT * FROM inquiry_items WHERE id = ?').get(result.lastInsertRowid) });
|
||
} catch (error) { skuError(res, error); }
|
||
});
|
||
|
||
app.patch('/api/inquiry-items/:id', auth.requirePermission('inquiry.edit'), async (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
const existing = db.prepare('SELECT * FROM inquiry_items WHERE id = ?').get(id);
|
||
if (!existing) return res.status(404).json({ error: '询盘明细不存在' });
|
||
const { product, packaging } = await resolveProduct(req.body?.sku || existing.sku);
|
||
const values = calculateInquiryItem(req.body || {}, product, packaging, existing);
|
||
const now = nowIso();
|
||
db.prepare(`UPDATE inquiry_items SET sku = ?, product_name = ?, supplier_id = ?, supplier_name = ?, purchase_price = ?, currency = ?, exchange_rate = ?, markup_rate = ?, markup_factor = ?, exw = ?, quantity = ?, packaging_snapshot = ?, product_snapshot = ?, quoted_at = ?, updated_at = ? WHERE id = ?`).run(product.sku, product.name || product.name_en || product.sku, values.supplierId, values.supplierName, values.purchasePrice, values.currency, values.exchangeRate, values.markupRate, values.markupFactor, values.exw, values.quantity, values.packagingSnapshot, values.productSnapshot, now, now, id);
|
||
res.json({ item: db.prepare('SELECT * FROM inquiry_items WHERE id = ?').get(id) });
|
||
} catch (error) { skuError(res, error); }
|
||
});
|
||
|
||
app.delete('/api/inquiry-items/:id', auth.requirePermission('inquiry.edit'), (req, res) => {
|
||
try {
|
||
const result = db.prepare('DELETE FROM inquiry_items WHERE id = ?').run(Number(req.params.id));
|
||
if (!result.changes) return res.status(404).json({ error: '询盘明细不存在' });
|
||
res.json({ message: '明细已删除' });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
// 产品包装
|
||
// 先用 resolveProduct 解析一次:既校验 SKU 真实存在,也拿回规范化后的货号 ——
|
||
// product_packaging.sku → products.sku 这条外键要求两边写法完全一致。
|
||
app.get('/api/products/:sku/packaging', async (req, res) => {
|
||
try {
|
||
const { product } = await resolveProduct(req.params.sku);
|
||
res.json(db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(product.sku) || { sku: product.sku });
|
||
} catch (error) { skuError(res, error); }
|
||
});
|
||
|
||
app.put('/api/products/:sku/packaging', auth.requirePermission('product.edit'), async (req, res) => {
|
||
try {
|
||
const { product } = await resolveProduct(req.params.sku);
|
||
const sku = product.sku;
|
||
const allowed = ['inner_pack_qty', 'carton_qty', 'carton_length', 'carton_width', 'carton_height', 'carton_weight', 'unit_length', 'unit_width', 'unit_height', 'unit_weight', 'packaging_material', 'notes'];
|
||
const values = allowed.map(field => req.body[field] === undefined ? null : (['packaging_material', 'notes'].includes(field) ? String(req.body[field]).trim() || null : numberOrNull(req.body[field])));
|
||
if (values.some((value, index) => value !== null && !['packaging_material', 'notes'].includes(allowed[index]) && !Number.isFinite(value))) return res.status(400).json({ error: '包装数值字段必须是数字' });
|
||
const now = nowIso();
|
||
db.prepare(`INSERT INTO product_packaging (sku, ${allowed.join(', ')}, created_at, updated_at) VALUES (?, ${allowed.map(() => '?').join(', ')}, ?, ?) ON CONFLICT(sku) DO UPDATE SET ${allowed.map(field => `${field} = excluded.${field}`).join(', ')}, updated_at = excluded.updated_at`).run(sku, ...values, now, now);
|
||
res.json({ packaging: db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(sku) });
|
||
} catch (error) { skuError(res, error); }
|
||
});
|
||
|
||
// 订单路由
|
||
app.get('/api/orders', (req, res) => {
|
||
try {
|
||
const orders = db.prepare(`
|
||
SELECT o.*, c.company as customer_name
|
||
FROM orders o
|
||
LEFT JOIN customers c ON o.customer_id = c.id
|
||
ORDER BY o.order_date DESC
|
||
`).all();
|
||
res.json(orders);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 商机路由
|
||
app.get('/api/deals', (req, res) => {
|
||
try {
|
||
const deals = db.prepare(`
|
||
SELECT d.*, c.company as customer_name
|
||
FROM deals d
|
||
LEFT JOIN customers c ON d.customer_id = c.id
|
||
WHERE d.status = 'open'
|
||
ORDER BY d.expected_close_date
|
||
`).all();
|
||
res.json(deals);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Assistant read layer: every query is explicit and read-only unless a future
|
||
// command is added to the allowlist below.
|
||
function getAssistantContext(user) {
|
||
/* 助手读的也是同一份客户口径 —— 否则业务员绕一句「列出最近客户」就能问出别人的名单 */
|
||
const custScope = customerAccess.scopeFilter(user, 'all');
|
||
const custWhere = custScope.conditions.length ? ' WHERE ' + custScope.conditions.join(' AND ') : '';
|
||
const stats = {
|
||
suppliers: db.prepare('SELECT COUNT(*) as count FROM suppliers').get().count,
|
||
products: db.prepare('SELECT COUNT(*) as count FROM products').get().count,
|
||
customers: db.prepare(`SELECT COUNT(*) as count FROM customers c${custWhere}`).get(...custScope.params).count,
|
||
orders: db.prepare(`SELECT COUNT(*) as count FROM orders WHERE status != 'cancelled'`).get().count,
|
||
deals: db.prepare(`SELECT COUNT(*) as count FROM deals WHERE status = 'open'`).get().count,
|
||
todos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count
|
||
};
|
||
return {
|
||
generatedAt: new Date().toISOString(),
|
||
stats,
|
||
recent: {
|
||
suppliers: db.prepare('SELECT id, name, contact_person, phone FROM suppliers ORDER BY created_at DESC LIMIT 8').all(),
|
||
products: db.prepare('SELECT sku, sku as id, name, brand, supplier_id, confidence as status FROM products ORDER BY created_at DESC LIMIT 8').all(),
|
||
customers: db.prepare(`SELECT id, company, country, industry, status, last_contact_at FROM customers c${custWhere} ORDER BY created_at DESC LIMIT 8`).all(...custScope.params),
|
||
orders: db.prepare('SELECT id, order_no, customer_id, order_date, total_amount, currency, status FROM orders ORDER BY order_date DESC LIMIT 8').all(),
|
||
deals: db.prepare(`SELECT id, title, customer_id, amount, currency, stage, expected_close_date FROM deals WHERE status = 'open' ORDER BY expected_close_date LIMIT 8`).all(),
|
||
todos: db.prepare(`SELECT id, title, due_date, priority, status, customer_id FROM todos WHERE status = 'pending' ORDER BY due_date ASC LIMIT 8`).all(),
|
||
activities: db.prepare('SELECT id, activity_type, subject, content, created_at FROM activities ORDER BY created_at DESC LIMIT 8').all()
|
||
},
|
||
capabilities: [
|
||
{ id: 'read.dashboard', label: '读取工作台概览' },
|
||
{ id: 'read.suppliers', label: '检索供应商与联系人' },
|
||
{ id: 'read.products', label: '检索产品与 SKU' },
|
||
{ id: 'read.customers', label: '检索客户与跟进状态' },
|
||
{ id: 'read.orders', label: '查询订单与状态' },
|
||
{ id: 'read.deals', label: '查询商机与预计成交' },
|
||
{ id: 'read.todos', label: '查询待办与截止日期' },
|
||
{ id: 'write.confirmed', label: '执行已确认的业务写操作' }
|
||
]
|
||
};
|
||
}
|
||
|
||
app.get('/api/assistant/context', (req, res) => {
|
||
try {
|
||
res.json(getAssistantContext(req.user));
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 本地规则答复(零配置兜底)。
|
||
* 没接任何模型时小皮也得能干活,所以这套意图识别一直保留。
|
||
*/
|
||
function localAssistantReply(prompt, context) {
|
||
const { stats } = context;
|
||
let reply = `已读取系统数据:${stats.customers} 个客户、${stats.products} 个产品、${stats.orders} 笔进行中订单、${stats.deals} 个开放商机、${stats.todos} 项待办。`;
|
||
let meta = 'READ_ONLY_CONTEXT';
|
||
if (/待办|任务|日程/.test(prompt)) {
|
||
const items = context.recent.todos.slice(0, 5).map(item => item.title).join('、');
|
||
reply = items ? `当前有 ${stats.todos} 项待办:${items}。` : '当前没有待处理的待办事项。';
|
||
} else if (/客户/.test(prompt)) {
|
||
const items = context.recent.customers.slice(0, 5).map(item => item.company).join('、');
|
||
reply = items ? `我找到 ${stats.customers} 个客户,最近记录包括:${items}。` : '当前还没有客户数据。';
|
||
} else if (/产品|SKU/.test(prompt)) {
|
||
const items = context.recent.products.slice(0, 5).map(item => item.name || item.sku).join('、');
|
||
reply = items ? `当前有 ${stats.products} 个产品,最近记录包括:${items}。` : '当前还没有产品数据。';
|
||
} else if (/供应商/.test(prompt)) {
|
||
const items = context.recent.suppliers.slice(0, 5).map(item => item.name).join('、');
|
||
reply = items ? `当前有 ${stats.suppliers} 个供应商,最近记录包括:${items}。` : '当前还没有供应商数据。';
|
||
} else if (/订单/.test(prompt)) {
|
||
reply = stats.orders ? `当前有 ${stats.orders} 笔进行中订单,最近订单状态已同步到系统上下文。` : '当前没有进行中的订单。';
|
||
} else if (/新建|新增|创建|修改|更新|删除|完成|标记/.test(prompt)) {
|
||
meta = 'CONFIRMATION_REQUIRED';
|
||
reply = '我已经识别到这是一个可能改变系统数据的操作。当前入口已连接系统能力层,但在真正写入前需要确认具体对象、字段和目标;请先说明要操作的业务对象。';
|
||
}
|
||
return { reply, meta };
|
||
}
|
||
|
||
/** 把系统上下文压成一段给大模型看的简报 —— 只带聚合数与最近几条,不外传全库 */
|
||
function assistantSystemPrompt(context) {
|
||
const { stats, recent } = context;
|
||
const line = (label, list, pick) => {
|
||
if (!list?.length) return `${label}:暂无`;
|
||
return `${label}:${list.slice(0, 8).map(pick).join('、')}`;
|
||
};
|
||
return [
|
||
'你是「小皮」,外贸出口业务的系统助理,回答简洁、务实、中文优先。',
|
||
'你只能依据下面这份系统快照回答;快照里没有的信息要如实说没有,不要编造。',
|
||
'涉及写操作(新建/修改/删除)时,先复述你要改的对象与字段,等用户确认。',
|
||
'',
|
||
`客户 ${stats.customers} 个 · 产品 ${stats.products} 个 · 供应商 ${stats.suppliers} 个 · 进行中订单 ${stats.orders} 笔 · 开放商机 ${stats.deals} 个 · 待办 ${stats.todos} 项`,
|
||
line('最近客户', recent.customers, item => item.company),
|
||
line('最近产品', recent.products, item => item.name || item.sku),
|
||
line('最近供应商', recent.suppliers, item => item.name),
|
||
line('最近待办', recent.todos, item => item.title)
|
||
].join('\n');
|
||
}
|
||
|
||
app.post('/api/assistant/command', async (req, res) => {
|
||
try {
|
||
const prompt = String(req.body?.prompt || '').trim();
|
||
if (!prompt) return res.status(400).json({ error: '请输入指令' });
|
||
const context = getAssistantContext(req.user);
|
||
|
||
/* 本地规则先算好:模型没配、或模型挂了,都能立刻回一句有用的话 */
|
||
const local = localAssistantReply(prompt, context);
|
||
|
||
/* 配了「智能助手对话」用途的模型就真调模型 —— 这才是智能配置的落点 */
|
||
let modelResult = null;
|
||
try {
|
||
modelResult = await aiProviders.chatByPurpose('assistant_chat', [
|
||
{ role: 'system', content: assistantSystemPrompt(context) },
|
||
{ role: 'user', content: prompt }
|
||
], { maxTokens: 900, temperature: 0.3, timeoutMs: 60000 });
|
||
} catch (error) {
|
||
modelResult = { ok: false, error: error.message, model: '(未记录)' };
|
||
}
|
||
|
||
if (modelResult?.ok && modelResult.text) {
|
||
return res.json({
|
||
reply: modelResult.text,
|
||
meta: 'MODEL',
|
||
context,
|
||
model: {
|
||
provider: modelResult.provider,
|
||
model: modelResult.model,
|
||
latency_ms: modelResult.latency_ms
|
||
}
|
||
});
|
||
}
|
||
|
||
if (modelResult && !modelResult.ok) {
|
||
/* 模型失败不静默:把原因带出来,用户才知道该去设置里查 */
|
||
return res.json({
|
||
reply: `${local.reply}\n\n(模型调用失败:${modelResult.error}。以上是本地数据直答,可在「设置 → 智能配置」检查连通性。)`,
|
||
meta: 'RULE_FALLBACK',
|
||
context,
|
||
model_error: modelResult.error
|
||
});
|
||
}
|
||
|
||
res.json({ reply: local.reply, meta: local.meta, context });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
|
||
|
||
// 全局搜索:仅索引业务表的非敏感字段,查询参数使用绑定变量。
|
||
const SEARCH_SOURCES = [
|
||
{ table: 'suppliers', type: 'supplier', typeLabel: '供应商', title: ['name', 'short_name'], detail: ['contact_person', 'phone', 'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'], path: '/suppliers' },
|
||
{ table: 'product_groups', type: 'product', typeLabel: '产品分组', title: ['name', 'group_key'], detail: ['brand', 'category', 'notes'], path: '/products' },
|
||
{ table: 'products', type: 'product', typeLabel: '产品', title: ['name', 'sku'], detail: ['display_sku', 'name_en', 'brand', 'category', 'spec', 'price', 'currency', 'moq', 'description', 'parse_method', 'source_contract', 'confidence'], path: '/products' },
|
||
{ table: 'item_attributes', type: 'product', typeLabel: '产品参数', title: ['item_sku', 'key'], detail: ['value'], path: '/products' },
|
||
{ table: 'tier_prices', type: 'product', typeLabel: '阶梯价格', title: ['sku'], detail: ['moq_from', 'moq_to', 'price', 'currency', 'valid_from', 'valid_until', 'notes'], path: '/products' },
|
||
{ table: 'supplier_quotes', type: 'supplier', typeLabel: '供应商报价', title: ['quote_no'], detail: ['quote_date', 'valid_until', 'currency', 'payment_terms', 'notes', 'status'], path: '/suppliers' },
|
||
{ table: 'price_history', type: 'product', typeLabel: '价格历史', title: ['sku'], detail: ['old_price', 'new_price', 'old_moq', 'new_moq', 'currency', 'source_contract', 'confidence', 'changed_at'], path: '/products' },
|
||
{ table: 'customers', type: 'customer', typeLabel: '客户', title: ['company', 'company_en'], detail: ['country', 'industry', 'website', 'address', 'phone', 'email', 'rating', 'status', 'source', 'tags', 'notes', 'last_contact_at'], path: '/customers' },
|
||
{ table: 'contacts', type: 'customer', typeLabel: '客户联系人', title: ['name', 'name_en'], detail: ['position', 'department', 'email', 'phone', 'whatsapp', 'wechat', 'notes'], path: '/customers' },
|
||
{ table: 'orders', type: 'order', typeLabel: '订单', title: ['order_no'], detail: ['order_date', 'delivery_date', 'payment_terms', 'total_amount', 'currency', 'status', 'notes'], path: '/orders' },
|
||
{ table: 'order_items', type: 'order', typeLabel: '订单明细', title: ['product_name', 'sku'], detail: ['qty', 'unit_price', 'amount', 'notes'], path: '/orders' },
|
||
{ table: 'deals', type: 'deal', typeLabel: '商机', title: ['title'], detail: ['amount', 'currency', 'stage', 'probability', 'expected_close_date', 'actual_close_date', 'products', 'status', 'notes'], path: '/prospects' },
|
||
{ table: 'todos', type: 'todo', typeLabel: '待办', title: ['title'], detail: ['description', 'due_date', 'priority', 'status'], path: '/' },
|
||
{ table: 'activities', type: 'activity', typeLabel: '活动', title: ['subject'], detail: ['activity_type', 'content', 'next_action', 'next_action_date'], path: '/' }
|
||
];
|
||
|
||
function existingColumns(table) {
|
||
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name));
|
||
}
|
||
|
||
app.get('/api/search', (req, res) => {
|
||
try {
|
||
const term = String(req.query.q || '').trim().slice(0, 100);
|
||
if (!term) return res.json({ results: [] });
|
||
const pattern = `%${term}%`;
|
||
const results = [];
|
||
for (const source of SEARCH_SOURCES) {
|
||
const columns = existingColumns(source.table);
|
||
const searchable = [...source.title, ...source.detail].filter(column => columns.has(column));
|
||
if (!searchable.length) continue;
|
||
const where = searchable.map(column => `CAST(${column} AS TEXT) LIKE ?`).join(' OR ');
|
||
const selected = ['rowid AS _search_id', ...searchable].join(', ');
|
||
const rows = db.prepare(`SELECT ${selected} FROM ${source.table} WHERE ${where} LIMIT 8`).all(...searchable.map(() => pattern));
|
||
rows.forEach(row => {
|
||
const title = source.title.map(column => row[column]).filter(Boolean).join(' · ') || source.typeLabel;
|
||
const detail = source.detail.map(column => row[column]).filter(value => value !== undefined && value !== null && value !== '').join(' · ');
|
||
results.push({ id: row._search_id, type: source.type, typeLabel: source.typeLabel, title, detail: detail || '匹配到业务记录', path: source.path });
|
||
});
|
||
}
|
||
res.json({ results: results.slice(0, 30) });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 日程 / 待办(工作日历用)
|
||
// 日历按可见网格的日期范围取数,包含已完成项,便于区分状态。
|
||
// ---------------------------------------------------------------------------
|
||
const PRIORITIES = ['high', 'medium', 'low'];
|
||
|
||
// list=schedule(默认)按 due_date 取日历日程;list=memo 取备忘录待办(无日期)
|
||
app.get('/api/todos', (req, res) => {
|
||
try {
|
||
const list = req.query.list === 'memo' ? 'memo' : 'schedule';
|
||
const conditions = ['t.list = ?'];
|
||
const params = [list];
|
||
|
||
if (list === 'schedule') {
|
||
const from = String(req.query.from || '').slice(0, 10);
|
||
const to = String(req.query.to || '').slice(0, 10);
|
||
conditions.push('t.due_date IS NOT NULL');
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(from)) { conditions.push('substr(t.due_date, 1, 10) >= ?'); params.push(from); }
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(to)) { conditions.push('substr(t.due_date, 1, 10) <= ?'); params.push(to); }
|
||
}
|
||
|
||
const order = list === 'memo'
|
||
? `CASE t.status WHEN 'completed' THEN 1 ELSE 0 END ASC, t.created_at ASC`
|
||
: `t.due_date ASC, CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END`;
|
||
|
||
const todos = db.prepare(`
|
||
SELECT t.id, t.title, t.description, t.due_date, t.priority, t.status, t.customer_id,
|
||
t.created_at, t.completed_at, c.company AS customer_name
|
||
FROM todos t
|
||
LEFT JOIN customers c ON t.customer_id = c.id
|
||
WHERE ${conditions.join(' AND ')}
|
||
ORDER BY ${order}
|
||
LIMIT 500
|
||
`).all(...params);
|
||
|
||
res.json(todos);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/todos', (req, res) => {
|
||
try {
|
||
const list = req.body?.list === 'memo' ? 'memo' : 'schedule';
|
||
const title = String(req.body?.title || '').trim().slice(0, 200);
|
||
if (!title) return res.status(400).json({ error: list === 'memo' ? '请填写待办内容' : '请填写日程标题' });
|
||
|
||
// 备忘录不带日期;日程必须落在某一天
|
||
let dueDate = null;
|
||
if (list === 'schedule') {
|
||
const rawDueDate = String(req.body?.due_date || '').slice(0, 10);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(rawDueDate)) return res.status(400).json({ error: '日期格式应为 YYYY-MM-DD' });
|
||
dueDate = rawDueDate;
|
||
}
|
||
|
||
const description = String(req.body?.description || '').trim().slice(0, 1000) || null;
|
||
const priority = PRIORITIES.includes(req.body?.priority) ? req.body.priority : 'medium';
|
||
const now = new Date().toISOString();
|
||
|
||
const result = db.prepare(`
|
||
INSERT INTO todos (title, description, due_date, priority, status, list, created_at)
|
||
VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||
`).run(title, description, dueDate, priority, list, now);
|
||
|
||
// 活动流;activities 表缺失或字段约束变化时不影响主流程
|
||
try {
|
||
db.prepare(`
|
||
INSERT INTO activities (activity_type, subject, content, created_at)
|
||
VALUES ('todo', ?, ?, ?)
|
||
`).run(list === 'memo' ? '新建待办' : '新建日程', list === 'memo' ? title : `${dueDate} ${title}`, now);
|
||
} catch (activityError) {
|
||
console.warn('写入活动流失败(已忽略):', activityError.message);
|
||
}
|
||
|
||
const todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(result.lastInsertRowid);
|
||
res.json({ id: result.lastInsertRowid, todo, message: list === 'memo' ? '待办已添加' : '日程已添加' });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 勾选完成 / 取消完成 / 改标题等,局部更新
|
||
app.patch('/api/todos/:id', (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
const existing = db.prepare('SELECT * FROM todos WHERE id = ?').get(id);
|
||
if (!existing) return res.status(404).json({ error: '待办不存在' });
|
||
|
||
const fields = [];
|
||
const params = [];
|
||
const body = req.body || {};
|
||
|
||
if (body.status !== undefined) {
|
||
const status = body.status === 'completed' ? 'completed' : 'pending';
|
||
fields.push('status = ?', 'completed_at = ?');
|
||
params.push(status, status === 'completed' ? new Date().toISOString() : null);
|
||
} else if (body.completed === true || body.completed === false) {
|
||
fields.push('status = ?', 'completed_at = ?');
|
||
params.push(body.completed ? 'completed' : 'pending', body.completed ? new Date().toISOString() : null);
|
||
}
|
||
|
||
if (body.title !== undefined) {
|
||
const title = String(body.title).trim().slice(0, 200);
|
||
if (!title) return res.status(400).json({ error: '标题不能为空' });
|
||
fields.push('title = ?');
|
||
params.push(title);
|
||
}
|
||
|
||
if (body.priority !== undefined) {
|
||
fields.push('priority = ?');
|
||
params.push(PRIORITIES.includes(body.priority) ? body.priority : 'medium');
|
||
}
|
||
|
||
if (body.description !== undefined) {
|
||
fields.push('description = ?');
|
||
params.push(String(body.description).trim().slice(0, 1000) || null);
|
||
}
|
||
|
||
if (!fields.length) return res.status(400).json({ error: '没有需要更新的字段' });
|
||
|
||
params.push(id);
|
||
db.prepare(`UPDATE todos SET ${fields.join(', ')} WHERE id = ?`).run(...params);
|
||
|
||
const todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(id);
|
||
res.json({ todo, message: todo.status === 'completed' ? '已标记完成' : '已更新' });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/todos/:id', (req, res) => {
|
||
try {
|
||
const result = db.prepare('DELETE FROM todos WHERE id = ?').run(Number(req.params.id));
|
||
if (!result.changes) return res.status(404).json({ error: '待办不存在' });
|
||
res.json({ message: '已删除' });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 合同导入:认列交给通用字符规则引擎(lib/contractParser.js),不按行列位置。
|
||
// 原件落磁盘保留 7 天(lib/contractFiles.js);解析结果才是长期数据。
|
||
app.post('/api/contracts/import', auth.requirePermission('cleanup.view'), upload.array('files', 20), fixUploadFilenames, (req, res) => {
|
||
try {
|
||
const files = req.files || [];
|
||
if (!files.length) return res.status(400).json({ error: '请上传至少一个合同文件,字段名为 files' });
|
||
const now = nowIso();
|
||
const rules = contractParser.loadRules();
|
||
const jobNo = `CIMP-${now.replace(/[-:TZ.]/g, '').slice(0, 14)}-${Math.floor(Math.random() * 900 + 100)}`;
|
||
const ruleVersion = `${Object.keys(rules.fields).length}字段词典${rules.override_source ? '+自定义' : ''}`;
|
||
const jobResult = db.prepare(
|
||
`INSERT INTO contract_import_jobs (job_no, status, file_count, source_files, parse_method, rule_version,
|
||
column_map, parse_source, created_at, updated_at)
|
||
VALUES (?, 'parsing', ?, ?, 'rules', ?, NULL, 'rules', ?, ?)`
|
||
).run(jobNo, files.length, JSON.stringify(files.map(file => file.originalname)), ruleVersion, now, now);
|
||
|
||
const insertItem = db.prepare(
|
||
`INSERT INTO contract_import_items (job_id, filename, row_no, raw_text, sku, product_name, spec,
|
||
purchase_price, currency, quantity, moq, status, review_note,
|
||
parse_source, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_review', ?, 'rules', ?, ?)`
|
||
);
|
||
/* 分批提交:每批 BATCH_SIZE 行一个事务。
|
||
*
|
||
* 为什么不是「一个文件一个事务」:大 CSV(几千行)会让 SQLite 的写锁一次持有数秒,
|
||
* 而写锁是全库唯一的 —— 这期间其他人的每一次写操作都得排队干等。
|
||
* 切成 400 行一批后,单次锁持有约 1–5ms,既保住批量写入的速度
|
||
* (不必每插一条就 fsync 一次),也不会拖住任何人。
|
||
*
|
||
* 代价是失去「整文件原子性」:中途失败时先落的批次会留在库里。
|
||
* 但它们都带着文件名和行号、状态是 pending_review,在待复核队列里看得见也改得动 ——
|
||
* 比整批回滚更实用(用户至少能保住已解析的部分)。job 仍会被标成 parse_failed。
|
||
*
|
||
* ⚠️ 当前规模下单文件很少超过几百行,这是防御性改造,不是救火。 */
|
||
const BATCH_SIZE = 400;
|
||
const intOrNull = value => (value === null || value === undefined ? null : Math.round(value));
|
||
const matched = {};
|
||
const report = [];
|
||
let parsedFiles = 0;
|
||
|
||
const addFile = file => {
|
||
/* 原件先落盘(保留 7 天)。保存失败不抛 —— 原件只是辅助材料,
|
||
不能因为磁盘满/没权限就把整次导入判失败。 */
|
||
contractFiles.saveOriginal(jobNo, file);
|
||
const result = contractParser.parseUpload(file, rules);
|
||
report.push(`${file.originalname}:表头第 ${result.header_row ?? '—'} 行,认出 ${Object.keys(result.column_map).length} 个字段,${result.rows.length} 行`);
|
||
for (const [field, hit] of Object.entries(result.column_map)) matched[field] = hit;
|
||
|
||
if (!result.rows.length) {
|
||
insertItem.run(jobResult.lastInsertRowid, file.originalname, null, null, null, null, null,
|
||
null, null, null, null, result.notes || '没有认出表头,需人工复核', now, now);
|
||
return;
|
||
}
|
||
parsedFiles += 1;
|
||
for (let i = 0; i < result.rows.length; i += BATCH_SIZE) {
|
||
const batch = result.rows.slice(i, i + BATCH_SIZE);
|
||
db.transaction(() => {
|
||
for (const row of batch) {
|
||
insertItem.run(jobResult.lastInsertRowid, file.originalname, row.row_no, row.raw_text,
|
||
row.sku, row.product_name, row.spec, row.purchase_price, row.currency,
|
||
intOrNull(row.quantity), intOrNull(row.moq), '规则解析结果,提交前请复核', now, now);
|
||
}
|
||
})();
|
||
}
|
||
};
|
||
// 解析中途抛错必须把任务落成失败态。否则 job 永远停在 'parsing',
|
||
// 工作台的「待复核合同」会一直把它算成待处理项,用户点进去还看不出哪儿错了。
|
||
try {
|
||
files.forEach(addFile);
|
||
/* column_map 把「认了哪几列、多少分」存下来 —— 这是规则引擎唯一能自证的现场,
|
||
认错了列时用户能看到是哪一列抢了位置,再照着加排除词。 */
|
||
const noteText = Object.keys(matched).length
|
||
? contractParser.describeMatch(matched)
|
||
: '没有认出任何字段,建议人工补录或用 AI 解析';
|
||
db.prepare(`UPDATE contract_import_jobs SET status = 'pending_review', column_map = ?, notes = ?, updated_at = ? WHERE id = ?`)
|
||
.run(JSON.stringify(matched), `${noteText}|${report.join(';')}`.slice(0, 1200), now, jobResult.lastInsertRowid);
|
||
} catch (parseError) {
|
||
db.prepare(`UPDATE contract_import_jobs SET status = 'parse_failed', notes = ?, updated_at = ? WHERE id = ?`)
|
||
.run(`解析中断:${parseError.message}`.slice(0, 500), now, jobResult.lastInsertRowid);
|
||
throw parseError;
|
||
}
|
||
|
||
const rowCount = db.prepare('SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ?')
|
||
.get(jobResult.lastInsertRowid).count;
|
||
auth.audit({
|
||
username: req.user?.username,
|
||
action: 'contract_import',
|
||
detail: `导入合同 ${files.length} 个文件(任务 ${jobNo}),解析出 ${rowCount} 行,认列:${Object.keys(matched).length} 个字段`,
|
||
req
|
||
});
|
||
res.status(201).json({
|
||
job: db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(jobResult.lastInsertRowid),
|
||
message: parsedFiles
|
||
? '已按字符规则解析,进入待复核队列'
|
||
: '文件已收下,但规则没认出头:可点「AI 解析」或直接人工补录'
|
||
});
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.get('/api/contracts/import-jobs', (req, res) => {
|
||
try { res.json(db.prepare('SELECT * FROM contract_import_jobs ORDER BY created_at DESC LIMIT 100').all()); } catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.get('/api/contracts/import-jobs/:id', (req, res) => {
|
||
try {
|
||
const job = db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(Number(req.params.id));
|
||
if (!job) return res.status(404).json({ error: '导入任务不存在' });
|
||
res.json({ job, items: db.prepare('SELECT * FROM contract_import_items WHERE job_id = ? ORDER BY filename, row_no, id').all(job.id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.patch('/api/contracts/import-items/:id/review', auth.requirePermission('cleanup.view'), (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
const item = db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id);
|
||
if (!item) return res.status(404).json({ error: '导入明细不存在' });
|
||
const status = ['approved', 'rejected', 'pending_review'].includes(req.body?.status) ? req.body.status : null;
|
||
if (!status) return res.status(400).json({ error: 'status 必须是 approved、rejected 或 pending_review' });
|
||
const now = nowIso();
|
||
db.prepare(`UPDATE contract_import_items SET status = ?, review_note = ?, sku = ?, product_name = ?, purchase_price = ?, currency = ?, quantity = ?, reviewed_at = ?, updated_at = ? WHERE id = ?`).run(status, req.body.review_note || null, req.body.sku ?? item.sku, req.body.product_name ?? item.product_name, req.body.purchase_price === undefined ? item.purchase_price : numberOrNull(req.body.purchase_price), req.body.currency ?? item.currency, req.body.quantity === undefined ? item.quantity : numberOrNull(req.body.quantity), now, now, id);
|
||
const remaining = db.prepare(`SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ? AND status = 'pending_review'`).get(item.job_id).count;
|
||
db.prepare('UPDATE contract_import_jobs SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?').run(remaining ? 'pending_review' : 'reviewed', now, remaining ? null : now, item.job_id);
|
||
res.json({ item: db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
/* ============================== 客户报价单 ============================== */
|
||
|
||
/** 报价单合计一律实时从明细汇总 —— 主表不冗余总价,避免两处口径打架 */
|
||
const QUOTE_TOTAL_SQL = `COALESCE((SELECT SUM(qi.total_price) FROM quotation_items qi WHERE qi.quotation_id = q.id), 0)`;
|
||
const QUOTE_STATUSES = ['draft', 'sent', 'accepted', 'rejected', 'expired'];
|
||
|
||
function roundTo(value, digits) {
|
||
const factor = Math.pow(10, digits);
|
||
return Math.round(Number(value) * factor) / factor;
|
||
}
|
||
|
||
/** 生成报价单号:QT-YYYYMMDD-NNN,同一天内递增 */
|
||
function nextQuoteNo() {
|
||
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||
const prefix = `QT-${stamp}-`;
|
||
const last = db.prepare('SELECT quote_no FROM customer_quotations WHERE quote_no LIKE ? ORDER BY quote_no DESC LIMIT 1').get(`${prefix}%`);
|
||
const tail = last ? String(last.quote_no).slice(prefix.length) : '';
|
||
const seq = /^\d+$/.test(tail) ? Number(tail) + 1 : 1;
|
||
return `${prefix}${String(seq).padStart(3, '0')}`;
|
||
}
|
||
|
||
/**
|
||
* 明细行单价:采购价 ÷ 汇率 × (1 + 加价率)。
|
||
* 与询盘报价是同一套口径(inquiry_items 的 exw 就是这么算的),不要在这里另立一套。
|
||
*/
|
||
function priceFromCost(purchasePrice, exchangeRate, markupRate) {
|
||
const cost = Number(purchasePrice);
|
||
const rate = Number(exchangeRate);
|
||
if (!Number.isFinite(cost) || cost <= 0) return null;
|
||
if (!Number.isFinite(rate) || rate <= 0) return null;
|
||
const markup = Number(markupRate);
|
||
return roundTo(cost / rate * (1 + (Number.isFinite(markup) ? markup : 0)), 4);
|
||
}
|
||
|
||
/** 百分比(0-100);空值返回 null,便于区分「没填」和「填了 0」 */
|
||
function percentOrNull(value, label) {
|
||
if (value === undefined || value === null || value === '') return null;
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num) || num < 0 || num > 100) throw badRequest(`${label}必须在 0 到 100 之间`);
|
||
return roundTo(num, 4);
|
||
}
|
||
|
||
/**
|
||
* 结构化付款条件 → 一句可读文本,写进 payment_terms 列。
|
||
* 报价单列表 / 导出直接展示这一列,所以文本要能独立读懂。
|
||
* T/T + 30% → "T/T 30% 预付款,70% 尾款";100% → "T/T 100% 预付款(全额预付)"。
|
||
*/
|
||
function composePaymentTerms(method, deposit, balance) {
|
||
if (!method) return null;
|
||
if (deposit === null) return method;
|
||
if (deposit >= 100) return `${method} 100% 预付款(全额预付)`;
|
||
return `${method} ${deposit}% 预付款,${balance === null ? 100 - deposit : balance}% 尾款`;
|
||
}
|
||
|
||
/** 交货方式 + 地点 → "FOB Shanghai" */
|
||
function composeDeliveryTerms(method, port) {
|
||
const text = [method, port].filter(Boolean).join(' ').trim();
|
||
return text || null;
|
||
}
|
||
|
||
app.get('/api/quotes', (req, res) => {
|
||
try {
|
||
res.json(db.prepare(`
|
||
SELECT q.*, c.company AS customer_company, c.country AS customer_country, c.email AS customer_email,
|
||
${QUOTE_TOTAL_SQL} AS total_amount,
|
||
(SELECT COUNT(*) FROM quotation_items qi WHERE qi.quotation_id = q.id) AS item_count
|
||
FROM customer_quotations q
|
||
LEFT JOIN customers c ON c.id = q.customer_id
|
||
ORDER BY q.created_at DESC LIMIT 200
|
||
`).all());
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.get('/api/quotes/:id', (req, res) => {
|
||
try {
|
||
const quote = db.prepare(`SELECT q.*, c.company AS customer_company FROM customer_quotations q LEFT JOIN customers c ON c.id = q.customer_id WHERE q.id = ?`).get(Number(req.params.id));
|
||
if (!quote) return res.status(404).json({ error: '报价单不存在' });
|
||
res.json({
|
||
quote,
|
||
customer: quote.customer_id ? db.prepare('SELECT * FROM customers WHERE id = ?').get(quote.customer_id) : null,
|
||
contacts: quote.customer_id ? db.prepare('SELECT * FROM contacts WHERE customer_id = ? ORDER BY is_primary DESC, id').all(quote.customer_id) : [],
|
||
items: db.prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY id').all(quote.id)
|
||
});
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.post('/api/quotes', auth.requirePermission('quote.edit'), (req, res) => {
|
||
try {
|
||
const customer = getCustomer(req.body?.customer_id);
|
||
if (!customer) return res.status(400).json({ error: '请选择客户(customer_id 无效)' });
|
||
|
||
const rawItems = Array.isArray(req.body?.items) ? req.body.items : [];
|
||
if (!rawItems.length) return res.status(400).json({ error: '报价明细不能为空' });
|
||
|
||
const currency = String(req.body?.currency || 'USD').trim().toUpperCase();
|
||
if (!/^[A-Z]{3}$/.test(currency)) return res.status(400).json({ error: '币种必须是 3 位字母代码' });
|
||
|
||
const quoteDate = String(req.body?.quote_date || new Date().toISOString().slice(0, 10)).slice(0, 10);
|
||
if (!isDate(quoteDate)) return res.status(400).json({ error: 'quote_date 格式应为 YYYY-MM-DD' });
|
||
let validUntil = req.body?.valid_until ? String(req.body.valid_until).slice(0, 10) : null;
|
||
if (validUntil && !isDate(validUntil)) return res.status(400).json({ error: 'valid_until 格式应为 YYYY-MM-DD' });
|
||
|
||
/* 有效期天数与截止日期互为兜底:只给天数就自己算日期;两个都给则以日期为准重算天数,避免两处打架 */
|
||
let validityDays = null;
|
||
if (req.body?.validity_days !== undefined && req.body?.validity_days !== null && req.body?.validity_days !== '') {
|
||
const days = Number(req.body.validity_days);
|
||
if (!Number.isInteger(days) || days <= 0) throw badRequest('有效期天数必须是大于 0 的整数');
|
||
validityDays = days;
|
||
}
|
||
if (!validUntil && validityDays) {
|
||
validUntil = new Date(Date.parse(quoteDate) + validityDays * 86400000).toISOString().slice(0, 10);
|
||
} else if (validUntil) {
|
||
validityDays = Math.max(0, Math.round((Date.parse(validUntil) - Date.parse(quoteDate)) / 86400000));
|
||
}
|
||
if (validUntil && validUntil < quoteDate) throw badRequest('有效期不能早于报价日期');
|
||
|
||
/* 付款条件:预付 + 尾款必须凑满 100,否则存进去的是一笔算不平的账 */
|
||
const paymentMethod = req.body?.payment_method ? String(req.body.payment_method).trim() : null;
|
||
const depositPercent = percentOrNull(req.body?.deposit_percent, '预付款比例');
|
||
const balancePercent = percentOrNull(req.body?.balance_percent, '尾款比例');
|
||
if (depositPercent !== null && balancePercent !== null && Math.abs(depositPercent + balancePercent - 100) > 0.001) {
|
||
throw badRequest('预付款与尾款比例之和必须是 100%');
|
||
}
|
||
const paymentTermsText = paymentMethod
|
||
? composePaymentTerms(paymentMethod, depositPercent, balancePercent)
|
||
: (req.body?.payment_terms ? String(req.body.payment_terms).trim() : null);
|
||
|
||
const deliveryMethod = req.body?.delivery_method ? String(req.body.delivery_method).trim() : null;
|
||
const deliveryPort = req.body?.delivery_port ? String(req.body.delivery_port).trim() : null;
|
||
const deliveryTermsText = (deliveryMethod || deliveryPort)
|
||
? composeDeliveryTerms(deliveryMethod, deliveryPort)
|
||
: (req.body?.delivery_terms ? String(req.body.delivery_terms).trim() : null);
|
||
|
||
const status = QUOTE_STATUSES.includes(req.body?.status) ? req.body.status : 'draft';
|
||
|
||
/* 每条明细先校验干净再进事务 —— 不让半截数据落库 */
|
||
const items = rawItems.map((raw, index) => {
|
||
const label = `第 ${index + 1} 行`;
|
||
const sku = String(raw?.sku || '').trim();
|
||
if (!sku) throw badRequest(`${label}缺少货号`);
|
||
const qty = Number(raw?.qty);
|
||
if (!Number.isInteger(qty) || qty <= 0) throw badRequest(`${label}的数量必须是大于 0 的整数`);
|
||
|
||
const pick = key => {
|
||
const value = raw?.[key];
|
||
if (value === undefined || value === null || value === '') return null;
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? num : null;
|
||
};
|
||
const purchasePrice = pick('purchase_price');
|
||
const exchangeRate = pick('exchange_rate');
|
||
const markupRate = pick('markup_rate');
|
||
|
||
const provided = pick('unit_price');
|
||
// 前端为了即时预览会自己算一次并传上来;没传就按同一口径在后端补算,兜住
|
||
const unitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate);
|
||
if (unitPrice === null || unitPrice < 0) {
|
||
throw badRequest(`${label}的单价无法确定:请填单价,或同时填齐采购价 / 汇率 / 加价率`);
|
||
}
|
||
|
||
return {
|
||
sku,
|
||
productName: String(raw?.product_name || sku).trim(),
|
||
productNameEn: raw?.product_name_en ? String(raw.product_name_en).trim() : null,
|
||
spec: raw?.spec ? String(raw.spec).trim() : null,
|
||
qty,
|
||
unitPrice: roundTo(unitPrice, 4),
|
||
totalPrice: roundTo(unitPrice * qty, 2),
|
||
currency,
|
||
purchasePrice, exchangeRate, markupRate,
|
||
notes: raw?.notes ? String(raw.notes).trim() : null
|
||
};
|
||
});
|
||
|
||
const now = nowIso();
|
||
const create = db.transaction(() => {
|
||
const quoteNo = nextQuoteNo();
|
||
const result = db.prepare(`INSERT INTO customer_quotations (quote_no, customer_id, quote_date, valid_until, validity_days, currency, pi_no, payment_method, deposit_percent, balance_percent, payment_terms, delivery_method, delivery_port, delivery_terms, status, notes, internal_notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||
.run(quoteNo, customer.id, quoteDate, validUntil, validityDays, currency,
|
||
req.body?.pi_no ? String(req.body.pi_no).trim() : null,
|
||
paymentMethod, depositPercent, balancePercent, paymentTermsText,
|
||
deliveryMethod, deliveryPort, deliveryTermsText,
|
||
status,
|
||
req.body?.notes ? String(req.body.notes).trim() : null,
|
||
req.body?.internal_notes ? String(req.body.internal_notes).trim() : null,
|
||
now, now);
|
||
const quotationId = result.lastInsertRowid;
|
||
const insertItem = db.prepare(`INSERT INTO quotation_items (quotation_id, sku, product_name, product_name_en, spec, qty, unit_price, total_price, currency, purchase_price, exchange_rate, markup_rate, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||
for (const item of items) {
|
||
insertItem.run(quotationId, item.sku, item.productName, item.productNameEn, item.spec, item.qty, item.unitPrice, item.totalPrice, item.currency, item.purchasePrice, item.exchangeRate, item.markupRate, item.notes, now);
|
||
}
|
||
return quotationId;
|
||
});
|
||
|
||
const quotationId = create();
|
||
res.status(201).json({
|
||
id: quotationId,
|
||
quote: db.prepare('SELECT * FROM customer_quotations WHERE id = ?').get(quotationId),
|
||
items: db.prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY id').all(quotationId)
|
||
});
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.patch('/api/quotes/:id', auth.requirePermission('quote.edit'), (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!db.prepare('SELECT id FROM customer_quotations WHERE id = ?').get(id)) return res.status(404).json({ error: '报价单不存在' });
|
||
const fields = [];
|
||
const values = [];
|
||
if (req.body?.status !== undefined) {
|
||
if (!QUOTE_STATUSES.includes(req.body.status)) return res.status(400).json({ error: `status 必须是 ${QUOTE_STATUSES.join(' / ')}` });
|
||
fields.push('status = ?'); values.push(req.body.status);
|
||
}
|
||
/* 有效期:天数与日期互为兜底,和 POST 保持同一套口径 */
|
||
if (req.body?.valid_until !== undefined || req.body?.validity_days !== undefined) {
|
||
const current = db.prepare('SELECT quote_date FROM customer_quotations WHERE id = ?').get(id);
|
||
let validUntil = req.body?.valid_until ? String(req.body.valid_until).slice(0, 10) : null;
|
||
if (validUntil && !isDate(validUntil)) throw badRequest('valid_until 格式应为 YYYY-MM-DD');
|
||
let days = null;
|
||
if (req.body?.validity_days !== undefined && req.body?.validity_days !== null && req.body?.validity_days !== '') {
|
||
const n = Number(req.body.validity_days);
|
||
if (!Number.isInteger(n) || n <= 0) throw badRequest('有效期天数必须是大于 0 的整数');
|
||
days = n;
|
||
}
|
||
const base = current?.quote_date || new Date().toISOString().slice(0, 10);
|
||
if (!validUntil && days) validUntil = new Date(Date.parse(base) + days * 86400000).toISOString().slice(0, 10);
|
||
else if (validUntil) days = Math.max(0, Math.round((Date.parse(validUntil) - Date.parse(base)) / 86400000));
|
||
fields.push('valid_until = ?'); values.push(validUntil);
|
||
fields.push('validity_days = ?'); values.push(days);
|
||
}
|
||
/* 付款条件整组重拼:只传了部分字段(例如只改比例)时用库里的现值补齐,不能顺手清空 */
|
||
if (req.body?.payment_method !== undefined || req.body?.deposit_percent !== undefined || req.body?.balance_percent !== undefined) {
|
||
const existing = db.prepare('SELECT payment_method, deposit_percent, balance_percent FROM customer_quotations WHERE id = ?').get(id);
|
||
const method = req.body?.payment_method !== undefined
|
||
? (req.body.payment_method ? String(req.body.payment_method).trim() : null)
|
||
: (existing?.payment_method || null);
|
||
const deposit = req.body?.deposit_percent !== undefined
|
||
? percentOrNull(req.body.deposit_percent, '预付款比例')
|
||
: (existing?.deposit_percent ?? null);
|
||
const balance = req.body?.balance_percent !== undefined
|
||
? percentOrNull(req.body.balance_percent, '尾款比例')
|
||
: (existing?.balance_percent ?? null);
|
||
if (deposit !== null && balance !== null && Math.abs(deposit + balance - 100) > 0.001) throw badRequest('预付款与尾款比例之和必须是 100%');
|
||
fields.push('payment_method = ?'); values.push(method);
|
||
fields.push('deposit_percent = ?'); values.push(deposit);
|
||
fields.push('balance_percent = ?'); values.push(balance);
|
||
fields.push('payment_terms = ?'); values.push(composePaymentTerms(method, deposit, balance));
|
||
}
|
||
/* 交货条件同上:方式或地点任一变了就重拼 delivery_terms */
|
||
if (req.body?.delivery_method !== undefined || req.body?.delivery_port !== undefined) {
|
||
const existing = db.prepare('SELECT delivery_method, delivery_port FROM customer_quotations WHERE id = ?').get(id);
|
||
const method = req.body?.delivery_method !== undefined
|
||
? (req.body.delivery_method ? String(req.body.delivery_method).trim() : null)
|
||
: (existing?.delivery_method || null);
|
||
const port = req.body?.delivery_port !== undefined
|
||
? (req.body.delivery_port ? String(req.body.delivery_port).trim() : null)
|
||
: (existing?.delivery_port || null);
|
||
fields.push('delivery_method = ?'); values.push(method);
|
||
fields.push('delivery_port = ?'); values.push(port);
|
||
fields.push('delivery_terms = ?'); values.push(composeDeliveryTerms(method, port));
|
||
}
|
||
for (const key of ['pi_no', 'notes', 'internal_notes']) {
|
||
if (req.body?.[key] !== undefined) {
|
||
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
|
||
fields.push(`${key} = ?`); values.push(value || null);
|
||
}
|
||
}
|
||
if (!fields.length) return res.status(400).json({ error: '没有需要更新的字段' });
|
||
fields.push('updated_at = ?'); values.push(nowIso(), id);
|
||
db.prepare(`UPDATE customer_quotations SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||
res.json({ quote: db.prepare('SELECT * FROM customer_quotations WHERE id = ?').get(id) });
|
||
} catch (error) { jsonError(res, error); }
|
||
});
|
||
|
||
app.use((error, req, res, next) => {
|
||
if (res.headersSent) return next(error);
|
||
console.error('API 请求失败:', error);
|
||
jsonError(res, error);
|
||
});
|
||
|
||
// SPA fallback - 所有未匹配的路由返回 index.html
|
||
app.get('*', (req, res) => {
|
||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||
});
|
||
|
||
// 启动服务器
|
||
app.listen(PORT, HOST, () => {
|
||
console.log('========================================');
|
||
console.log(' 外贸供应链管理系统');
|
||
console.log('========================================');
|
||
console.log(` 服务器地址: http://${HOST}:${PORT}`);
|
||
console.log(` 启动时间: ${new Date().toLocaleString('zh-CN')}`);
|
||
console.log('========================================');
|
||
/* 合同原件只留 7 天:启动清一次,之后每 6 小时一次(见 lib/contractFiles.js)。
|
||
放这里而不是定时任务里 —— 自建 VPS 上不一定有 cron,服务起来就该自己管好磁盘。 */
|
||
contractFiles.startPurgeSchedule(db);
|
||
});
|
||
|