Compare commits
8 Commits
9859abba7e
...
v3.4.9
| Author | SHA1 | Date | |
|---|---|---|---|
| a7d55b7293 | |||
| 7e3dc131ad | |||
| f09e00e16f | |||
| ff462bca9a | |||
| aa0e140fec | |||
| dc30932012 | |||
| f8d8e2d6d5 | |||
| 54fca99208 |
@@ -30,7 +30,6 @@ import ProductsPage from './pages/ProductsPage';
|
||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import StoreWithdrawalsPage from './pages/StoreWithdrawalsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||
@@ -97,13 +96,19 @@ export default function App() {
|
||||
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||
<Route path="/finance/store-withdrawals" element={<StoreWithdrawalsPage />} />
|
||||
<Route
|
||||
path="/finance/store-withdrawals"
|
||||
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
|
||||
/>
|
||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-withdrawals" element={<Navigate to="/finance/store-withdrawals" replace />} />
|
||||
<Route
|
||||
path="/store-withdrawals"
|
||||
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
|
||||
/>
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||
|
||||
@@ -74,7 +74,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '财务',
|
||||
children: [
|
||||
{ key: '/finance/store-bills', label: '门店账单' },
|
||||
{ key: '/finance/store-withdrawals', label: '门店提现审' },
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||
|
||||
@@ -721,7 +721,7 @@ export default function DashboardPage() {
|
||||
<Card
|
||||
loading={loading}
|
||||
title="待审门店提现"
|
||||
extra={<Link to="/finance/store-withdrawals">去处理</Link>}
|
||||
extra={<Link to="/finance/store-bills?kind=WITHDRAW">去处理</Link>}
|
||||
>
|
||||
<Statistic
|
||||
value={stats?.pendingStoreWithdrawals ?? 0}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; redeemNo: string; amount: number; settleAmount: number; createdAt: string;
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
@@ -21,6 +27,7 @@ export default function RedeemRecordsPage() {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.channel) qs.set('channel', filters.channel);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -30,6 +37,19 @@ export default function RedeemRecordsPage() {
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'channel',
|
||||
width: 110,
|
||||
render: (v: RedeemChannel | undefined) => {
|
||||
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
return (
|
||||
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
|
||||
{REDEEM_CHANNEL_LABELS[channel]}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||
@@ -37,12 +57,19 @@ export default function RedeemRecordsPage() {
|
||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -50,16 +77,65 @@ export default function RedeemRecordsPage() {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="redeemNo" label="核销号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="redeemNo" label="核销号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel" label="方式">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'SCAN', label: '扫码核销' },
|
||||
{ value: 'PHONE', label: '手机号核销' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">
|
||||
{
|
||||
REDEEM_CHANNEL_LABELS[
|
||||
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
||||
]
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
@@ -17,44 +19,61 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import {
|
||||
STORE_SETTLEMENT_KIND_LABELS,
|
||||
STORE_SETTLEMENT_STATUS_LABELS,
|
||||
storeSettlementStatusLabel,
|
||||
type StoreSettlementKind,
|
||||
type StoreSettlementStatus,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
kind: StoreSettlementKind;
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
status: string;
|
||||
paidAt?: string | null;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||
amount: number;
|
||||
status: StoreSettlementStatus;
|
||||
date: string;
|
||||
overdue?: boolean;
|
||||
redeemCount?: number | null;
|
||||
redeemAmount?: number | null;
|
||||
settlementRate?: number | null;
|
||||
payoutCount?: number | null;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string | null };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string; phone: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
PENDING_REVIEW: 'orange',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||
T1_BILL: 'blue',
|
||||
WITHDRAW: 'purple',
|
||||
};
|
||||
|
||||
export default function StoreBillsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
kind: initialKind,
|
||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||||
});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-bills',
|
||||
'/admin/store-settlements',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.kind) qs.set('kind', filters.kind);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
@@ -64,21 +83,38 @@ export default function StoreBillsPage() {
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [overdueSummary, setOverdueSummary] = useState<{
|
||||
pendingCount: number;
|
||||
overdueCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
kind: filters.kind || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
}, [filters.kind, filters.status, form]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setStores(res.items))
|
||||
.catch(() => {});
|
||||
void request<{ pendingCount: number; overdueCount: number }>(
|
||||
'/admin/store-withdrawals/overdue-summary',
|
||||
)
|
||||
.then(setOverdueSummary)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
@@ -102,9 +138,75 @@ export default function StoreBillsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
|
||||
function approveWithdraw(id: string) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '审核通过并标记已结算?',
|
||||
content: (
|
||||
<Input
|
||||
placeholder="打款凭证号(可选)"
|
||||
onChange={(e) => {
|
||||
paymentRef = e.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '通过并结算',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await request(`/admin/store-withdrawals/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
||||
});
|
||||
message.success('已通过并标记已结算');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
void request<{ pendingCount: number; overdueCount: number }>(
|
||||
'/admin/store-withdrawals/overdue-summary',
|
||||
)
|
||||
.then(setOverdueSummary)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rejectWithdraw(id: string) {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '驳回提现申请?',
|
||||
content: (
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写驳回理由"
|
||||
onChange={(e) => {
|
||||
reason = e.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '确认驳回',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (!reason.trim()) {
|
||||
message.error('请填写驳回理由');
|
||||
throw new Error('reason required');
|
||||
}
|
||||
await request(`/admin/store-withdrawals/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(row: Row) {
|
||||
const path =
|
||||
row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`;
|
||||
const d = await request<Record<string, unknown>>(path);
|
||||
setDetail(d);
|
||||
setDetailKind(row.kind);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
@@ -118,88 +220,134 @@ export default function StoreBillsPage() {
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
message.success(`已导出 ${result.count} 条 T+1 账单`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
|
||||
const selectedRows = (data?.items ?? []).filter(
|
||||
(r) =>
|
||||
selectedKeys.includes(`${r.kind}-${r.id}`) &&
|
||||
r.kind === 'T1_BILL' &&
|
||||
r.status === 'UNPAID',
|
||||
);
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
width: 110,
|
||||
render: (v) => String(v || '').slice(0, 10),
|
||||
title: '类型',
|
||||
dataIndex: 'kind',
|
||||
width: 100,
|
||||
render: (k: StoreSettlementKind) => (
|
||||
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'billNo',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space>
|
||||
<span>{v}</span>
|
||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
width: 160,
|
||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
|
||||
{
|
||||
title: '核销金额',
|
||||
dataIndex: 'redeemAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '结算比例',
|
||||
dataIndex: 'settlementRate',
|
||||
width: 90,
|
||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||
title: '笔数',
|
||||
width: 80,
|
||||
render: (_, row) =>
|
||||
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
|
||||
},
|
||||
{
|
||||
title: '应付金额',
|
||||
dataIndex: 'payoutAmount',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
width: 100,
|
||||
render: (s: string, row) => (
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
|
||||
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
{row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => approveWithdraw(row.id)}>
|
||||
通过
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => rejectWithdraw(row.id)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const withdrawDetailItems =
|
||||
(detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const storeAccount = detail?.storeAccount as
|
||||
| {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
门店对账单
|
||||
门店账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
||||
</Typography.Text>
|
||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||
<Typography.Text type="danger">
|
||||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="记录数" value={summary.count} />
|
||||
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.payoutAmount ?? summary.totalAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -208,16 +356,37 @@ export default function StoreBillsPage() {
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
|
||||
initialValues={{
|
||||
kind: filters.kind || undefined,
|
||||
status: filters.status || undefined,
|
||||
}}
|
||||
onFinish={(v: {
|
||||
kind?: string;
|
||||
storeId?: string;
|
||||
status?: string;
|
||||
range?: [Dayjs, Dayjs];
|
||||
}) => {
|
||||
setFilters({
|
||||
kind: v.kind || '',
|
||||
storeId: v.storeId || '',
|
||||
status: v.status || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
setSelectedKeys([]);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="kind" label="类型">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={Object.entries(STORE_SETTLEMENT_KIND_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -232,10 +401,16 @@ export default function StoreBillsPage() {
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
options={Object.entries(STORE_SETTLEMENT_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label:
|
||||
value === 'PAID'
|
||||
? '已打款/已结算'
|
||||
: label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="账单日">
|
||||
<Form.Item name="range" label="日期">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
@@ -249,6 +424,7 @@ export default function StoreBillsPage() {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
setSelectedKeys([]);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
@@ -256,33 +432,36 @@ export default function StoreBillsPage() {
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出 Excel
|
||||
导出 T+1 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedKeys.length}
|
||||
disabled={!selectedRows.length}
|
||||
loading={batchLoading}
|
||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedKeys.length})
|
||||
批量确认打款 ({selectedRows.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
rowKey={(r) => `${r.kind}-${r.id}`}
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
getCheckboxProps: (r) => ({
|
||||
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
||||
}),
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -295,14 +474,25 @@ export default function StoreBillsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Drawer
|
||||
title={detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细'}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={detailKind === 'WITHDRAW' ? 640 : 520}
|
||||
>
|
||||
{detail && detailKind === 'T1_BILL' && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">
|
||||
{String(detail.billDate || '').slice(0, 10)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">
|
||||
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
@@ -318,7 +508,8 @@ export default function StoreBillsPage() {
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
render: (_, r) =>
|
||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
@@ -334,6 +525,83 @@ export default function StoreBillsPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{detail && detailKind === 'WITHDRAW' && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
¥{Number(detail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{storeSettlementStatusLabel('WITHDRAW', String(detail.status))}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="申请时间">
|
||||
{detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回理由">
|
||||
{detail.rejectReason ? String(detail.rejectReason) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款凭证">
|
||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{storeAccount ? (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
收款账户
|
||||
</Typography.Title>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="户名">
|
||||
{storeAccount.bankAccountName || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账号">
|
||||
{storeAccount.bankAccountNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">
|
||||
{storeAccount.bankBranch || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
) : null}
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
提现明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={withdrawDetailItems}
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) => {
|
||||
const payout = r.storePayout as
|
||||
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
||||
| undefined;
|
||||
return String(payout?.redeemRecord?.redeemNo || '—');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '应付',
|
||||
render: (_, r) => {
|
||||
const payout = r.storePayout as { payoutAmount?: number } | undefined;
|
||||
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{String(detail.status) === 'PENDING_REVIEW' ? (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={() => approveWithdraw(String(detail.id))}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => rejectWithdraw(String(detail.id))}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -190,6 +190,9 @@ export default function HomePage() {
|
||||
<div className="shop-home-stat">
|
||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
||||
<p className="shop-home-stat-sub">
|
||||
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-home-stat">
|
||||
<p className="shop-home-stat-label">今日到账金额</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type RangeKey = 'today' | '7d' | '30d';
|
||||
@@ -23,27 +24,53 @@ function inRange(dateStr: string, range: RangeKey) {
|
||||
return d >= start;
|
||||
}
|
||||
|
||||
function channelLabel(channel: unknown): string {
|
||||
const key = channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
return REDEEM_CHANNEL_LABELS[key];
|
||||
}
|
||||
|
||||
export default function RecordsPage() {
|
||||
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [range, setRange] = useState<RangeKey>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [stats, setStats] = useState<RedeemStatsDto | null>(null);
|
||||
|
||||
const loadRecords = useCallback(() => {
|
||||
return Promise.all([
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
}),
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records?pageSize=200').then(
|
||||
(d) => {
|
||||
setRecords(d.list || []);
|
||||
},
|
||||
),
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const loadStats = useCallback(async (r: RangeKey) => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const data = await request<RedeemStatsDto>('SHOP_H5', `/shop/redeem/stats?range=${r}`);
|
||||
setStats(data);
|
||||
} catch {
|
||||
setStats(null);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statsOpen) void loadStats(range);
|
||||
}, [statsOpen, range, loadStats]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
@@ -71,11 +98,13 @@ export default function RecordsPage() {
|
||||
<div className="shop-records-main">
|
||||
<nav className="shop-records-filters">
|
||||
<div className="shop-records-range-tabs">
|
||||
{([
|
||||
['today', '今日'],
|
||||
['7d', '近7日'],
|
||||
['30d', '近30日'],
|
||||
] as const).map(([key, label]) => (
|
||||
{(
|
||||
[
|
||||
['today', '今日'],
|
||||
['7d', '近7日'],
|
||||
['30d', '近30日'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
@@ -86,21 +115,35 @@ export default function RecordsPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="shop-records-status-chips">
|
||||
{([
|
||||
['all', '全部'],
|
||||
['pending', '待打款'],
|
||||
['paid', '已打款'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="shop-records-status-row">
|
||||
<div className="shop-records-status-chips">
|
||||
{(
|
||||
[
|
||||
['all', '全部'],
|
||||
['pending', '待打款'],
|
||||
['paid', '已打款'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-records-stats-btn"
|
||||
onClick={() => setStatsOpen(true)}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
|
||||
bar_chart
|
||||
</span>
|
||||
统计
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -116,7 +159,10 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="shop-records-summary-note">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-success-green)' }}>
|
||||
<span
|
||||
className="material-symbols-outlined shop-fill-icon"
|
||||
style={{ fontSize: 16, color: 'var(--color-success-green)' }}
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
结算比例: {summary.rate}% (按{summary.rate / 10}折结算)
|
||||
@@ -138,16 +184,29 @@ export default function RecordsPage() {
|
||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||
const channel = (r.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel;
|
||||
return (
|
||||
<article key={String(r.id)} className="shop-record-card">
|
||||
<div className="shop-record-card-top">
|
||||
<div>
|
||||
<div className="shop-record-order">
|
||||
<span className="shop-record-time" style={{ margin: 0 }}>订单号</span>
|
||||
<span className="shop-record-time" style={{ margin: 0 }}>
|
||||
订单号
|
||||
</span>
|
||||
<span>{r.redeemNo ? String(r.redeemNo) : '—'}</span>
|
||||
</div>
|
||||
<p className="shop-record-time">
|
||||
核销时间: {new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
|
||||
核销时间:{' '}
|
||||
{new Date(String(r.createdAt))
|
||||
.toLocaleString('zh-CN', { hour12: false })
|
||||
.slice(0, 16)}
|
||||
</p>
|
||||
<p className="shop-record-channel">
|
||||
<span
|
||||
className={`shop-record-channel-tag${channel === 'PHONE' ? ' phone' : ''}`}
|
||||
>
|
||||
{channelLabel(channel)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
|
||||
@@ -172,7 +231,9 @@ export default function RecordsPage() {
|
||||
</p>
|
||||
{storeName && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>restaurant</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>
|
||||
restaurant
|
||||
</span>
|
||||
{storeName}
|
||||
</span>
|
||||
)}
|
||||
@@ -190,6 +251,80 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{statsOpen && (
|
||||
<div className="shop-stats-overlay" role="dialog" aria-modal="true" aria-label="核销方式统计">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-stats-backdrop"
|
||||
aria-label="关闭"
|
||||
onClick={() => setStatsOpen(false)}
|
||||
/>
|
||||
<div className="shop-stats-sheet">
|
||||
<div className="shop-stats-sheet-head">
|
||||
<h2>核销方式统计</h2>
|
||||
<button type="button" className="shop-stats-close" onClick={() => setStatsOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="shop-stats-range-hint">
|
||||
统计区间:{range === 'today' ? '今日' : range === '7d' ? '近7日' : '近30日'}(与上方筛选同步)
|
||||
</p>
|
||||
{statsLoading ? (
|
||||
<p className="shop-records-empty">加载中…</p>
|
||||
) : !stats ? (
|
||||
<p className="shop-records-empty">统计加载失败</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="shop-stats-total">
|
||||
<div>
|
||||
<p className="shop-records-summary-label">合计笔数</p>
|
||||
<p className="shop-stats-total-value">{stats.totalCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">核销总额</p>
|
||||
<p className="shop-stats-total-value">¥{formatMoney(stats.totalAmount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">到账总额</p>
|
||||
<p className="shop-stats-total-value">¥{formatMoney(stats.totalSettleAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-stats-channel-list">
|
||||
{stats.byChannel.map((b) => (
|
||||
<div key={b.channel} className="shop-stats-channel-card">
|
||||
<div className="shop-stats-channel-title">
|
||||
<span
|
||||
className={`shop-record-channel-tag${b.channel === 'PHONE' ? ' phone' : ''}`}
|
||||
>
|
||||
{REDEEM_CHANNEL_LABELS[b.channel]}
|
||||
</span>
|
||||
<strong>{b.count} 笔</strong>
|
||||
</div>
|
||||
<div className="shop-stats-channel-row">
|
||||
<span>核销额</span>
|
||||
<span>¥{formatMoney(b.amount)}</span>
|
||||
</div>
|
||||
<div className="shop-stats-channel-row">
|
||||
<span>到账额</span>
|
||||
<span>¥{formatMoney(b.settleAmount)}</span>
|
||||
</div>
|
||||
<div className="shop-stats-bar">
|
||||
<div
|
||||
className={`shop-stats-bar-fill${b.channel === 'PHONE' ? ' phone' : ''}`}
|
||||
style={{
|
||||
width: `${stats.totalCount > 0 ? Math.round((b.count / stats.totalCount) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
+167
-1
@@ -587,6 +587,12 @@
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.shop-home-stat-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.shop-home-scan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1549,18 +1555,42 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.shop-records-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.shop-records-status-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shop-records-status-chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shop-records-stats-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--color-primary);
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-records-chip {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
@@ -1578,6 +1608,142 @@
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.shop-record-channel {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.shop-record-channel-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.shop-record-channel-tag.phone {
|
||||
background: rgba(168, 85, 247, 0.12);
|
||||
color: #7e22ce;
|
||||
}
|
||||
|
||||
.shop-stats-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shop-stats-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.shop-stats-sheet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 78vh;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.shop-stats-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.shop-stats-sheet-head h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shop-stats-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-stats-range-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
.shop-stats-total {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-container-low, #f5f5f4);
|
||||
}
|
||||
|
||||
.shop-stats-total-value {
|
||||
margin: 4px 0 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shop-stats-channel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shop-stats-channel-card {
|
||||
border: 1px solid var(--color-surface-container-highest);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.shop-stats-channel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.shop-stats-channel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.shop-stats-bar {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-container-highest);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shop-stats-bar-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.shop-stats-bar-fill.phone {
|
||||
background: #a855f7;
|
||||
}
|
||||
|
||||
.shop-records-main {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReady } from '@tarojs/taro';
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
type StoreRedeemMarqueeProps = {
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
/** 飘动速度 px/s */
|
||||
const FLY_SPEED = 58;
|
||||
const MIN_FLY_MS = 2200;
|
||||
const FLY_SPEED = 56;
|
||||
const MIN_FLY_MS = 2400;
|
||||
const PAUSE_MIN_MS = 1000;
|
||||
const PAUSE_MAX_MS = 5000;
|
||||
const TICK_MS = 16;
|
||||
/** 全文滚出视口后,再向左多走 10px */
|
||||
const EXTRA_AFTER_EXIT_PX = 10;
|
||||
|
||||
function estimateTextWidth(text: string): number {
|
||||
let w = 0;
|
||||
@@ -24,18 +26,67 @@ function randomPauseMs() {
|
||||
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
|
||||
}
|
||||
|
||||
function uid(prefix: string) {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 10)}`;
|
||||
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
|
||||
function getBoxWidthFallback(): number {
|
||||
try {
|
||||
const sys = Taro.getSystemInfoSync();
|
||||
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
|
||||
// 与 section 同宽:左右 var(--space-page)
|
||||
return Math.max(220, Math.floor(screenW - 32));
|
||||
} catch {
|
||||
return 300;
|
||||
}
|
||||
}
|
||||
|
||||
function createQuery() {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
Taro.nextTick(() => {
|
||||
try {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
.select(selector)
|
||||
.boundingClientRect()
|
||||
.exec((res) => {
|
||||
const w = Number(res?.[0]?.width || 0);
|
||||
resolve(w > 8 ? Math.ceil(w) : fallback);
|
||||
});
|
||||
} catch {
|
||||
resolve(fallback);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function measureTextWidth(selector: string, text: string): Promise<number> {
|
||||
const fallback = estimateTextWidth(text);
|
||||
return new Promise((resolve) => {
|
||||
Taro.nextTick(() => {
|
||||
try {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
.select(selector)
|
||||
.boundingClientRect()
|
||||
.exec((res) => {
|
||||
const w = Number(res?.[0]?.width || 0);
|
||||
if (w > 8 && w < fallback * 3) resolve(Math.ceil(w));
|
||||
else resolve(fallback);
|
||||
});
|
||||
} catch {
|
||||
resolve(fallback);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
|
||||
* H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。
|
||||
*
|
||||
* 小程序注意:
|
||||
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
|
||||
* - 不用 Text + transform(支持差),改用 View + left
|
||||
* - 字宽用估算,避免屏外元素测宽失败
|
||||
*/
|
||||
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||
const items = useMemo(
|
||||
@@ -46,64 +97,20 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||
[lines],
|
||||
);
|
||||
|
||||
const rootIdRef = useRef(uid('smr'));
|
||||
const textIdRef = useRef(uid('smt'));
|
||||
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
|
||||
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
|
||||
const indexRef = useRef(0);
|
||||
const boxWidthRef = useRef(0);
|
||||
const boxWidthRef = useRef(getBoxWidthFallback());
|
||||
const itemsKey = items.join('\n');
|
||||
|
||||
const [displayIndex, setDisplayIndex] = useState(0);
|
||||
const [offset, setOffset] = useState(9999);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const measureBox = () =>
|
||||
new Promise<number>((resolve) => {
|
||||
Taro.nextTick(() => {
|
||||
try {
|
||||
createQuery()
|
||||
.select(`#${rootIdRef.current}`)
|
||||
.boundingClientRect()
|
||||
.exec((res) => {
|
||||
const box = Number(res?.[0]?.width || 0);
|
||||
const next = box > 8 ? box : boxWidthRef.current || 300;
|
||||
boxWidthRef.current = next;
|
||||
resolve(next);
|
||||
});
|
||||
} catch {
|
||||
resolve(boxWidthRef.current || 300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const measureText = (text: string) =>
|
||||
new Promise<number>((resolve) => {
|
||||
const fallback = estimateTextWidth(text);
|
||||
Taro.nextTick(() => {
|
||||
try {
|
||||
createQuery()
|
||||
.select(`#${textIdRef.current}`)
|
||||
.boundingClientRect()
|
||||
.exec((res) => {
|
||||
const tw = Number(res?.[0]?.width || 0);
|
||||
if (tw > 8 && tw < fallback * 3) resolve(Math.ceil(tw));
|
||||
else resolve(fallback);
|
||||
});
|
||||
} catch {
|
||||
resolve(fallback);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
useReady(() => {
|
||||
void measureBox().then(() => setReady(true));
|
||||
});
|
||||
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
const waiters = new Set<ReturnType<typeof setTimeout>>();
|
||||
let rafId = 0;
|
||||
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
@@ -115,78 +122,65 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||
waiters.add(id);
|
||||
});
|
||||
|
||||
const clearAnim = () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
const clearTick = () => {
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fly = (start: number, end: number, durationMs: number) =>
|
||||
const fly = (from: number, to: number, durationMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const began = Date.now();
|
||||
setOffset(start);
|
||||
|
||||
const step = () => {
|
||||
setLeftPx(from);
|
||||
clearTick();
|
||||
tickTimer = setInterval(() => {
|
||||
if (cancelled) {
|
||||
clearAnim();
|
||||
clearTick();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const t = Math.min(1, (Date.now() - began) / durationMs);
|
||||
setOffset(start + (end - start) * t);
|
||||
setLeftPx(from + (to - from) * t);
|
||||
if (t >= 1) {
|
||||
clearAnim();
|
||||
clearTick();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
rafId = requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
clearAnim();
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
rafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
tickTimer = setInterval(step, 32);
|
||||
}
|
||||
}, TICK_MS);
|
||||
});
|
||||
|
||||
const loop = async () => {
|
||||
indexRef.current = 0;
|
||||
setDisplayIndex(0);
|
||||
await sleep(60);
|
||||
|
||||
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
|
||||
boxWidthRef.current = measured;
|
||||
if (cancelled) return;
|
||||
|
||||
while (!cancelled) {
|
||||
if (!items.length) break;
|
||||
|
||||
while (!cancelled && items.length) {
|
||||
const idx = indexRef.current % items.length;
|
||||
const text = items[idx];
|
||||
const box = boxWidthRef.current;
|
||||
|
||||
setDisplayIndex(idx);
|
||||
setOffset(9999);
|
||||
|
||||
const from = box;
|
||||
setLeftPx(from);
|
||||
await sleep(48);
|
||||
if (cancelled) break;
|
||||
|
||||
const box = await measureBox();
|
||||
const textW = await measureText(text);
|
||||
if (cancelled) break;
|
||||
|
||||
const start = box;
|
||||
const end = -textW;
|
||||
const distance = start - end;
|
||||
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
|
||||
// 全文 left 边缘移出容器左边界后再走 10px
|
||||
const to = -(textW + EXTRA_AFTER_EXIT_PX);
|
||||
const distance = from - to;
|
||||
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
|
||||
|
||||
await fly(start, end, durationMs);
|
||||
await sleep(32);
|
||||
if (cancelled) break;
|
||||
|
||||
await fly(from, to, durationMs);
|
||||
if (cancelled) break;
|
||||
|
||||
// 飞出后随机停留 1~5 秒,再播下一条
|
||||
await sleep(randomPauseMs());
|
||||
if (cancelled) break;
|
||||
|
||||
@@ -198,7 +192,7 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearAnim();
|
||||
clearTick();
|
||||
waiters.forEach(clearTimeout);
|
||||
waiters.clear();
|
||||
};
|
||||
@@ -207,17 +201,15 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||
if (!items.length) return null;
|
||||
|
||||
const current = items[displayIndex] || items[0];
|
||||
const textStyle: CSSProperties = {
|
||||
transform: `translateX(${offset}px)`,
|
||||
WebkitTransform: `translateX(${offset}px)`,
|
||||
opacity: ready ? 1 : 0,
|
||||
};
|
||||
const innerStyle: CSSProperties = { left: `${leftPx}px` };
|
||||
|
||||
return (
|
||||
<View id={rootIdRef.current} className="store-detail-marquee">
|
||||
<Text id={textIdRef.current} className="store-detail-marquee-text" style={textStyle}>
|
||||
{current}
|
||||
</Text>
|
||||
<View className="store-detail-marquee-inner" style={innerStyle}>
|
||||
<Text id={textIdRef.current} className="store-detail-marquee-text">
|
||||
{current}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,8 +342,6 @@ export default function StoreDetailPage() {
|
||||
<View className="store-detail-info-card">
|
||||
<Text className="store-detail-name">{store.name}</Text>
|
||||
|
||||
{marqueeLines.length > 0 ? <StoreRedeemMarquee lines={marqueeLines} /> : null}
|
||||
|
||||
<View className="store-detail-row">
|
||||
<Text className="store-detail-meta store-detail-meta--flex">
|
||||
{store.district ? `${store.district} · ` : ''}
|
||||
@@ -385,6 +383,12 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{marqueeLines.length > 0 ? (
|
||||
<View className="store-detail-marquee-wrap">
|
||||
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{intro ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
|
||||
@@ -127,9 +127,13 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.store-detail-marquee-wrap {
|
||||
margin: 0 var(--space-page) 12px;
|
||||
}
|
||||
|
||||
.store-detail-marquee {
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background: #fff7f6;
|
||||
border: 1px solid rgba(166, 29, 36, 0.12);
|
||||
@@ -137,22 +141,20 @@
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-detail-marquee-inner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-marquee-text {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
margin-top: -10px;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #a61d24;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.store-detail-section {
|
||||
|
||||
@@ -19,14 +19,39 @@ export interface RedeemPreviewDto {
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
/** 门店核销方式 */
|
||||
export type RedeemChannel = 'SCAN' | 'PHONE';
|
||||
|
||||
export const REDEEM_CHANNEL_LABELS: Record<RedeemChannel, string> = {
|
||||
SCAN: '扫码核销',
|
||||
PHONE: '手机号核销',
|
||||
};
|
||||
|
||||
export interface RedeemRecordDto {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
/** 核销方式:扫码 SCAN / 手机号 PHONE */
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RedeemChannelStatsBucket {
|
||||
channel: RedeemChannel;
|
||||
count: number;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
}
|
||||
|
||||
export interface RedeemStatsDto {
|
||||
range: 'today' | '7d' | '30d';
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
totalSettleAmount: number;
|
||||
byChannel: RedeemChannelStatsBucket[];
|
||||
}
|
||||
|
||||
export interface RedeemPhoneBalanceDto {
|
||||
sessionId: string;
|
||||
totalBalance: number;
|
||||
|
||||
@@ -11,6 +11,44 @@ export const STORE_WITHDRAW_STATUS_LABELS: Record<StoreWithdrawStatus, string> =
|
||||
PAID: '已结算',
|
||||
};
|
||||
|
||||
/** 总部「门店账单」统一列表:T+1 出账 / 手动提现 */
|
||||
export type StoreSettlementKind = 'T1_BILL' | 'WITHDRAW';
|
||||
|
||||
export const STORE_SETTLEMENT_KIND_LABELS: Record<StoreSettlementKind, string> = {
|
||||
T1_BILL: 'T+1出账',
|
||||
WITHDRAW: '手动提现',
|
||||
};
|
||||
|
||||
export type StoreSettlementStatus = FinancePayStatus | StoreWithdrawStatus;
|
||||
|
||||
export const STORE_SETTLEMENT_STATUS_LABELS: Record<StoreSettlementStatus, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
PENDING_REVIEW: '待审核',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export interface StoreSettlementRowDto {
|
||||
kind: StoreSettlementKind;
|
||||
id: string;
|
||||
billNo: string;
|
||||
storeId: string;
|
||||
amount: number;
|
||||
status: StoreSettlementStatus;
|
||||
date: string;
|
||||
overdue?: boolean;
|
||||
redeemCount?: number | null;
|
||||
redeemAmount?: number | null;
|
||||
settlementRate?: number | null;
|
||||
payoutCount?: number | null;
|
||||
store?: {
|
||||
id: string;
|
||||
name: string;
|
||||
cityName: string;
|
||||
phone?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoreWithdrawBankAccountDto {
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
@@ -73,6 +111,19 @@ export const FINANCE_PAY_STATUS_LABELS: Record<FinancePayStatus, string> = {
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
export function storeSettlementStatusLabel(
|
||||
kind: StoreSettlementKind,
|
||||
status: string,
|
||||
): string {
|
||||
if (kind === 'WITHDRAW' && status in STORE_WITHDRAW_STATUS_LABELS) {
|
||||
return STORE_WITHDRAW_STATUS_LABELS[status as StoreWithdrawStatus];
|
||||
}
|
||||
if (status in FINANCE_PAY_STATUS_LABELS) {
|
||||
return FINANCE_PAY_STATUS_LABELS[status as FinancePayStatus];
|
||||
}
|
||||
return STORE_SETTLEMENT_STATUS_LABELS[status as StoreSettlementStatus] ?? status;
|
||||
}
|
||||
|
||||
export interface PartnerBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
|
||||
@@ -52,6 +52,12 @@ enum RedeemPendingStatus {
|
||||
REJECTED
|
||||
}
|
||||
|
||||
/// Redeem channel: SCAN or PHONE
|
||||
enum RedeemChannel {
|
||||
SCAN
|
||||
PHONE
|
||||
}
|
||||
|
||||
enum ResourceMediaType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
@@ -1318,14 +1324,16 @@ model BenefitCoupon {
|
||||
}
|
||||
|
||||
model RedeemRecord {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
/// SCAN=qrcode, PHONE=phone
|
||||
channel RedeemChannel @default(SCAN)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||
@@ -1336,6 +1344,7 @@ model RedeemRecord {
|
||||
allocations RedeemRecordAllocation[]
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@index([storeId, channel, createdAt])
|
||||
@@map("user_redeem_record")
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export class AdminRedeemService {
|
||||
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
|
||||
where.channel = query.channel;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
|
||||
@@ -242,6 +242,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
/** SCAN | PHONE */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -96,6 +96,16 @@ export class ShopRedeemController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
stats(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('range') range?: string,
|
||||
) {
|
||||
const normalized =
|
||||
range === '7d' || range === '30d' || range === 'today' ? range : 'today';
|
||||
return this.redeemService.getShopRedeemStats(user.actorId, user.storeId!, normalized);
|
||||
}
|
||||
|
||||
@Post('phone/send-lookup-sms')
|
||||
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
||||
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
||||
|
||||
@@ -178,6 +178,7 @@ export class RedeemService {
|
||||
) {
|
||||
const settlementRate = Number(account.store.settlementRate);
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||
|
||||
let record;
|
||||
try {
|
||||
@@ -192,6 +193,7 @@ export class RedeemService {
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
channel: redeemChannel,
|
||||
allocations: {
|
||||
create: normalizedAllocations.map((item, index) => ({
|
||||
couponId: BigInt(item.couponId),
|
||||
@@ -1079,6 +1081,51 @@ export class RedeemService {
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getShopRedeemStats(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
range: 'today' | '7d' | '30d' = 'today',
|
||||
) {
|
||||
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
});
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
if (range === '7d') start.setDate(start.getDate() - 6);
|
||||
if (range === '30d') start.setDate(start.getDate() - 29);
|
||||
|
||||
const records = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId, createdAt: { gte: start } },
|
||||
select: { channel: true, amount: true, settleAmount: true },
|
||||
});
|
||||
|
||||
const buckets: Record<'SCAN' | 'PHONE', { count: number; amount: number; settleAmount: number }> = {
|
||||
SCAN: { count: 0, amount: 0, settleAmount: 0 },
|
||||
PHONE: { count: 0, amount: 0, settleAmount: 0 },
|
||||
};
|
||||
for (const r of records) {
|
||||
const key = r.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
buckets[key].count += 1;
|
||||
buckets[key].amount += Number(r.amount);
|
||||
buckets[key].settleAmount += Number(r.settleAmount);
|
||||
}
|
||||
|
||||
const byChannel = (['SCAN', 'PHONE'] as const).map((channel) => ({
|
||||
channel,
|
||||
count: buckets[channel].count,
|
||||
amount: Number(buckets[channel].amount.toFixed(2)),
|
||||
settleAmount: Number(buckets[channel].settleAmount.toFixed(2)),
|
||||
}));
|
||||
|
||||
return {
|
||||
range,
|
||||
totalCount: records.length,
|
||||
totalAmount: Number(byChannel.reduce((s, b) => s + b.amount, 0).toFixed(2)),
|
||||
totalSettleAmount: Number(byChannel.reduce((s, b) => s + b.settleAmount, 0).toFixed(2)),
|
||||
byChannel,
|
||||
};
|
||||
}
|
||||
|
||||
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
@@ -1091,6 +1138,8 @@ export class RedeemService {
|
||||
});
|
||||
const todayCount = records.length;
|
||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const todayScanCount = records.filter((r) => r.channel !== 'PHONE').length;
|
||||
const todayPhoneCount = records.filter((r) => r.channel === 'PHONE').length;
|
||||
const recent = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -1100,6 +1149,8 @@ export class RedeemService {
|
||||
store: binding.store,
|
||||
todayCount,
|
||||
todayAmount,
|
||||
todayScanCount,
|
||||
todayPhoneCount,
|
||||
recentRecords: recent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,6 +224,25 @@ export class AdminStorePayoutController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-settlements')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreSettlementController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminStoreSettlements({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
kind: query.kind,
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-bills')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreBillController {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AdminPartnerBillController,
|
||||
AdminStoreBillController,
|
||||
AdminStorePayoutController,
|
||||
AdminStoreSettlementController,
|
||||
AdminStoreWithdrawController,
|
||||
AdminWineryBillController,
|
||||
PartnerMeController,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
ShopWithdrawController,
|
||||
AdminStoreWithdrawController,
|
||||
AdminStorePayoutController,
|
||||
AdminStoreSettlementController,
|
||||
AdminStoreBillController,
|
||||
AdminPartnerBillController,
|
||||
AdminWineryBillController,
|
||||
|
||||
@@ -339,7 +339,7 @@ export class SettlementService {
|
||||
`单号:${created.withdrawNo}`,
|
||||
`金额:¥${Number(created.amount).toFixed(2)}`,
|
||||
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
|
||||
'请尽快在 HQ「财务 → 门店提现审」处理。',
|
||||
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
|
||||
].join('\n'),
|
||||
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
|
||||
dedupeTtlSec: 3600,
|
||||
@@ -594,7 +594,7 @@ export class SettlementService {
|
||||
detail: [
|
||||
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
||||
...lines,
|
||||
'请尽快在 HQ「财务 → 门店提现审」处理。',
|
||||
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
|
||||
].join('\n'),
|
||||
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
|
||||
dedupeTtlSec: 6 * 3600,
|
||||
@@ -857,6 +857,152 @@ export class SettlementService {
|
||||
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 总部「门店账单」统一列表:T+1 StoreBill + 手动提现 StoreWithdrawRequest
|
||||
* 中小数据量内存合并后分页。
|
||||
*/
|
||||
async listAdminStoreSettlements(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const kind = query.kind === 'T1_BILL' || query.kind === 'WITHDRAW' ? query.kind : undefined;
|
||||
const status = query.status?.trim() || undefined;
|
||||
|
||||
const includeBills =
|
||||
!kind || kind === 'T1_BILL'
|
||||
? !status || status === 'UNPAID' || status === 'PAID'
|
||||
: false;
|
||||
const includeWithdraws =
|
||||
!kind || kind === 'WITHDRAW'
|
||||
? !status ||
|
||||
status === 'PENDING_REVIEW' ||
|
||||
status === 'REJECTED' ||
|
||||
status === 'PAID'
|
||||
: false;
|
||||
|
||||
const billWhere = includeBills
|
||||
? this.buildStoreBillWhere({
|
||||
status: status === 'UNPAID' || status === 'PAID' ? status : undefined,
|
||||
storeId: query.storeId,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
})
|
||||
: null;
|
||||
|
||||
const withdrawWhere: Prisma.StoreWithdrawRequestWhereInput | null = includeWithdraws
|
||||
? (() => {
|
||||
const where: Prisma.StoreWithdrawRequestWhereInput = {};
|
||||
if (status === 'PENDING_REVIEW' || status === 'REJECTED' || status === 'PAID') {
|
||||
where.status = status;
|
||||
}
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.dateFrom || query.dateTo) {
|
||||
where.appliedAt = {};
|
||||
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
|
||||
if (query.dateTo) {
|
||||
const end = new Date(query.dateTo);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
where.appliedAt.lte = end;
|
||||
}
|
||||
}
|
||||
return where;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const bills = billWhere
|
||||
? await this.prisma.storeBill.findMany({
|
||||
where: billWhere,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const withdraws = withdrawWhere
|
||||
? await this.prisma.storeWithdrawRequest.findMany({
|
||||
where: withdrawWhere,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const now = new Date();
|
||||
type UnifiedRow = {
|
||||
kind: 'T1_BILL' | 'WITHDRAW';
|
||||
id: bigint;
|
||||
billNo: string;
|
||||
storeId: bigint;
|
||||
amount: number;
|
||||
status: string;
|
||||
date: Date;
|
||||
overdue?: boolean;
|
||||
redeemCount?: number;
|
||||
redeemAmount?: number;
|
||||
settlementRate?: number;
|
||||
payoutCount?: number;
|
||||
store?: { id: bigint; name: string; cityName: string; phone: string | null };
|
||||
};
|
||||
|
||||
const rows: UnifiedRow[] = [
|
||||
...bills.map((b) => ({
|
||||
kind: 'T1_BILL' as const,
|
||||
id: b.id,
|
||||
billNo: b.billNo,
|
||||
storeId: b.storeId,
|
||||
amount: Number(b.payoutAmount),
|
||||
status: b.status,
|
||||
date: b.billDate,
|
||||
redeemCount: b.redeemCount,
|
||||
redeemAmount: Number(b.redeemAmount),
|
||||
settlementRate: Number(b.settlementRate),
|
||||
store: b.store,
|
||||
})),
|
||||
...withdraws.map((w) => ({
|
||||
kind: 'WITHDRAW' as const,
|
||||
id: w.id,
|
||||
billNo: w.withdrawNo,
|
||||
storeId: w.storeId,
|
||||
amount: Number(w.amount),
|
||||
status: w.status,
|
||||
date: w.appliedAt,
|
||||
overdue: w.status === 'PENDING_REVIEW' ? isWithdrawOverdue(w.appliedAt, now) : false,
|
||||
payoutCount: w.payoutCount,
|
||||
store: w.store,
|
||||
})),
|
||||
];
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const dt = b.date.getTime() - a.date.getTime();
|
||||
if (dt !== 0) return dt;
|
||||
return Number(b.id - a.id);
|
||||
});
|
||||
|
||||
const total = rows.length;
|
||||
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
|
||||
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
|
||||
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
return serializeBigInt({
|
||||
items: slice,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
count: total,
|
||||
redeemAmount,
|
||||
payoutAmount,
|
||||
totalAmount: payoutAmount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listAdminStoreBills(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
||||
Reference in New Issue
Block a user