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
+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>
)
}