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:
co-authored by
Claude Opus 5
parent
3e9fd2dc97
commit
f32a2daaef
@@ -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
|
||||
Reference in New Issue
Block a user