feat(finance): 完成成本快照运行层、销售事实与人工支出登记
迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
This commit is contained in:
@@ -21,6 +21,8 @@ import WorkerLayout from '@/layouts/WorkerLayout'
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
|
||||
const AdminManualExpensePage = lazy(() => import('@/pages/admin/AdminManualExpensePage'))
|
||||
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
@@ -146,10 +148,12 @@ function AdminApplication() {
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
|
||||
<Route path="cost-center" element={<AdminCostCenterPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
|
||||
<Route path="fund" element={<AdminFundPage />} />
|
||||
<Route path="manual-expenses" element={<AdminManualExpensePage />} />
|
||||
</Route>
|
||||
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
BugOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FileTextOutlined,
|
||||
LogoutOutlined,
|
||||
LineChartOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
OrderedListOutlined,
|
||||
@@ -132,6 +134,8 @@ export default function AdminLayout() {
|
||||
role === 'finance'
|
||||
? [
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||
{ key: '/admin/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
|
||||
{ key: '/admin/manual-expenses', icon: <FileTextOutlined />, label: '人工支出' },
|
||||
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
|
||||
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
|
||||
@@ -144,10 +148,20 @@ export default function AdminLayout() {
|
||||
icon: <WalletOutlined />,
|
||||
label: role === 'support' ? '我的备用金' : '备用金',
|
||||
},
|
||||
{
|
||||
key: '/admin/manual-expenses',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '人工支出',
|
||||
},
|
||||
...(role === 'support'
|
||||
? []
|
||||
: [
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||
{
|
||||
key: '/admin/sales-profit',
|
||||
icon: <LineChartOutlined />,
|
||||
label: '销售毛利',
|
||||
},
|
||||
{
|
||||
key: '/admin/cost-center',
|
||||
icon: <ApartmentOutlined />,
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
approveAdminManualExpense,
|
||||
createAdminManualExpense,
|
||||
fetchAdminManualExpenseCostItems,
|
||||
fetchAdminManualExpenseOverview,
|
||||
fetchAdminManualExpenses,
|
||||
rejectAdminManualExpense,
|
||||
reimburseAdminManualExpense,
|
||||
} from '@/services/admin'
|
||||
import type { AdminManualExpense, AdminManualExpenseStatus } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatMoney } from './panels/shared'
|
||||
|
||||
type FilterForm = { dateRange?: unknown; status?: string; keyword?: string }
|
||||
|
||||
type ExpenseForm = {
|
||||
orderId?: number
|
||||
orderItemId?: number
|
||||
costItemId?: number
|
||||
amountYuan?: number
|
||||
expenseCategory: string
|
||||
payeeType: string
|
||||
payeeName: string
|
||||
payeeAccount?: string
|
||||
paymentMethod: string
|
||||
paidAt?: unknown
|
||||
evidenceReference: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const STATUS_META: Record<AdminManualExpenseStatus, { label: string; color: string }> = {
|
||||
draft: { label: '草稿', color: 'default' },
|
||||
submitted: { label: '待审核', color: 'orange' },
|
||||
approved: { label: '已通过', color: 'green' },
|
||||
rejected: { label: '已驳回', color: 'red' },
|
||||
reimbursed: { label: '已报销', color: 'blue' },
|
||||
reversed: { label: '已冲正', color: 'purple' },
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'goods_purchase', label: '采购拿货' },
|
||||
{ value: 'compensation', label: '客户赔偿' },
|
||||
{ value: 'refund_advance', label: '退款垫付' },
|
||||
{ value: 'service_fee', label: '服务费' },
|
||||
{ value: 'freight', label: '运费' },
|
||||
{ value: 'recharge', label: '平台充值' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const PAYEE_TYPE_OPTIONS = [
|
||||
{ value: 'worker', label: '打手' },
|
||||
{ value: 'upstream', label: '上游/供货' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'platform', 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: '其他' },
|
||||
]
|
||||
|
||||
export default function AdminManualExpensePage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [filterForm] = Form.useForm<FilterForm>()
|
||||
const [expenseForm] = Form.useForm<ExpenseForm>()
|
||||
const canReview = hasAdminPermission('finance.review')
|
||||
const canCreate = hasAdminPermission('fund.expense') || hasAdminPermission('finance.config')
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
status: '',
|
||||
keyword: '',
|
||||
})
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [actionId, setActionId] = useState<number | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<AdminManualExpense | null>(null)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-manual-expenses', filters, page, pageSize],
|
||||
queryFn: () => fetchAdminManualExpenses({ ...filters, page, pageSize }),
|
||||
})
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-manual-expense-overview', filters],
|
||||
queryFn: () => fetchAdminManualExpenseOverview(filters),
|
||||
})
|
||||
const costItemsQuery = useQuery({
|
||||
queryKey: ['admin-manual-expense-cost-items'],
|
||||
queryFn: fetchAdminManualExpenseCostItems,
|
||||
})
|
||||
|
||||
const list = listQuery.data?.data
|
||||
const overview = overviewQuery.data?.data?.overview
|
||||
const costItems = costItemsQuery.data?.data.items || []
|
||||
|
||||
useEffect(() => {
|
||||
const orderId = Number(searchParams.get('orderId') || 0)
|
||||
if (Number.isInteger(orderId) && orderId > 0) {
|
||||
setCreateOpen(true)
|
||||
expenseForm.setFieldsValue({ orderId })
|
||||
}
|
||||
}, [expenseForm, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
const current = expenseForm.getFieldValue('costItemId')
|
||||
if (current) return
|
||||
const systemItem = costItems.find(
|
||||
(item: { code: string; id: number }) => item.code === 'sys_manual_service',
|
||||
)
|
||||
if (systemItem) expenseForm.setFieldValue('costItemId', systemItem.id)
|
||||
}, [costItems, createOpen, expenseForm])
|
||||
|
||||
function refreshAll() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expense-overview'] })
|
||||
}
|
||||
|
||||
function applyFilters(values: FilterForm) {
|
||||
const range = Array.isArray(values.dateRange) ? values.dateRange : []
|
||||
setPage(1)
|
||||
setFilters({
|
||||
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
|
||||
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
|
||||
status: values.status || '',
|
||||
keyword: String(values.keyword || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterForm.resetFields()
|
||||
setPage(1)
|
||||
setFilters({ dateFrom: '', dateTo: '', status: '', keyword: '' })
|
||||
}
|
||||
|
||||
async function submitExpense() {
|
||||
const values = await expenseForm.validateFields()
|
||||
setCreating(true)
|
||||
try {
|
||||
const amountFen = Math.round(Number(values.amountYuan || 0) * 100)
|
||||
const paidAt = values.paidAt
|
||||
? dayjs(values.paidAt as Dayjs).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
const response = await createAdminManualExpense({
|
||||
orderId: values.orderId || undefined,
|
||||
orderItemId: values.orderItemId || undefined,
|
||||
costItemId: values.costItemId || undefined,
|
||||
amountFen,
|
||||
expenseCategory: values.expenseCategory,
|
||||
payeeType: values.payeeType,
|
||||
payeeName: values.payeeName,
|
||||
payeeAccount: values.payeeAccount || '',
|
||||
paymentMethod: values.paymentMethod,
|
||||
paidAt,
|
||||
evidenceJson: { reference: values.evidenceReference },
|
||||
note: values.note || '',
|
||||
})
|
||||
message.success(`已登记 ${response.data.expenseNo}`)
|
||||
setCreateOpen(false)
|
||||
expenseForm.resetFields()
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登记人工支出失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function approve(row: AdminManualExpense) {
|
||||
modal.confirm({
|
||||
title: '通过人工支出',
|
||||
content: `确认通过 ${row.expenseNo},金额 ${formatMoney(row.amountFen)}?通过后会计入成本。`,
|
||||
okText: '通过',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setActionId(row.id)
|
||||
try {
|
||||
await approveAdminManualExpense(row.id)
|
||||
message.success('人工支出已通过')
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '审核失败')
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openReject(row: AdminManualExpense) {
|
||||
setRejectTarget(row)
|
||||
setRejectReason('')
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
if (!rejectTarget) return
|
||||
if (!rejectReason.trim()) {
|
||||
message.warning('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setActionId(rejectTarget.id)
|
||||
try {
|
||||
await rejectAdminManualExpense(rejectTarget.id, rejectReason.trim())
|
||||
message.success('人工支出已驳回')
|
||||
setRejectTarget(null)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '驳回失败')
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function reimburse(row: AdminManualExpense) {
|
||||
modal.confirm({
|
||||
title: '标记已报销',
|
||||
content: `确认 ${row.expenseNo} 已完成报销?`,
|
||||
okText: '标记已报销',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setActionId(row.id)
|
||||
try {
|
||||
await reimburseAdminManualExpense(row.id)
|
||||
message.success('已标记报销')
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '标记报销失败')
|
||||
} finally {
|
||||
setActionId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminManualExpense> = [
|
||||
{
|
||||
title: '登记号 / 状态',
|
||||
dataIndex: 'expenseNo',
|
||||
width: 190,
|
||||
render: (value: string, row) => {
|
||||
const meta = STATUS_META[row.status]
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text copyable>{value}</Typography.Text>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '付款时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 165,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '订单 / 商品',
|
||||
key: 'order',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.platformOrderId || (row.orderId ? `#${row.orderId}` : '无关联订单')}</span>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{row.skuName || row.workOrderNo || '-'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '付款项目',
|
||||
key: 'costItem',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.costItemName || '未归类'}</span>
|
||||
<Typography.Text type="secondary">
|
||||
{CATEGORY_OPTIONS.find((item) => item.value === row.expenseCategory)?.label ||
|
||||
row.expenseCategory}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 120,
|
||||
render: (value: number) => <Typography.Text strong>{formatMoney(value)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '付款人 → 收款人',
|
||||
key: 'parties',
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{row.paidByName || '-'}</span>
|
||||
<Typography.Text type="secondary">→ {row.payeeName || '-'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
dataIndex: 'evidenceJson',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (value: Record<string, unknown>) => {
|
||||
const reference = typeof value?.reference === 'string' ? value.reference : ''
|
||||
return (
|
||||
<Typography.Text ellipsis title={reference}>
|
||||
{reference || '-'}
|
||||
</Typography.Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本快照',
|
||||
dataIndex: 'costSnapshotId',
|
||||
width: 100,
|
||||
render: (value: number | null) =>
|
||||
value ? <Tag color="green">#{value}</Tag> : <Tag color="orange">未生成</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
if (!canReview) return <Typography.Text type="secondary">仅查看</Typography.Text>
|
||||
return (
|
||||
<Space size={4}>
|
||||
{row.status === 'submitted' ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
loading={actionId === row.id}
|
||||
onClick={() => approve(row)}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
disabled={actionId === row.id}
|
||||
onClick={() => openReject(row)}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{row.status === 'approved' ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<WalletOutlined />}
|
||||
loading={actionId === row.id}
|
||||
onClick={() => reimburse(row)}
|
||||
>
|
||||
已报销
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'reimbursed' ? (
|
||||
<Typography.Text type="secondary">已完成</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="人工支出"
|
||||
description="登记公司实际付出的人工/垫付支出:项目、金额、订单、付款人、收款人与凭证。"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Form<FilterForm>
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
className="filter-form"
|
||||
onFinish={applyFilters}
|
||||
initialValues={{
|
||||
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
|
||||
status: '',
|
||||
keyword: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item name="dateRange" label="付款日期">
|
||||
<AdminRangePicker format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
...Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="登记号 / 订单号 / 收款人" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
{canCreate ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
登记人工支出
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{overview && overview.unclassifiedCount > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`有 ${overview.unclassifiedCount} 条支出未归类,金额 ${formatMoney(overview.unclassifiedAmountFen)}`}
|
||||
description="未归类的支出无法进入成本报表,请补齐付款项目。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{overview && overview.noOrderCount > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`有 ${overview.noOrderCount} 条支出没有关联订单,金额 ${formatMoney(overview.noOrderAmountFen)}`}
|
||||
description="平台充值、批量拿货、办公支出可以没有订单,但建议在备注里写清用途。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="待审核"
|
||||
value={overview?.submittedAmountFen || 0}
|
||||
formatter={() => formatMoney(overview?.submittedAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已通过"
|
||||
value={overview?.approvedAmountFen || 0}
|
||||
formatter={() => formatMoney(overview?.approvedAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已报销"
|
||||
value={overview?.reimbursedAmountFen || 0}
|
||||
formatter={() => formatMoney(overview?.reimbursedAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic title="待审核笔数" value={overview?.submittedCount || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={5}>
|
||||
<Card>
|
||||
<Statistic title="已通过笔数" value={overview?.approvedCount || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Table<AdminManualExpense>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={list?.items || []}
|
||||
loading={listQuery.isLoading}
|
||||
scroll={{ x: 1650 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total: list?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条支出`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="登记人工支出"
|
||||
open={createOpen}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false)
|
||||
expenseForm.resetFields()
|
||||
}}
|
||||
onOk={submitExpense}
|
||||
confirmLoading={creating}
|
||||
okText="提交登记"
|
||||
width={720}
|
||||
>
|
||||
<Form<ExpenseForm>
|
||||
form={expenseForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
expenseCategory: 'service_fee',
|
||||
payeeType: 'upstream',
|
||||
paymentMethod: 'wechat',
|
||||
paidAt: dayjs(),
|
||||
}}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="orderId" label="订单 ID(可选)">
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="有订单时填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="orderItemId" label="订单项 ID(可选)">
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="按订单项归集成本时填写"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="costItemId"
|
||||
label="付款项目"
|
||||
rules={[{ required: true, message: '请选择付款项目' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={costItemsQuery.isLoading}
|
||||
options={costItems.map((item: { id: number; code: string; name: string }) => ({
|
||||
value: item.id,
|
||||
label: `${item.name}(${item.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="金额(元)"
|
||||
rules={[{ required: true, message: '请填写金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="expenseCategory" label="支出分类" rules={[{ required: true }]}>
|
||||
<Select options={CATEGORY_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="payeeType" label="收款方类型" rules={[{ required: true }]}>
|
||||
<Select options={PAYEE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item
|
||||
name="payeeName"
|
||||
label="收款方"
|
||||
rules={[{ required: true, message: '请填写收款方' }]}
|
||||
>
|
||||
<Input placeholder="微信号 / 支付宝 / 姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item name="payeeAccount" label="收款账号(可选)">
|
||||
<Input placeholder="账号、手机号或备注" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="paymentMethod" label="付款方式" rules={[{ required: true }]}>
|
||||
<Select options={PAYMENT_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item name="paidAt" label="付款时间" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={9}>
|
||||
<Form.Item
|
||||
name="evidenceReference"
|
||||
label="凭证链接 / 流水号"
|
||||
rules={[{ required: true, message: '至少留一项可追溯凭证' }]}
|
||||
>
|
||||
<Input placeholder="转账截图链接、微信流水号或聊天记录说明" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="note" label="备注 / 原因">
|
||||
<Input.TextArea rows={3} placeholder="赔偿、加急、补差价等必须写清原因" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`驳回 ${rejectTarget?.expenseNo || ''}`}
|
||||
open={Boolean(rejectTarget)}
|
||||
onCancel={() => setRejectTarget(null)}
|
||||
onOk={submitReject}
|
||||
confirmLoading={Boolean(rejectTarget && actionId === rejectTarget.id)}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder="必须填写驳回原因"
|
||||
/>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CopyOutlined,
|
||||
FileTextOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
StopOutlined,
|
||||
@@ -181,42 +182,50 @@ export default function AdminOrderDetailPage() {
|
||||
<Card
|
||||
title="基础信息"
|
||||
extra={
|
||||
isAutoClaimOrder ? (
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
||||
复制 H5 领取链接
|
||||
</Button>
|
||||
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openStatusModal('active')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
恢复启用
|
||||
<Space>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/admin/manual-expenses?orderId=${detail.order.orderId}`)}
|
||||
>
|
||||
登记人工支出
|
||||
</Button>
|
||||
{isAutoClaimOrder ? (
|
||||
<>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
||||
复制 H5 领取链接
|
||||
</Button>
|
||||
) : canPauseOrClose ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => openStatusModal('paused')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
暂停兑换
|
||||
</Button>
|
||||
) : null}
|
||||
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => openStatusModal('closed')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
关闭兑换
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openStatusModal('active')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
恢复启用
|
||||
</Button>
|
||||
) : canPauseOrClose ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => openStatusModal('paused')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
暂停兑换
|
||||
</Button>
|
||||
) : null}
|
||||
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => openStatusModal('closed')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
关闭兑换
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
fetchAdminSalesDailyFacts,
|
||||
fetchAdminSalesProfitOverview,
|
||||
rebuildAdminSalesDailyFacts,
|
||||
} from '@/services/admin'
|
||||
import type { AdminSalesDailyFact } from '@/types/admin/finance'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatMoney } from './panels/shared'
|
||||
|
||||
type FilterForm = { dateRange?: unknown; provider?: string; carrierType?: string }
|
||||
|
||||
const CARRIER_OPTIONS = [
|
||||
{ value: '', label: '全部载体' },
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'work_order', label: '接单平台' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'shared', label: '共用' },
|
||||
]
|
||||
|
||||
const CARRIER_LABEL: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
work_order: '接单平台',
|
||||
manual: '人工客服',
|
||||
shared: '共用',
|
||||
unknown: '未知',
|
||||
}
|
||||
|
||||
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
|
||||
missing: { label: '无成本', color: 'red' },
|
||||
partial: { label: '部分缺失', color: 'orange' },
|
||||
estimated: { label: '预估成本', color: 'blue' },
|
||||
actual: { label: '实际成本', color: 'green' },
|
||||
}
|
||||
|
||||
export default function AdminSalesProfitPage() {
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<FilterForm>()
|
||||
const canConfig = hasAdminPermission('finance.config')
|
||||
const [filters, setFilters] = useState<{
|
||||
dateFrom: string
|
||||
dateTo: string
|
||||
provider: string
|
||||
carrierType: string
|
||||
}>({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [rebuilding, setRebuilding] = useState(false)
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-sales-profit-overview', filters],
|
||||
queryFn: () => fetchAdminSalesProfitOverview(filters),
|
||||
})
|
||||
const factsQuery = useQuery({
|
||||
queryKey: ['admin-sales-daily-facts', filters, page, pageSize],
|
||||
queryFn: () => fetchAdminSalesDailyFacts({ ...filters, page, pageSize }),
|
||||
})
|
||||
|
||||
const overview = overviewQuery.data?.data
|
||||
const facts = factsQuery.data?.data
|
||||
|
||||
function applyFilters(values: FilterForm) {
|
||||
const range = Array.isArray(values.dateRange) ? values.dateRange : []
|
||||
setPage(1)
|
||||
setFilters({
|
||||
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
|
||||
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
|
||||
provider: values.provider || '',
|
||||
carrierType: values.carrierType || '',
|
||||
})
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
setFilters({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
|
||||
}
|
||||
|
||||
async function rebuildFacts() {
|
||||
setRebuilding(true)
|
||||
try {
|
||||
const response = await rebuildAdminSalesDailyFacts({
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
})
|
||||
message.success(
|
||||
`销售事实已重算:${response.data.insertedCount} 行,范围 ${response.data.range.fromDate} ~ ${response.data.range.toDate}`,
|
||||
)
|
||||
await Promise.all([overviewQuery.refetch(), factsQuery.refetch()])
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重算销售事实失败')
|
||||
} finally {
|
||||
setRebuilding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = overview?.overview
|
||||
const dailyColumns: TableColumnsType<NonNullable<typeof overview>['daily'][number]> = [
|
||||
{ title: '日期', dataIndex: 'statDate', width: 120 },
|
||||
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
|
||||
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
|
||||
{ title: '退款', dataIndex: 'refundAmountFen', render: formatMoney },
|
||||
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
|
||||
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
|
||||
{
|
||||
title: '毛利',
|
||||
dataIndex: 'grossProfitFen',
|
||||
render: (value: number) => (
|
||||
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||||
{formatMoney(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const productColumns: TableColumnsType<NonNullable<typeof overview>['byProduct'][number]> = [
|
||||
{
|
||||
title: '标准商品',
|
||||
dataIndex: 'canonicalName',
|
||||
width: 320,
|
||||
ellipsis: true,
|
||||
render: (value: string, row) => (
|
||||
<Space size={4}>
|
||||
<span>{value}</span>
|
||||
{row.canonicalProductId ? (
|
||||
<Tag>{row.canonicalSku}</Tag>
|
||||
) : (
|
||||
<Tag color="orange">未归一</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
|
||||
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
|
||||
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
|
||||
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
|
||||
{
|
||||
title: '毛利',
|
||||
dataIndex: 'grossProfitFen',
|
||||
render: (value: number) => (
|
||||
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||||
{formatMoney(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const carrierColumns: TableColumnsType<NonNullable<typeof overview>['byCarrier'][number]> = [
|
||||
{
|
||||
title: '履约载体',
|
||||
dataIndex: 'carrierType',
|
||||
render: (value: string) => CARRIER_LABEL[value] || value,
|
||||
},
|
||||
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
|
||||
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
|
||||
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
|
||||
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
|
||||
{
|
||||
title: '毛利',
|
||||
dataIndex: 'grossProfitFen',
|
||||
render: (value: number) => (
|
||||
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||||
{formatMoney(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const factColumns: TableColumnsType<AdminSalesDailyFact> = [
|
||||
{ title: '日期', dataIndex: 'statDate', width: 110 },
|
||||
{ title: '来源', dataIndex: 'provider', width: 120 },
|
||||
{
|
||||
title: '店铺',
|
||||
dataIndex: 'shopId',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (value) => value || '全局',
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'canonicalName',
|
||||
width: 260,
|
||||
ellipsis: true,
|
||||
render: (value: string, row) =>
|
||||
row.canonicalProductId ? value : <Tag color="orange">未归一</Tag>,
|
||||
},
|
||||
{
|
||||
title: '载体',
|
||||
dataIndex: 'carrierType',
|
||||
width: 100,
|
||||
render: (value: string) => CARRIER_LABEL[value] || value,
|
||||
},
|
||||
{ title: '订单项', dataIndex: 'itemCount', width: 80 },
|
||||
{ title: '销售收入', dataIndex: 'grossRevenueFen', width: 120, render: formatMoney },
|
||||
{ title: '成本', dataIndex: 'costAmountFen', width: 120, render: formatMoney },
|
||||
{
|
||||
title: '成本状态',
|
||||
dataIndex: 'costStage',
|
||||
width: 110,
|
||||
render: (value: string) => {
|
||||
const meta = COST_STAGE_LABEL[value] || { label: value, color: 'default' }
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '毛利',
|
||||
dataIndex: 'grossProfitFen',
|
||||
width: 130,
|
||||
render: (value: number) => (
|
||||
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||||
{formatMoney(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="销售毛利"
|
||||
description="按订单项归集收入与成本;未归一商品单独成行,成本缺失时毛利不直接用于决策。"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Form<FilterForm>
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="filter-form"
|
||||
onFinish={applyFilters}
|
||||
initialValues={{
|
||||
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
|
||||
provider: '',
|
||||
carrierType: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item name="dateRange" label="销售日期">
|
||||
<AdminRangePicker format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="来源">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: '', label: '全部来源' },
|
||||
{ value: '91kaquan', label: '91卡券' },
|
||||
{ value: 'pdd', label: '拼多多' },
|
||||
{ value: 'douyin_mashida', label: '抖音码速达' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="carrierType" label="载体">
|
||||
<Select style={{ width: 150 }} options={CARRIER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
{canConfig ? (
|
||||
<Button icon={<ReloadOutlined />} loading={rebuilding} onClick={rebuildFacts}>
|
||||
重算销售事实
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{overviewQuery.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="读取销售毛利失败"
|
||||
description={
|
||||
overviewQuery.error instanceof Error ? overviewQuery.error.message : '请稍后重试'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{summary && summary.costMissingCount > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`有 ${summary.costMissingCount} 个订单项没有成本快照,当前毛利偏高`}
|
||||
description="请先在「商品与成本」中补齐成本项/版本并回填快照,再使用毛利数据做经营决策。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{summary && summary.unmappedRevenuePct > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`未归一商品收入占 ${summary.unmappedRevenuePct.toFixed(2)}%`}
|
||||
description={`未归一收入 ${formatMoney(summary.unmappedRevenueFen)},已单独成行;建议补渠道 SKU 映射。`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{summary && summary.ordersWithoutItems > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`有 ${summary.ordersWithoutItems} 个已支付订单没有订单项`}
|
||||
description={`涉及 ${formatMoney(summary.revenueWithoutItemsFen)},无法归集到商品与成本,未进入销售事实。`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="销售收入"
|
||||
value={summary?.grossRevenueFen || 0}
|
||||
formatter={() => formatMoney(summary?.grossRevenueFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="净收入"
|
||||
value={summary?.netRevenueFen || 0}
|
||||
formatter={() => formatMoney(summary?.netRevenueFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="成本"
|
||||
value={summary?.costAmountFen || 0}
|
||||
formatter={() => formatMoney(summary?.costAmountFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="毛利"
|
||||
value={summary?.grossProfitFen || 0}
|
||||
valueStyle={{ color: (summary?.grossProfitFen || 0) >= 0 ? '#3f8600' : '#cf1322' }}
|
||||
formatter={() => formatMoney(summary?.grossProfitFen || 0)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic title="订单数" value={summary?.orderCount || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} xl={4}>
|
||||
<Card>
|
||||
<Statistic title="订单项" value={summary?.itemCount || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'daily',
|
||||
label: '日报',
|
||||
children: (
|
||||
<Table
|
||||
rowKey="statDate"
|
||||
size="small"
|
||||
columns={dailyColumns}
|
||||
dataSource={overview?.daily || []}
|
||||
loading={overviewQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'product',
|
||||
label: '商品',
|
||||
children: (
|
||||
<Table
|
||||
rowKey={(row) => `${row.canonicalProductId || 'unmapped'}`}
|
||||
size="small"
|
||||
columns={productColumns}
|
||||
dataSource={overview?.byProduct || []}
|
||||
loading={overviewQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'carrier',
|
||||
label: '载体',
|
||||
children: (
|
||||
<Table
|
||||
rowKey="carrierType"
|
||||
size="small"
|
||||
columns={carrierColumns}
|
||||
dataSource={overview?.byCarrier || []}
|
||||
loading={overviewQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'facts',
|
||||
label: '事实明细',
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={factColumns}
|
||||
dataSource={facts?.items || []}
|
||||
loading={factsQuery.isLoading}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total: facts?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条事实`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 1400 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { apiGet } from '@/lib/http'
|
||||
import type { AdminFinanceSummary, AdminFinanceTransactionResult } from '@/types/admin/finance'
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminFinanceSummary,
|
||||
AdminFinanceTransactionResult,
|
||||
AdminSalesFactsResult,
|
||||
AdminSalesProfitOverview,
|
||||
} from '@/types/admin/finance'
|
||||
|
||||
export type AdminFinanceDateType = 'occurred' | 'created' | 'reviewed'
|
||||
|
||||
@@ -22,3 +27,29 @@ export function fetchAdminFinanceTransactions(
|
||||
) {
|
||||
return apiGet<AdminFinanceTransactionResult>('/api/v1/admin/finance/transactions', params)
|
||||
}
|
||||
|
||||
export type AdminSalesProfitParams = {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
provider?: string
|
||||
carrierType?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function fetchAdminSalesProfitOverview(params: AdminSalesProfitParams = {}) {
|
||||
return apiGet<AdminSalesProfitOverview>('/api/v1/admin/finance/sales-profit/overview', params)
|
||||
}
|
||||
|
||||
export function fetchAdminSalesDailyFacts(params: AdminSalesProfitParams = {}) {
|
||||
return apiGet<AdminSalesFactsResult>('/api/v1/admin/finance/sales-facts', params)
|
||||
}
|
||||
|
||||
export function rebuildAdminSalesDailyFacts(payload: { dateFrom?: string; dateTo?: string }) {
|
||||
return apiPost<{
|
||||
range: { fromDate: string; toDate: string }
|
||||
deletedCount: number
|
||||
insertedCount: number
|
||||
computedAt: string
|
||||
}>('/api/v1/admin/finance/sales-facts/rebuild', payload)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth'
|
||||
export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './manual-expense'
|
||||
export * from './cost-center'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminManualExpense,
|
||||
AdminManualExpenseListResult,
|
||||
AdminManualExpenseOverview,
|
||||
} from '@/types/admin/manual-expense'
|
||||
|
||||
export type AdminManualExpenseParams = {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
status?: string
|
||||
keyword?: string
|
||||
costItemId?: number
|
||||
paidByAdminUserId?: number
|
||||
orderId?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpenses(params: AdminManualExpenseParams = {}) {
|
||||
return apiGet<AdminManualExpenseListResult>('/api/v1/admin/finance/manual-expenses', params)
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpenseOverview(params: AdminManualExpenseParams = {}) {
|
||||
return apiGet<AdminManualExpenseOverview>(
|
||||
'/api/v1/admin/finance/manual-expenses/overview',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpenseCostItems() {
|
||||
return apiGet<{
|
||||
items: Array<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
costType: string
|
||||
carrierType: string
|
||||
pricingMode: string
|
||||
}>
|
||||
}>('/api/v1/admin/finance/manual-expenses/cost-items')
|
||||
}
|
||||
|
||||
export function fetchAdminManualExpense(expenseId: number) {
|
||||
return apiGet<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}`)
|
||||
}
|
||||
|
||||
export function createAdminManualExpense(payload: Record<string, unknown>) {
|
||||
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
|
||||
}
|
||||
|
||||
export function approveAdminManualExpense(
|
||||
expenseId: number,
|
||||
payload: Record<string, unknown> = {},
|
||||
) {
|
||||
return apiPost<AdminManualExpense>(
|
||||
`/api/v1/admin/finance/manual-expenses/${expenseId}/approve`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function rejectAdminManualExpense(expenseId: number, rejectReason: string) {
|
||||
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reject`, {
|
||||
rejectReason,
|
||||
})
|
||||
}
|
||||
|
||||
export function reimburseAdminManualExpense(expenseId: number) {
|
||||
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reimburse`)
|
||||
}
|
||||
@@ -60,3 +60,83 @@ export type AdminFinanceTransactionResult = {
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
range: { from: string; to: string }
|
||||
}
|
||||
|
||||
export type AdminSalesProfitRange = {
|
||||
fromDate: string
|
||||
toDate: string
|
||||
}
|
||||
|
||||
export type AdminSalesProfitOverview = {
|
||||
range: AdminSalesProfitRange
|
||||
overview: {
|
||||
orderCount: number
|
||||
ordersWithoutItems: number
|
||||
revenueWithoutItemsFen: number
|
||||
itemCount: number
|
||||
grossRevenueFen: number
|
||||
refundAmountFen: number
|
||||
netRevenueFen: number
|
||||
costAmountFen: number
|
||||
costMissingCount: number
|
||||
grossProfitFen: number
|
||||
unmappedRevenueFen: number
|
||||
unmappedRevenuePct: number
|
||||
}
|
||||
daily: Array<{
|
||||
statDate: string
|
||||
itemCount: number
|
||||
grossRevenueFen: number
|
||||
refundAmountFen: number
|
||||
netRevenueFen: number
|
||||
costAmountFen: number
|
||||
costMissingCount: number
|
||||
grossProfitFen: number
|
||||
}>
|
||||
byCarrier: Array<{
|
||||
carrierType: string
|
||||
itemCount: number
|
||||
grossRevenueFen: number
|
||||
costAmountFen: number
|
||||
costMissingCount: number
|
||||
grossProfitFen: number
|
||||
}>
|
||||
byProduct: Array<{
|
||||
canonicalProductId: number | null
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
itemCount: number
|
||||
grossRevenueFen: number
|
||||
costAmountFen: number
|
||||
costMissingCount: number
|
||||
grossProfitFen: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type AdminSalesDailyFact = {
|
||||
id: number
|
||||
statDate: string
|
||||
provider: string
|
||||
shopId: string
|
||||
canonicalProductId: number | null
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
carrierType: string
|
||||
orderCount: number
|
||||
itemCount: number
|
||||
quantity: number
|
||||
grossRevenueFen: number
|
||||
refundAmountFen: number
|
||||
netRevenueFen: number
|
||||
costAmountFen: number
|
||||
costMissingCount: number
|
||||
costStage: 'missing' | 'partial' | 'estimated' | 'actual'
|
||||
grossProfitFen: number
|
||||
computedAt: string
|
||||
}
|
||||
|
||||
export type AdminSalesFactsResult = {
|
||||
range: AdminSalesProfitRange
|
||||
items: AdminSalesDailyFact[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
computedAt: string
|
||||
}
|
||||
|
||||
@@ -26,8 +26,20 @@ export type {
|
||||
AdminFinanceTransaction,
|
||||
AdminFinanceSummary,
|
||||
AdminFinanceTransactionResult,
|
||||
AdminSalesDailyFact,
|
||||
AdminSalesFactsResult,
|
||||
AdminSalesProfitOverview,
|
||||
AdminSalesProfitRange,
|
||||
} from './finance'
|
||||
|
||||
// Manual expense types
|
||||
export type {
|
||||
AdminManualExpense,
|
||||
AdminManualExpenseListResult,
|
||||
AdminManualExpenseOverview,
|
||||
AdminManualExpenseStatus,
|
||||
} from './manual-expense'
|
||||
|
||||
// Fund / imprest account types
|
||||
export type {
|
||||
AdminFundAccount,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export type AdminManualExpenseStatus =
|
||||
| 'draft'
|
||||
| 'submitted'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'reimbursed'
|
||||
| 'reversed'
|
||||
|
||||
export type AdminManualExpense = {
|
||||
id: number
|
||||
expenseNo: string
|
||||
orderId: number | null
|
||||
orderItemId: number | null
|
||||
workOrderId: number | null
|
||||
fundExpenseId: number | null
|
||||
costItemId: number | null
|
||||
costItemName: string
|
||||
costItemCode: string
|
||||
expenseCategory: string
|
||||
amountFen: number
|
||||
currency: string
|
||||
paidByAdminUserId: number | null
|
||||
paidByName: string
|
||||
payeeType: string
|
||||
payeeName: string
|
||||
payeeAccount: string
|
||||
paymentMethod: string
|
||||
paidAt: string
|
||||
status: AdminManualExpenseStatus
|
||||
approvedBy: string
|
||||
approvedAt: string | null
|
||||
rejectedBy: string
|
||||
rejectedAt: string | null
|
||||
rejectReason: string
|
||||
reimbursedBy: string
|
||||
reimbursedAt: string | null
|
||||
evidenceJson: Record<string, unknown>
|
||||
costSnapshotId: number | null
|
||||
reversalOfId: number | null
|
||||
expenseBatchNo: string
|
||||
note: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
platformOrderId: string
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
workOrderNo: string
|
||||
}
|
||||
|
||||
export type AdminManualExpenseListResult = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
items: AdminManualExpense[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export type AdminManualExpenseOverview = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
overview: {
|
||||
submittedCount: number
|
||||
submittedAmountFen: number
|
||||
approvedCount: number
|
||||
approvedAmountFen: number
|
||||
reimbursedCount: number
|
||||
reimbursedAmountFen: number
|
||||
rejectedCount: number
|
||||
rejectedAmountFen: number
|
||||
noOrderCount: number
|
||||
noOrderAmountFen: number
|
||||
unclassifiedCount: number
|
||||
unclassifiedAmountFen: number
|
||||
}
|
||||
byPayer: Array<{ paidByName: string; recordCount: number; amountFen: number }>
|
||||
byCostItem: Array<{
|
||||
costItemId: number | null
|
||||
costItemName: string
|
||||
expenseCategory: string
|
||||
recordCount: number
|
||||
amountFen: number
|
||||
}>
|
||||
}
|
||||
Reference in New Issue
Block a user