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 (
+ 读取中… {error}
+
+
+ >
+ )}
+
+
+
+
+ {!data?.expenses?.length ? (
+ 日期 类型 金额
+ 折 {orderCurrency} 收款方 / 单号 状态
+
+ ) : data.expenses.map(row => (
+ 还没记过支出。快递费、吊卡费都记在这儿。
+
+ ))}
+
+ {row.spent_at || '—'}
+ {row.category}
+ {fmt(row.amount, row.currency)}
+ {num(row.amount_in_order_currency).toFixed(2)}
+
+ {row.payee || '—'}
+ {row.voucher_no &&
+
+
+
+
+ {canWrite && <>
+
+
+ >}
+
+