feat(finance): 订单页人工录单按单实际金额计入成本
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { App, Alert, Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { createAdminManualOrderCost } from '@/services/admin'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
|
||||
export type ManualOrderCostItem = {
|
||||
orderItemId: number
|
||||
itemTitle: string
|
||||
skuNick?: string
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
type ManualOrderCostModalProps = {
|
||||
open: boolean
|
||||
orderId: number
|
||||
orderNo: string
|
||||
items: ManualOrderCostItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ManualOrderCostFormValues = {
|
||||
orderItemId: number
|
||||
amountYuan: number
|
||||
expenseCategory: string
|
||||
payeeName?: string
|
||||
paymentMethod?: string
|
||||
evidence?: string
|
||||
note?: string
|
||||
effective?: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'service_fee', label: '人工服务费' },
|
||||
{ value: 'goods_purchase', label: '商品采购' },
|
||||
{ value: 'compensation', label: '赔付 / 补偿' },
|
||||
{ value: 'refund_advance', label: '垫付退款' },
|
||||
{ value: 'freight', label: '运费' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'alipay', label: '支付宝' },
|
||||
{ value: 'bank', label: '银行卡' },
|
||||
{ value: 'cash', label: '现金' },
|
||||
{ value: 'platform_balance', label: '平台余额' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
function itemLabel(item: ManualOrderCostItem) {
|
||||
const parts = [item.itemTitle || `订单项 #${item.orderItemId}`]
|
||||
if (item.skuNick) parts.push(item.skuNick)
|
||||
if (item.quantity && item.quantity > 1) parts.push(`×${item.quantity}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export default function ManualOrderCostModal({
|
||||
open,
|
||||
orderId,
|
||||
orderNo,
|
||||
items,
|
||||
onClose,
|
||||
}: ManualOrderCostModalProps) {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ManualOrderCostFormValues>()
|
||||
const [canApprove, setCanApprove] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCanApprove(hasAdminPermission('finance.review'))
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
|
||||
amountYuan: undefined,
|
||||
expenseCategory: 'service_fee',
|
||||
paymentMethod: 'other',
|
||||
effective: hasAdminPermission('finance.review'),
|
||||
})
|
||||
}, [form, items, open])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ManualOrderCostFormValues) =>
|
||||
createAdminManualOrderCost({
|
||||
orderId,
|
||||
orderItemId: values.orderItemId,
|
||||
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
|
||||
expenseCategory: values.expenseCategory,
|
||||
payeeName: values.payeeName || '内部人工',
|
||||
paymentMethod: values.paymentMethod || 'other',
|
||||
note: values.note || '',
|
||||
evidenceJson: values.evidence
|
||||
? { reference: values.evidence }
|
||||
: { note: values.note || '订单页人工录单' },
|
||||
effective: canApprove ? values.effective !== false : false,
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
if (result.effective) {
|
||||
message.success(`已计入订单成本,支出单号 ${result.record.expenseNo}`)
|
||||
} else if (result.reason === 'approval_failed') {
|
||||
message.warning(result.message || '已提交,但自动生效失败,请到人工支出页审核')
|
||||
} else {
|
||||
message.success(`已提交人工支出 ${result.record.expenseNo},财务审核后计入毛利`)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '录入人工成本失败')
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields()
|
||||
mutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="人工录单 / 录入订单成本"
|
||||
okText={canApprove ? '确认录入并计入成本' : '提交审核'}
|
||||
cancelText="取消"
|
||||
confirmLoading={mutation.isPending}
|
||||
onOk={() => void submit()}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
>
|
||||
<Form<ManualOrderCostFormValues>
|
||||
form={form as FormInstance<ManualOrderCostFormValues>}
|
||||
layout="vertical"
|
||||
initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="每一单单独录实际金额,不按小时、不按单量套模板"
|
||||
description="成本会先关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。审核通过后写入成本快照,并计入销售毛利。"
|
||||
/>
|
||||
<Form.Item label="订单号">
|
||||
<Input value={orderNo} readOnly />
|
||||
</Form.Item>
|
||||
{items.length === 1 ? (
|
||||
<>
|
||||
<Form.Item label="计入订单项">
|
||||
<Input value={itemLabel(items[0])} readOnly />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderItemId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="orderItemId"
|
||||
label="计入订单项"
|
||||
rules={[{ required: true, message: '请选择订单项' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="订单有多个商品,请选择计入哪一项"
|
||||
options={items.map((item) => ({
|
||||
value: item.orderItemId,
|
||||
label: itemLabel(item),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="人工成本金额(元)"
|
||||
rules={[{ required: true, message: '请输入实际金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 12.50"
|
||||
prefix="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expenseCategory"
|
||||
label="费用类型"
|
||||
rules={[{ required: true, message: '请选择费用类型' }]}
|
||||
>
|
||||
<Select options={CATEGORY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payeeName" label="处理人 / 收款方">
|
||||
<Input placeholder="例如:客服小王、上游商家;留空记内部人工" maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentMethod" label="付款方式">
|
||||
<Select options={PAYMENT_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidence" label="凭证链接 / 流水号(可选)">
|
||||
<Input placeholder="转账截图链接、流水号等;留空则用备注作为凭证" maxLength={300} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="这一单实际做了什么、为什么是这个金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
{canApprove ? (
|
||||
<Form.Item
|
||||
name="effective"
|
||||
label="立即计入成本"
|
||||
valuePropName="checked"
|
||||
extra="关闭后先提交,等财务在人工支出页审核通过再计入毛利。"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="提交后需要财务审核"
|
||||
description="当前账号没有 finance.review 权限,录入后状态为待审核,审核通过才会写入成本快照。"
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
|
||||
@@ -38,10 +39,27 @@ export default function AdminOrderDetailPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { message, modal } = App.useApp()
|
||||
const { orderId = '' } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [statusModalVisible, setStatusModalVisible] = useState(false)
|
||||
const [manualCostOpen, setManualCostOpen] = useState(false)
|
||||
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
|
||||
const [statusReason, setStatusReason] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('manualCost') === '1') {
|
||||
setManualCostOpen(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
function closeManualCost() {
|
||||
setManualCostOpen(false)
|
||||
if (searchParams.get('manualCost')) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('manualCost')
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-order-detail', orderId],
|
||||
queryFn: () => fetchAdminOrderDetail(orderId),
|
||||
@@ -183,11 +201,8 @@ export default function AdminOrderDetailPage() {
|
||||
title="基础信息"
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/admin/manual-expenses?orderId=${detail.order.orderId}`)}
|
||||
>
|
||||
登记人工支出
|
||||
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
|
||||
人工录单
|
||||
</Button>
|
||||
{isAutoClaimOrder ? (
|
||||
<>
|
||||
@@ -368,6 +383,19 @@ export default function AdminOrderDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ManualOrderCostModal
|
||||
open={manualCostOpen}
|
||||
orderId={detail.order.orderId}
|
||||
orderNo={detail.order.platformOrderId || String(detail.order.orderId)}
|
||||
items={detail.items.map((item) => ({
|
||||
orderItemId: item.orderItemId,
|
||||
itemTitle: item.itemTitle,
|
||||
skuNick: item.skuNick,
|
||||
quantity: item.quantity,
|
||||
}))}
|
||||
onClose={closeManualCost}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DollarOutlined,
|
||||
EyeOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -333,11 +334,13 @@ export default function AdminOrdersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
minWidth: 130,
|
||||
minWidth: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
const orderNo = row.platformOrderId || String(row.orderId)
|
||||
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
|
||||
const isManualOrder =
|
||||
row.fulfillmentType === 'manual' || (!row.canClaimH5 && !row.workOrderCount)
|
||||
|
||||
return (
|
||||
<Space size={6} wrap>
|
||||
@@ -350,6 +353,17 @@ export default function AdminOrdersPage() {
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{isManualOrder ? (
|
||||
<Tooltip title="人工录单:录入这一单的实际人工成本">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => navigate(`/admin/orders/${row.orderId}?manualCost=1`)}
|
||||
>
|
||||
人工录单
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{row.canClaimH5 ? (
|
||||
<Tooltip title="复制自动直充 H5 领取链接">
|
||||
<Button
|
||||
|
||||
@@ -49,6 +49,15 @@ export function createAdminManualExpense(payload: Record<string, unknown>) {
|
||||
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
|
||||
}
|
||||
|
||||
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
|
||||
return apiPost<{
|
||||
record: AdminManualExpense
|
||||
effective: boolean
|
||||
reason: 'approved' | 'pending_review' | 'approval_failed'
|
||||
message?: string
|
||||
}>('/api/v1/admin/finance/manual-order-costs', payload)
|
||||
}
|
||||
|
||||
export function approveAdminManualExpense(
|
||||
expenseId: number,
|
||||
payload: Record<string, unknown> = {},
|
||||
|
||||
Reference in New Issue
Block a user