feat(order): 订单支出台账(快递费/吊卡费等杂费)

订单上原本只有「卖给客户多少钱」,而一张单真实花出去的还有寄样品的快递费、
印吊卡的费用、商检报关拖车。不记下来就算不出这单赚没赚 —— 小单子几百块
杂费能把毛利吃掉一截。

· 新表 order_expenses,一张订单挂多笔:类型/金额/币种/日期/收款方/单号/
  已付未付/备注。单独成表而不是往 orders 加列,因为杂费是多笔不定项的,
  寄三次样品就是三条快递费,各有单号;塞进 orders 只能存一个合计,事后
  对不出「这 800 块是哪来的」。

· 跨币种强制填汇率并存快照。杂费基本是人民币、订单常是美元;不存快照的话
  汇率一变历史订单毛利跟着变,账永远对不上。同币种强制按 1,防止前端传个
  7.2 把美元支出又乘一遍。

· 新权限点 order.expense,不复用 order.edit。掏钱寄样品的是销售业务员,
  而销售业务组只有 order.view —— 挂在 order.edit 上要么他记不了账,要么
  为了让他记账得把「改订单金额」一起给他。启动时 syncNewPermissions()
  会自动把这个新点补给四个默认组。

· 订单列表加「杂费」列(合计 + 未付)。SUM 外层再 ROUND 一次:不加会漏出
  348.15999999999997 这种浮点尾巴直接显示在页面上。

