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
+26
View File
@@ -161,6 +161,32 @@ function ensureColumns() {
sha256 TEXT, bytes BLOB NOT NULL, stored_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_cart_items_sort ON cart_items(sort);
/* 订单支出(业务员记的杂费:快递费、吊卡费、商检、报关、拖车、打样…)。
单独一张表而不是往 orders 加几列 —— 一笔订单的杂费是多笔不定项的,
寄三次样品就是三条快递费,各自有单号和日期;塞进 orders 只能存一个合计,
事后对不出「这 800 块是哪来的」。
rate_to_order 是「记账当时」的汇率快照:快递费是人民币、订单可能是美元,
不存快照的话汇率一变,历史订单的毛利会跟着变,账永远对不上。 */
CREATE TABLE IF NOT EXISTS order_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
category TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
rate_to_order REAL NOT NULL DEFAULT 1,
spent_at TEXT,
payee TEXT,
supplier_id INTEGER REFERENCES suppliers(id),
voucher_no TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
note TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_order_expenses_order ON order_expenses(order_id);
CREATE INDEX IF NOT EXISTS idx_order_expenses_date ON order_expenses(spent_at);
`);
const columnMigrations = {
+33
View File
@@ -630,3 +630,36 @@ CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);
-- ---------------------------------------------------------------------------
-- 订单支出(业务员自己记的杂费:快递费、吊卡费、商检、报关、拖车、打样…)
--
-- 为什么单独一张表而不是往 orders 加几列:一笔订单的杂费是**多笔、不定项**的,
-- 寄三次样品就是三条快递费,各自有单号和日期。塞进 orders 只能存一个合计,
-- 事后对不出「这 800 块是哪来的」。
--
-- 币种是关键:快递费、吊卡费都是人民币,订单却可能是美元。所以每笔支出带自己的
-- currency,外加一个 rate_to_order —— **下单当时的汇率快照**。不存快照的话,
-- 三个月后汇率变了,历史订单的毛利会跟着变,账就永远对不上。
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS order_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
category TEXT NOT NULL, -- 快递费 / 吊卡费 / 商检费 / 报关费 / 拖车费 / 打样费 / 唛头印刷 / 其他
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
-- 支出币种 → 订单币种的汇率快照;同币种为 1
rate_to_order REAL NOT NULL DEFAULT 1,
spent_at TEXT, -- 发生日期 YYYY-MM-DD
payee TEXT, -- 收款方:顺丰 / 某印刷厂
supplier_id INTEGER REFERENCES suppliers(id),
voucher_no TEXT, -- 快递单号 / 发票号,事后核对用
is_paid INTEGER NOT NULL DEFAULT 0,
note TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_order_expenses_order ON order_expenses(order_id);
CREATE INDEX IF NOT EXISTS idx_order_expenses_date ON order_expenses(spent_at);
+5 -1
View File
@@ -24,6 +24,10 @@ const PERMISSIONS = [
{ key: 'purchase.edit', label: '编辑采购单', group: '业务管理' },
{ key: 'order.view', label: '查看订单管理', group: '业务管理' },
{ key: 'order.edit', label: '编辑订单管理', group: '业务管理' },
/* 订单支出(快递费/吊卡费这类杂费)单独成点,不搭在 order.edit 上 ——
掏钱寄样品的是销售业务员,而业务组只有 order.view。若把记账挂在 order.edit 上,
要么业务员记不了账,要么为了让他记账得把「改订单金额」的权限一起给他。 */
{ key: 'order.expense', label: '记录订单支出(快递/吊卡等杂费)', group: '业务管理' },
/* 客户管理 */
{ key: 'customer.view', label: '查看我的客户', group: '客户管理' },
@@ -100,7 +104,7 @@ const DEFAULT_PERMISSION_GROUPS = [
'quote.view', 'quote.edit',
'contract.view',
'purchase.view',
'order.view',
'order.view', 'order.expense',
'customer.view', 'customer.edit',
'prospect.view', 'pool.view', 'pool.claim',
'product.view', 'supplier.view', 'cleanup.view'
+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 {
+269
View File
@@ -0,0 +1,269 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import Drawer from './Drawer'
import { useAuth, hasPermission } from '../auth'
/**
* 订单支出(业务员记的杂费:快递费、吊卡费、商检、报关…)。
*
* 为什么要有这个:订单上只有「卖给客户多少钱」,而一张单子真实花出去的还有寄样品的
* 快递费、印吊卡的费用、商检报关拖车。不记下来就算不出这单到底赚没赚 —— 小单子
* 几百块杂费能把毛利吃掉一截。
*
* 币种是这里最容易出错的地方:杂费基本都是人民币,订单常常是美元。所以每笔支出
* 带自己的币种,跨币种时**必须填汇率**(不猜,猜出来的毛利没人敢用),
* 后端会把汇率快照存下来,之后汇率再变历史账也不动。
*/
const num = value => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const fmt = (value, currency) => `${currency || ''} ${num(value).toFixed(2)}`.trim()
const today = () => new Date().toISOString().slice(0, 10)
const EMPTY = {
category: '快递费',
amount: '',
currency: 'CNY',
rate_to_order: '',
spent_at: today(),
payee: '',
voucher_no: '',
is_paid: false,
note: ''
}
function OrderExpenses({ order, onClose, onChanged }) {
const { user } = useAuth()
const canWrite = hasPermission(user, 'order.expense')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [form, setForm] = useState(EMPTY)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [editingId, setEditingId] = useState(null)
const orderCurrency = String(order?.currency || 'USD').toUpperCase()
const crossCurrency = String(form.currency || '').toUpperCase() !== orderCurrency
const load = useCallback(async () => {
setLoading(true)
try {
const { data: payload } = await axios.get(`/api/orders/${order.id}/expenses`)
setData(payload)
} catch (err) {
setError(err?.response?.data?.error || '读取支出失败')
} finally {
setLoading(false)
}
}, [order.id])
useEffect(() => { load() }, [load])
const summary = data?.summary
/* 毛利口径写清楚:这里减掉的只是杂费,没减货款采购成本 ——
标签上必须注明,否则会被当成真毛利去报价。 */
const afterExpense = useMemo(
() => (summary ? num(order.total_amount) - num(summary.total) : null),
[summary, order.total_amount]
)
const reset = () => { setForm({ ...EMPTY, currency: 'CNY' }); setEditingId(null); setError('') }
const submit = async event => {
event.preventDefault()
setSaving(true)
setError('')
try {
const body = {
...form,
amount: Number(form.amount),
rate_to_order: crossCurrency ? Number(form.rate_to_order) : 1,
supplier_id: null
}
if (editingId) await axios.patch(`/api/orders/${order.id}/expenses/${editingId}`, body)
else await axios.post(`/api/orders/${order.id}/expenses`, body)
reset()
await load()
onChanged?.()
} catch (err) {
setError(err?.response?.data?.error || '保存失败')
} finally {
setSaving(false)
}
}
const startEdit = row => {
setEditingId(row.id)
setError('')
setForm({
category: row.category,
amount: String(row.amount ?? ''),
currency: row.currency,
rate_to_order: String(row.rate_to_order ?? ''),
spent_at: row.spent_at || today(),
payee: row.payee || '',
voucher_no: row.voucher_no || '',
is_paid: !!row.is_paid,
note: row.note || ''
})
}
const remove = async row => {
if (!window.confirm(`删除这笔「${row.category} ${fmt(row.amount, row.currency)}」?`)) return
try {
await axios.delete(`/api/orders/${order.id}/expenses/${row.id}`)
if (editingId === row.id) reset()
await load()
onChanged?.()
} catch (err) {
setError(err?.response?.data?.error || '删除失败')
}
}
const togglePaid = async row => {
try {
await axios.patch(`/api/orders/${order.id}/expenses/${row.id}`, { is_paid: row.is_paid ? 0 : 1 })
await load()
onChanged?.()
} catch (err) {
setError(err?.response?.data?.error || '保存失败')
}
}
const set = (key, value) => setForm(prev => ({ ...prev, [key]: value }))
return (
<Drawer
wide
title={`订单支出 · ${order.order_no}`}
sub={`订单金额 ${fmt(order.total_amount, orderCurrency)}${order.customer_name ? ` · ${order.customer_name}` : ''}`}
onClose={onClose}
>
{loading ? <p className="oe-hint">读取中…</p> : (
<>
<div className="oe-summary">
<div className="oe-stat">
<span>杂费合计</span>
<strong>{fmt(summary?.total, orderCurrency)}</strong>
</div>
<div className="oe-stat">
<span>其中未付</span>
<strong className={num(summary?.unpaid) > 0 ? 'oe-unpaid' : ''}>{fmt(summary?.unpaid, orderCurrency)}</strong>
</div>
<div className="oe-stat">
<span>订单金额 − 杂费</span>
<strong>{fmt(afterExpense, orderCurrency)}</strong>
<em>未扣货款采购成本</em>
</div>
</div>
{!!summary?.count && (
<div className="oe-bycat">
{Object.entries(summary.byCategory).map(([name, value]) => (
<span key={name}>{name} <b>{num(value).toFixed(2)}</b></span>
))}
</div>
)}
{error && <p className="oe-error">{error}</p>}
{canWrite && (
<form className="oe-form" onSubmit={submit}>
<div className="oe-form-title">{editingId ? '修改这笔支出' : '记一笔支出'}</div>
<div className="oe-grid">
<label>类型
<select value={form.category} onChange={e => set('category', e.target.value)}>
{(data?.categories || []).map(name => <option key={name} value={name}>{name}</option>)}
</select>
</label>
<label>金额
<input type="number" step="0.01" min="0" required value={form.amount}
onChange={e => set('amount', e.target.value)} placeholder="286.50" />
</label>
<label>币种
<select value={form.currency} onChange={e => set('currency', e.target.value)}>
{['CNY', 'USD', 'EUR', 'GBP', 'HKD', 'JPY'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</label>
{/* 跨币种才出现:同币种硬填汇率只会填错 */}
{crossCurrency && (
<label className="oe-rate">1 {form.currency} = ? {orderCurrency}
<input type="number" step="0.0001" min="0" required value={form.rate_to_order}
onChange={e => set('rate_to_order', e.target.value)} placeholder="0.1395" />
</label>
)}
<label>发生日期
<input type="date" value={form.spent_at} onChange={e => set('spent_at', e.target.value)} />
</label>
<label>收款方
<input type="text" value={form.payee} onChange={e => set('payee', e.target.value)} placeholder="顺丰 / 某印刷厂" />
</label>
<label>单号/发票号
<input type="text" value={form.voucher_no} onChange={e => set('voucher_no', e.target.value)} placeholder="快递单号,事后好核对" />
</label>
<label className="oe-wide">备注
<input type="text" value={form.note} onChange={e => set('note', e.target.value)} placeholder="寄 2 套样品到德国" />
</label>
</div>
<div className="oe-form-foot">
<label className="oe-check">
<input type="checkbox" checked={form.is_paid} onChange={e => set('is_paid', e.target.checked)} />
已付款
</label>
<span className="oe-grow" />
{editingId && <button type="button" className="btn btn-ghost" onClick={reset}>取消</button>}
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : editingId ? '保存修改' : '记上'}
</button>
</div>
</form>
)}
<table className="data-table oe-table">
<thead>
<tr>
<th>日期</th><th>类型</th><th>金额</th>
<th>折 {orderCurrency}</th><th>收款方 / 单号</th><th>状态</th><th />
</tr>
</thead>
<tbody>
{!data?.expenses?.length ? (
<tr><td colSpan="7" className="empty-state">还没记过支出。快递费、吊卡费都记在这儿。</td></tr>
) : data.expenses.map(row => (
<tr key={row.id} className={editingId === row.id ? 'oe-editing' : ''}>
<td>{row.spent_at || '—'}</td>
<td>{row.category}</td>
<td className="oe-num">{fmt(row.amount, row.currency)}</td>
<td className="oe-num">{num(row.amount_in_order_currency).toFixed(2)}</td>
<td>
{row.payee || '—'}
{row.voucher_no && <div className="oe-voucher">{row.voucher_no}</div>}
{row.note && <div className="oe-note">{row.note}</div>}
</td>
<td>
<button type="button" className={`oe-paid ${row.is_paid ? 'is-paid' : ''}`}
onClick={() => canWrite && togglePaid(row)} disabled={!canWrite}
title={canWrite ? '点一下切换付款状态' : ''}>
{row.is_paid ? '已付' : '未付'}
</button>
</td>
<td className="oe-ops">
{canWrite && <>
<button type="button" className="btn-link" onClick={() => startEdit(row)}>改</button>
<button type="button" className="btn-link oe-del" onClick={() => remove(row)}>删</button>
</>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</Drawer>
)
}
export default OrderExpenses
+25 -2
View File
@@ -2,12 +2,16 @@ import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
import OrderExpenses from '../components/OrderExpenses'
function OrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const { user } = useAuth()
const canEdit = hasPermission(user, 'order.edit')
/* 支出单独一个权限点:掏钱寄样品的是业务员,而业务组没有 order.edit */
const canExpense = hasPermission(user, 'order.expense')
const [expenseOrder, setExpenseOrder] = useState(null)
useEffect(() => {
fetchOrders()
@@ -51,12 +55,13 @@ function OrderManagement() {
<th>订单日期</th>
<th>交货日期</th>
<th>金额</th>
<th>杂费</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 1, 0.8, 1]} />
</tbody>
</table>
</div>
@@ -80,6 +85,7 @@ function OrderManagement() {
<th>订单日期</th>
<th>交货日期</th>
<th>金额</th>
<th>杂费</th>
<th>状态</th>
<th>操作</th>
</tr>
@@ -87,7 +93,7 @@ function OrderManagement() {
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">暂无订单数据</td>
<td colSpan="8" className="empty-state">暂无订单数据</td>
</tr>
) : (
orders.map(order => (
@@ -105,6 +111,14 @@ function OrderManagement() {
? `${order.currency} ${order.total_amount.toFixed(2)}`
: '-'}
</td>
<td>
{order.expense_count ? (
<button type="button" className="btn-link" onClick={() => setExpenseOrder(order)}>
{order.currency} {Number(order.expense_total || 0).toFixed(2)}
{order.expense_unpaid > 0 && <em className="order-expense-unpaid"> 未付 {Number(order.expense_unpaid).toFixed(2)}</em>}
</button>
) : <span className="text-dim">—</span>}
</td>
<td>
<span className={`status-badge status-${order.status}`}>
{getStatusText(order.status)}
@@ -112,6 +126,7 @@ function OrderManagement() {
</td>
<td>
<button className="btn-link">查看</button>
{canExpense && <button type="button" className="btn-link" onClick={() => setExpenseOrder(order)}>支出</button>}
{canEdit && <button className="btn-link">编辑</button>}
</td>
</tr>
@@ -120,6 +135,14 @@ function OrderManagement() {
</tbody>
</table>
</div>
{expenseOrder && (
<OrderExpenses
order={expenseOrder}
onClose={() => setExpenseOrder(null)}
onChanged={fetchOrders}
/>
)}
</div>
)
}