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:
co-authored by
Claude Opus 5
parent
3e9fd2dc97
commit
f32a2daaef
+285
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user