Files
happy-export/frontend/src/components/OrderExpenses.jsx
T
DrsakuraandClaude Opus 5 f32a2daaef 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]>
2026-10-22 15:22:56 +08:00

270 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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