diff --git a/backend/db/db.js b/backend/db/db.js index e8e2d18..f9ef39c 100644 --- a/backend/db/db.js +++ b/backend/db/db.js @@ -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 = { diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 8463772..46db6c0 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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); diff --git a/backend/lib/permissions.js b/backend/lib/permissions.js index 5b4c2a9..9d6de42 100644 --- a/backend/lib/permissions.js +++ b/backend/lib/permissions.js @@ -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' diff --git a/backend/server.js b/backend/server.js index 41a68fe..fe643f5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 { diff --git a/frontend/src/components/OrderExpenses.jsx b/frontend/src/components/OrderExpenses.jsx new file mode 100644 index 0000000..df6f99c --- /dev/null +++ b/frontend/src/components/OrderExpenses.jsx @@ -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 ( + + {loading ?

读取中…

: ( + <> +
+
+ 杂费合计 + {fmt(summary?.total, orderCurrency)} +
+
+ 其中未付 + 0 ? 'oe-unpaid' : ''}>{fmt(summary?.unpaid, orderCurrency)} +
+
+ 订单金额 − 杂费 + {fmt(afterExpense, orderCurrency)} + 未扣货款采购成本 +
+
+ + {!!summary?.count && ( +
+ {Object.entries(summary.byCategory).map(([name, value]) => ( + {name} {num(value).toFixed(2)} + ))} +
+ )} + + {error &&

{error}

} + + {canWrite && ( +
+
{editingId ? '修改这笔支出' : '记一笔支出'}
+
+ + + + {/* 跨币种才出现:同币种硬填汇率只会填错 */} + {crossCurrency && ( + + )} + + + + +
+
+ + + {editingId && } + +
+
+ )} + + + + + + + + + {!data?.expenses?.length ? ( + + ) : data.expenses.map(row => ( + + + + + + + + + + ))} + +
日期类型金额折 {orderCurrency}收款方 / 单号状态 +
还没记过支出。快递费、吊卡费都记在这儿。
{row.spent_at || '—'}{row.category}{fmt(row.amount, row.currency)}{num(row.amount_in_order_currency).toFixed(2)} + {row.payee || '—'} + {row.voucher_no &&
{row.voucher_no}
} + {row.note &&
{row.note}
} +
+ + + {canWrite && <> + + + } +
+ + )} +
+ ) +} + +export default OrderExpenses diff --git a/frontend/src/pages/OrderManagement.jsx b/frontend/src/pages/OrderManagement.jsx index a940fc2..e9427d3 100644 --- a/frontend/src/pages/OrderManagement.jsx +++ b/frontend/src/pages/OrderManagement.jsx @@ -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() { 订单日期 交货日期 金额 + 杂费 状态 操作 - + @@ -80,6 +85,7 @@ function OrderManagement() { 订单日期 交货日期 金额 + 杂费 状态 操作 @@ -87,7 +93,7 @@ function OrderManagement() { {orders.length === 0 ? ( - 暂无订单数据 + 暂无订单数据 ) : ( orders.map(order => ( @@ -105,6 +111,14 @@ function OrderManagement() { ? `${order.currency} ${order.total_amount.toFixed(2)}` : '-'} + + {order.expense_count ? ( + + ) : —} + {getStatusText(order.status)} @@ -112,6 +126,7 @@ function OrderManagement() { + {canExpense && } {canEdit && } @@ -120,6 +135,14 @@ function OrderManagement() { + + {expenseOrder && ( + setExpenseOrder(null)} + onChanged={fetchOrders} + /> + )} ) }