· 金额逐行取两位再相加,保证明细几行加起来等于合计。

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Drsakura
2026-10-22 15:22:56 +08:00
co-authored by Claude Opus 5
parent 3e9fd2dc97
commit f32a2daaef
6 changed files with 643 additions and 5 deletions
+285 -2
View File
@@ -486,7 +486,7 @@ app.post('/api/customers/:id/contacts', auth.requirePermission('customer.edit'),
if (!customer) return res.status(404).json({ error: '客户不存在' });
/* customer.edit 只说明「这个人有权编辑客户」,没说明「有权编辑**这一条**客户」。
少了这道归属校验,任何销售业务员猜个 id 就能往别人名下、甚至别组织的客户里
插联系人(GET 同一条客户是 404,写却成功),还能把 is_primary 设成 1
插联系人(GET 同一条客户是 404,写却成功)—— 还能把 is_primary 设成 1
把自己变成主联系人。隔壁的 PATCH 一直有这道检查,这里是漏了。 */
if (!customerAccess.canEdit(req.user, customer)) {
return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' });
@@ -749,8 +749,18 @@ app.put('/api/products/:sku/packaging', auth.requirePermission('product.edit'),
// 订单路由
app.get('/api/orders', (req, res) => {
try {
/* 带上杂费合计:订单列表上只看 total_amount 看不出赚没赚 ——
快递费吊卡费加起来几百块,小单子能把毛利吃掉一截。
逐行取两位再 SUM,跟支出明细页的合计口径一致。 */
const orders = db.prepare(`
SELECT o.*, c.company as customer_name
SELECT o.*, c.company as customer_name,
/* 外层再 ROUND 一次:SUM 累加浮点会漏出 348.15999999999997 这种尾巴,
直接显示在订单列表上很难看,也让人怀疑数算错了 */
ROUND(COALESCE((SELECT SUM(ROUND(e.amount * e.rate_to_order, 2))
FROM order_expenses e WHERE e.order_id = o.id), 0), 2) AS expense_total,
ROUND(COALESCE((SELECT SUM(ROUND(e.amount * e.rate_to_order, 2))
FROM order_expenses e WHERE e.order_id = o.id AND e.is_paid = 0), 0), 2) AS expense_unpaid,
(SELECT COUNT(*) FROM order_expenses e WHERE e.order_id = o.id) AS expense_count
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
ORDER BY o.order_date DESC
@@ -761,6 +771,279 @@ app.get('/api/orders', (req, res) => {
}
});
// ---------------------------------------------------------------------------
// 产品速查
//
// 跟 ⌘K 全局搜索的区别:全局搜索是「找到后跳过去」,速查是「报价时就地看数」——
// 不离开当前页面,一次把采购价、MOQ、规格、箱规、阶梯价全带回来,能直接抄进报价。
// 所以它不返回 path,返回的是数。
// ---------------------------------------------------------------------------
app.get('/api/sku/quick', auth.requirePermission('product.view'), (req, res) => {
try {
const term = String(req.query.q || '').trim().slice(0, 60);
if (!term) return res.json({ results: [] });
const like = `%${term}%`;
/* 货号精确/前缀命中排在最前 —— 报价时多半是直接敲货号,
让它跟在一堆品名模糊匹配后面就等于没做速查。 */
const rows = db.prepare(`
SELECT p.sku, p.display_sku, p.name, p.name_en, p.spec, p.price, p.currency, p.moq,
p.description, p.confidence, p.last_updated, p.group_id,
s.name AS supplier_name, s.id AS supplier_id,
CASE
WHEN UPPER(COALESCE(p.display_sku, p.sku)) = UPPER(@raw) THEN 0
WHEN UPPER(COALESCE(p.display_sku, p.sku)) LIKE UPPER(@prefix) THEN 1
ELSE 2
END AS rank
FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id
WHERE p.sku LIKE @like OR p.display_sku LIKE @like
OR p.name LIKE @like OR p.name_en LIKE @like
OR p.spec LIKE @like OR p.description LIKE @like
ORDER BY rank, p.price IS NULL, p.price ASC
LIMIT 20
`).all({ like, raw: term, prefix: `${term}%` });
if (!rows.length) return res.json({ results: [] });
/* 箱规和阶梯价一次批量取回,别在循环里逐条查 */
const skus = rows.map(r => r.sku);
const ph = skus.map(() => '?').join(',');
const packing = new Map();
for (const row of db.prepare(`SELECT * FROM product_packaging WHERE sku IN (${ph})`).all(...skus)) {
packing.set(row.sku, row);
}
const tiers = new Map();
for (const row of db.prepare(
`SELECT sku, moq_from, moq_to, price, currency FROM tier_prices WHERE sku IN (${ph}) ORDER BY moq_from`
).all(...skus)) {
if (!tiers.has(row.sku)) tiers.set(row.sku, []);
tiers.get(row.sku).push(row);
}
res.json({
results: rows.map(row => {
const pack = packing.get(row.sku) || null;
return {
sku: row.sku,
displaySku: row.display_sku || row.sku,
name: row.name,
nameEn: row.name_en,
spec: row.spec,
price: row.price,
currency: row.currency || 'CNY',
moq: row.moq,
supplier: row.supplier_name,
supplierId: row.supplier_id,
confidence: row.confidence,
lastUpdated: row.last_updated,
groupId: row.group_id,
description: row.description,
/* 箱规拼成一句「40×30×25cm / 12.5kg / 装 24」—— 报价时要的是这一句,
不是四个分开的数字 */
packing: pack ? {
cartonQty: pack.carton_qty,
innerQty: pack.inner_pack_qty,
cartonSize: [pack.carton_length, pack.carton_width, pack.carton_height].every(v => v)
? `${pack.carton_length}×${pack.carton_width}×${pack.carton_height}cm` : null,
cartonWeight: pack.carton_weight,
material: pack.packaging_material
} : null,
tiers: tiers.get(row.sku) || []
};
})
});
} catch (error) { jsonError(res, error); }
});
// ---------------------------------------------------------------------------
// 订单支出(业务员自己记的杂费)
//
// 为什么不复用 order.edit:掏钱寄样品的是销售业务员,而业务组只有 order.view。
// 挂在 order.edit 上要么他记不了账,要么得把「改订单金额」一起给他 —— 所以
// 单独一个 order.expense 权限点。
// ---------------------------------------------------------------------------
/** 支出类型。前端下拉用这一份,避免两边各写一套对不上。 */
const EXPENSE_CATEGORIES = [
'快递费', '吊卡费', '唛头印刷', '打样费', '商检费', '报关费',
'拖车费', '订舱费', '保险费', '银行手续费', '样品采购', '其他'
];
/** 行金额换算到订单币种。逐行取两位再相加 —— 否则页面上几行加起来跟合计对不上。 */
const expenseInOrderCurrency = row => roundTo(Number(row.amount || 0) * Number(row.rate_to_order || 1), 2);
function getOrder(id) {
return db.prepare('SELECT * FROM orders WHERE id = ?').get(Number(id));
}
/**
* 能不能给这张订单记账。
* orders.owner_id 目前还没人往里写,为空时不设门槛(跟客户那边「存量没 org_id
* 不设门槛」同一个口径);一旦开始记归属,这里自动就生效了。
*/
function canTouchOrderExpense(user, order) {
if (!user) return false;
if (auth.permissionsOfRole(user.role).includes('order.edit')) return true;
if (order.owner_id === null || order.owner_id === undefined) return true;
return Number(order.owner_id) === Number(user.id);
}
function expenseSummary(orderId, orderCurrency) {
const rows = db.prepare('SELECT * FROM order_expenses WHERE order_id = ?').all(orderId);
let total = 0;
let unpaid = 0;
const byCategory = {};
for (const row of rows) {
const value = expenseInOrderCurrency(row);
total = roundTo(total + value, 2);
if (!row.is_paid) unpaid = roundTo(unpaid + value, 2);
byCategory[row.category] = roundTo((byCategory[row.category] || 0) + value, 2);
}
return { count: rows.length, total, unpaid, currency: orderCurrency || 'USD', byCategory };
}
app.get('/api/orders/:id/expenses', auth.requirePermission('order.view'), (req, res) => {
try {
const order = getOrder(req.params.id);
if (!order) return res.status(404).json({ error: '订单不存在' });
const expenses = db.prepare(`
SELECT e.*, s.name AS supplier_name, u.display_name AS created_by_name
FROM order_expenses e
LEFT JOIN suppliers s ON s.id = e.supplier_id
LEFT JOIN users u ON u.id = e.created_by
WHERE e.order_id = ?
ORDER BY COALESCE(e.spent_at, e.created_at) DESC, e.id DESC
`).all(order.id).map(row => ({ ...row, amount_in_order_currency: expenseInOrderCurrency(row) }));
res.json({
order: { id: order.id, order_no: order.order_no, currency: order.currency, total_amount: order.total_amount },
categories: EXPENSE_CATEGORIES,
expenses,
summary: expenseSummary(order.id, order.currency)
});
} catch (error) { jsonError(res, error); }
});
/** 校验并整理一笔支出的入参。缺字段就抛 badRequest,不让半截数据落库。 */
function normalizeExpense(body, order) {
const category = String(body?.category || '').trim();
if (!category) throw badRequest('请选择支出类型');
const amount = Number(body?.amount);
if (!Number.isFinite(amount) || amount <= 0) throw badRequest('金额必须是大于 0 的数字');
const currency = String(body?.currency || 'CNY').trim().toUpperCase().slice(0, 8);
const orderCurrency = String(order.currency || 'USD').toUpperCase();
/* 同币种强制 1,别让前端传个 7.2 进来把 CNY 支出又乘一遍。
跨币种必须给汇率 —— 猜一个会让毛利悄悄算错,不如直接要。 */
let rate = 1;
if (currency !== orderCurrency) {
rate = Number(body?.rate_to_order);
if (!Number.isFinite(rate) || rate <= 0) {
throw badRequest(`支出是 ${currency}、订单是 ${orderCurrency},请填 1 ${currency} 折算成多少 ${orderCurrency}`);
}
}
const spentAt = body?.spent_at ? String(body.spent_at).slice(0, 10) : null;
if (spentAt && !isDate(spentAt)) throw badRequest('发生日期格式应为 YYYY-MM-DD');
const supplierId = numberOrNull(body?.supplier_id);
if (supplierId && !db.prepare('SELECT id FROM suppliers WHERE id = ?').get(supplierId)) {
throw badRequest('供应商不存在');
}
return {
category,
amount: roundTo(amount, 2),
currency,
rate_to_order: rate,
spent_at: spentAt,
payee: body?.payee ? String(body.payee).trim().slice(0, 100) : null,
supplier_id: supplierId,
voucher_no: body?.voucher_no ? String(body.voucher_no).trim().slice(0, 80) : null,
is_paid: body?.is_paid ? 1 : 0,
note: body?.note ? String(body.note).trim().slice(0, 500) : null
};
}
app.post('/api/orders/:id/expenses', auth.requirePermission('order.expense'), (req, res) => {
try {
const order = getOrder(req.params.id);
if (!order) return res.status(404).json({ error: '订单不存在' });
if (!canTouchOrderExpense(req.user, order)) {
return res.status(403).json({ error: '这张订单不在你名下' });
}
const data = normalizeExpense(req.body, order);
const now = nowIso();
const result = db.prepare(`
INSERT INTO order_expenses
(order_id, category, amount, currency, rate_to_order, spent_at, payee,
supplier_id, voucher_no, is_paid, note, created_by, created_at, updated_at)
VALUES (@order_id, @category, @amount, @currency, @rate_to_order, @spent_at, @payee,
@supplier_id, @voucher_no, @is_paid, @note, @created_by, @created_at, @updated_at)
`).run({ ...data, order_id: order.id, created_by: req.user?.id || null, created_at: now, updated_at: now });
auth.audit({
username: req.user?.username,
action: 'order_expense_add',
detail: `订单 ${order.order_no} 记入 ${data.category} ${data.amount} ${data.currency}`,
req
});
res.status(201).json({
id: result.lastInsertRowid,
summary: expenseSummary(order.id, order.currency)
});
} catch (error) { jsonError(res, error); }
});
app.patch('/api/orders/:id/expenses/:expenseId', auth.requirePermission('order.expense'), (req, res) => {
try {
const order = getOrder(req.params.id);
if (!order) return res.status(404).json({ error: '订单不存在' });
if (!canTouchOrderExpense(req.user, order)) {
return res.status(403).json({ error: '这张订单不在你名下' });
}
const existing = db.prepare('SELECT * FROM order_expenses WHERE id = ? AND order_id = ?')
.get(Number(req.params.expenseId), order.id);
if (!existing) return res.status(404).json({ error: '这笔支出不存在' });
/* 整条重算而不是逐字段 patch:金额/币种/汇率三者相互牵连,
单独改币种却留着旧汇率就会算出一个谁都解释不了的数。 */
const data = normalizeExpense({ ...existing, ...req.body }, order);
db.prepare(`
UPDATE order_expenses
SET category = @category, amount = @amount, currency = @currency,
rate_to_order = @rate_to_order, spent_at = @spent_at, payee = @payee,
supplier_id = @supplier_id, voucher_no = @voucher_no, is_paid = @is_paid,
note = @note, updated_at = @updated_at
WHERE id = @id
`).run({ ...data, id: existing.id, updated_at: nowIso() });
res.json({ ok: true, summary: expenseSummary(order.id, order.currency) });
} catch (error) { jsonError(res, error); }
});
app.delete('/api/orders/:id/expenses/:expenseId', auth.requirePermission('order.expense'), (req, res) => {
try {
const order = getOrder(req.params.id);
if (!order) return res.status(404).json({ error: '订单不存在' });
if (!canTouchOrderExpense(req.user, order)) {
return res.status(403).json({ error: '这张订单不在你名下' });
}
const removed = db.prepare('DELETE FROM order_expenses WHERE id = ? AND order_id = ?')
.run(Number(req.params.expenseId), order.id);
if (!removed.changes) return res.status(404).json({ error: '这笔支出不存在' });
auth.audit({
username: req.user?.username,
action: 'order_expense_delete',
detail: `订单 ${order.order_no} 删除一笔支出 #${req.params.expenseId}`,
req
});
res.json({ ok: true, summary: expenseSummary(order.id, order.currency) });
} catch (error) { jsonError(res, error); }
});
// 商机路由
app.get('/api/deals', (req, res) => {
try {