Compare commits
10 Commits
c89560e4f8
...
v3.4.9
| Author | SHA1 | Date | |
|---|---|---|---|
| a7d55b7293 | |||
| 7e3dc131ad | |||
| f09e00e16f | |||
| ff462bca9a | |||
| aa0e140fec | |||
| dc30932012 | |||
| f8d8e2d6d5 | |||
| 54fca99208 | |||
| 9859abba7e | |||
| 26472f8678 |
@@ -30,7 +30,6 @@ import ProductsPage from './pages/ProductsPage';
|
|||||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||||
import ResourcesPage from './pages/ResourcesPage';
|
import ResourcesPage from './pages/ResourcesPage';
|
||||||
import StoreBillsPage from './pages/StoreBillsPage';
|
import StoreBillsPage from './pages/StoreBillsPage';
|
||||||
import StoreWithdrawalsPage from './pages/StoreWithdrawalsPage';
|
|
||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import WineryBillsPage from './pages/WineryBillsPage';
|
import WineryBillsPage from './pages/WineryBillsPage';
|
||||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||||
@@ -97,13 +96,19 @@ export default function App() {
|
|||||||
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
<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/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
<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-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="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '财务',
|
label: '财务',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/finance/store-bills', label: '门店账单' },
|
{ key: '/finance/store-bills', label: '门店账单' },
|
||||||
{ key: '/finance/store-withdrawals', label: '门店提现审' },
|
|
||||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export type StoreCreateForm = {
|
|||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
withdrawWhitelistEnabled?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PHONE_RE = /^1\d{10}$/;
|
const PHONE_RE = /^1\d{10}$/;
|
||||||
|
|||||||
@@ -721,7 +721,7 @@ export default function DashboardPage() {
|
|||||||
<Card
|
<Card
|
||||||
loading={loading}
|
loading={loading}
|
||||||
title="待审门店提现"
|
title="待审门店提现"
|
||||||
extra={<Link to="/finance/store-withdrawals">去处理</Link>}
|
extra={<Link to="/finance/store-bills?kind=WITHDRAW">去处理</Link>}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={stats?.pendingStoreWithdrawals ?? 0}
|
value={stats?.pendingStoreWithdrawals ?? 0}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useState } from 'react';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
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 };
|
user?: { userNo: string; phone: string | null };
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { couponNo: string };
|
||||||
@@ -21,6 +27,7 @@ export default function RedeemRecordsPage() {
|
|||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
if (filters.channel) qs.set('channel', filters.channel);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -30,6 +37,19 @@ export default function RedeemRecordsPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
{ 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: ['user', 'userNo'], width: 110 },
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
@@ -37,12 +57,19 @@ export default function RedeemRecordsPage() {
|
|||||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button
|
||||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
type="link"
|
||||||
setDrawerOpen(true);
|
size="small"
|
||||||
}}>详情</Button>
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -50,16 +77,65 @@ export default function RedeemRecordsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form
|
||||||
<Form.Item name="redeemNo" label="核销号"><Input allowClear /></Form.Item>
|
form={form}
|
||||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
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>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
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)}>
|
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
<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.amount)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -17,44 +19,61 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Dayjs } from 'dayjs';
|
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 { request, type Paginated } from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
|
kind: StoreSettlementKind;
|
||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
billDate: string;
|
amount: number;
|
||||||
redeemCount: number;
|
status: StoreSettlementStatus;
|
||||||
redeemAmount: number;
|
date: string;
|
||||||
settlementRate: number;
|
overdue?: boolean;
|
||||||
payoutAmount: number;
|
redeemCount?: number | null;
|
||||||
status: string;
|
redeemAmount?: number | null;
|
||||||
paidAt?: string | null;
|
settlementRate?: number | null;
|
||||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
payoutCount?: number | null;
|
||||||
|
store?: { id: string; name: string; cityName: string; phone?: string | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreOption = { id: string; name: string; phone: string };
|
type StoreOption = { id: string; name: string; phone: string };
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
UNPAID: '未打款',
|
|
||||||
PAID: '已打款',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
UNPAID: 'red',
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
|
PENDING_REVIEW: 'orange',
|
||||||
|
REJECTED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||||
|
T1_BILL: 'blue',
|
||||||
|
WITHDRAW: 'purple',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StoreBillsPage() {
|
export default function StoreBillsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||||
const [form] = Form.useForm();
|
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 [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-bills',
|
'/admin/store-settlements',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.kind) qs.set('kind', filters.kind);
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
@@ -64,21 +83,38 @@ export default function StoreBillsPage() {
|
|||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
.then((res) => setStores(res.items))
|
.then((res) => setStores(res.items))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
void request<{ pendingCount: number; overdueCount: number }>(
|
||||||
|
'/admin/store-withdrawals/overdue-summary',
|
||||||
|
)
|
||||||
|
.then(setOverdueSummary)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function confirmPay(ids: string[], amountHint?: number) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认打款?',
|
title: '确认打款?',
|
||||||
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||||
okText: '确认打款',
|
okText: '确认打款',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
@@ -102,9 +138,75 @@ export default function StoreBillsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
function approveWithdraw(id: string) {
|
||||||
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
|
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);
|
setDetail(d);
|
||||||
|
setDetailKind(row.kind);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,88 +220,134 @@ export default function StoreBillsPage() {
|
|||||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||||
message.success(`已导出 ${result.count} 条`);
|
message.success(`已导出 ${result.count} 条 T+1 账单`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter(
|
||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
|
(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> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
|
||||||
{
|
{
|
||||||
title: '账单日',
|
title: '类型',
|
||||||
dataIndex: 'billDate',
|
dataIndex: 'kind',
|
||||||
width: 110,
|
width: 100,
|
||||||
render: (v) => String(v || '').slice(0, 10),
|
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', 'name'], width: 140, ellipsis: true },
|
||||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||||
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
|
|
||||||
{
|
{
|
||||||
title: '核销金额',
|
title: '笔数',
|
||||||
dataIndex: 'redeemAmount',
|
width: 80,
|
||||||
width: 110,
|
render: (_, row) =>
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '结算比例',
|
|
||||||
dataIndex: 'settlementRate',
|
|
||||||
width: 90,
|
|
||||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '应付金额',
|
title: '应付金额',
|
||||||
dataIndex: 'payoutAmount',
|
dataIndex: 'amount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s: string, row) => (
|
||||||
|
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 160,
|
width: 200,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
明细
|
明细
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'UNPAID' && (
|
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
|
||||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
|
||||||
确认打款
|
确认打款
|
||||||
</Button>
|
</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>
|
</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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
门店对账单
|
门店账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色
|
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||||
|
<Typography.Text type="danger">
|
||||||
|
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space size="large" wrap>
|
<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.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>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -208,16 +356,37 @@ export default function StoreBillsPage() {
|
|||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
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({
|
setFilters({
|
||||||
|
kind: v.kind || '',
|
||||||
storeId: v.storeId || '',
|
storeId: v.storeId || '',
|
||||||
status: v.status || '',
|
status: v.status || '',
|
||||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||||
});
|
});
|
||||||
setPage(1);
|
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="门店">
|
<Form.Item name="storeId" label="门店">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
@@ -232,10 +401,16 @@ export default function StoreBillsPage() {
|
|||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
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>
|
||||||
<Form.Item name="range" label="账单日">
|
<Form.Item name="range" label="日期">
|
||||||
<DatePicker.RangePicker />
|
<DatePicker.RangePicker />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
@@ -249,6 +424,7 @@ export default function StoreBillsPage() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setSelectedKeys([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
@@ -256,33 +432,36 @@ export default function StoreBillsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||||
导出 Excel
|
导出 T+1 Excel
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={!selectedKeys.length}
|
disabled={!selectedRows.length}
|
||||||
loading={batchLoading}
|
loading={batchLoading}
|
||||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
|
||||||
>
|
>
|
||||||
批量确认打款 ({selectedKeys.length})
|
批量确认打款 ({selectedRows.length})
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey={(r) => `${r.kind}-${r.id}`}
|
||||||
className="admin-table-nowrap"
|
className="admin-table-nowrap"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
|
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: setSelectedKeys,
|
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={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -295,14 +474,25 @@ export default function StoreBillsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
<Drawer
|
||||||
{detail && (
|
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 column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
<Descriptions.Item label="账单日">
|
||||||
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
{String(detail.billDate || '').slice(0, 10)}
|
||||||
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
</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="打款时间">
|
<Descriptions.Item label="打款时间">
|
||||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -318,7 +508,8 @@ export default function StoreBillsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '核销单号',
|
title: '核销单号',
|
||||||
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
render: (_, r) =>
|
||||||
|
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '金额',
|
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>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -373,7 +373,6 @@ type StoreRow = {
|
|||||||
coverUrl: string | null;
|
coverUrl: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
withdrawWhitelistEnabled?: boolean;
|
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { companyName: string };
|
partner?: { companyName: string };
|
||||||
@@ -593,7 +592,6 @@ export default function StoresPage() {
|
|||||||
bankAccountNo: account?.bankAccountNo || undefined,
|
bankAccountNo: account?.bankAccountNo || undefined,
|
||||||
bankBranch: account?.bankBranch || undefined,
|
bankBranch: account?.bankBranch || undefined,
|
||||||
visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled,
|
||||||
withdrawWhitelistEnabled: !!d.withdrawWhitelistEnabled,
|
|
||||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||||
? (d.visibilityPhones as string[])
|
? (d.visibilityPhones as string[])
|
||||||
: [],
|
: [],
|
||||||
@@ -637,7 +635,6 @@ export default function StoresPage() {
|
|||||||
bankAccountNo: v.bankAccountNo ?? null,
|
bankAccountNo: v.bankAccountNo ?? null,
|
||||||
bankBranch: v.bankBranch ?? null,
|
bankBranch: v.bankBranch ?? null,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
withdrawWhitelistEnabled: !!v.withdrawWhitelistEnabled,
|
|
||||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
@@ -814,7 +811,6 @@ export default function StoresPage() {
|
|||||||
bankBranch: values.bankBranch.trim(),
|
bankBranch: values.bankBranch.trim(),
|
||||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||||
withdrawWhitelistEnabled: !!values.withdrawWhitelistEnabled,
|
|
||||||
visibilityPhones: (values.visibilityPhones ?? [])
|
visibilityPhones: (values.visibilityPhones ?? [])
|
||||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
@@ -1184,14 +1180,6 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
name="withdrawWhitelistEnabled"
|
|
||||||
label="未出账提现白名单"
|
|
||||||
valuePropName="checked"
|
|
||||||
extra="FIN-001:开启后该门店可对未出账余额发起手动提现"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="允许" unCheckedChildren="关闭" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="bankAccountName" label="结算户名">
|
<Form.Item name="bankAccountName" label="结算户名">
|
||||||
<Input placeholder="开户名" />
|
<Input placeholder="开户名" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -1438,14 +1426,6 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
|
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
|
||||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
name="withdrawWhitelistEnabled"
|
|
||||||
label="未出账提现白名单"
|
|
||||||
valuePropName="checked"
|
|
||||||
initialValue={false}
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="允许" unCheckedChildren="关闭" />
|
|
||||||
</Form.Item>
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ export default function HomePage() {
|
|||||||
<div className="shop-home-stat">
|
<div className="shop-home-stat">
|
||||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</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>
|
||||||
<div className="shop-home-stat">
|
<div className="shop-home-stat">
|
||||||
<p className="shop-home-stat-label">今日到账金额</p>
|
<p className="shop-home-stat-label">今日到账金额</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type RangeKey = 'today' | '7d' | '30d';
|
type RangeKey = 'today' | '7d' | '30d';
|
||||||
@@ -23,27 +24,53 @@ function inRange(dateStr: string, range: RangeKey) {
|
|||||||
return d >= start;
|
return d >= start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: unknown): string {
|
||||||
|
const key = channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||||
|
return REDEEM_CHANNEL_LABELS[key];
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecordsPage() {
|
export default function RecordsPage() {
|
||||||
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [range, setRange] = useState<RangeKey>('today');
|
const [range, setRange] = useState<RangeKey>('today');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<RedeemStatsDto | null>(null);
|
||||||
|
|
||||||
const loadRecords = useCallback(() => {
|
const loadRecords = useCallback(() => {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records?pageSize=200').then(
|
||||||
setRecords(d.list || []);
|
(d) => {
|
||||||
}),
|
setRecords(d.list || []);
|
||||||
|
},
|
||||||
|
),
|
||||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||||
.then((s) => setStoreName(String(s.name || '')))
|
.then((s) => setStoreName(String(s.name || '')))
|
||||||
.catch(() => {}),
|
.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(() => {
|
useEffect(() => {
|
||||||
void loadRecords();
|
void loadRecords();
|
||||||
}, [loadRecords]);
|
}, [loadRecords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (statsOpen) void loadStats(range);
|
||||||
|
}, [statsOpen, range, loadStats]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return records.filter((r) => {
|
return records.filter((r) => {
|
||||||
if (!inRange(String(r.createdAt), range)) return false;
|
if (!inRange(String(r.createdAt), range)) return false;
|
||||||
@@ -71,11 +98,13 @@ export default function RecordsPage() {
|
|||||||
<div className="shop-records-main">
|
<div className="shop-records-main">
|
||||||
<nav className="shop-records-filters">
|
<nav className="shop-records-filters">
|
||||||
<div className="shop-records-range-tabs">
|
<div className="shop-records-range-tabs">
|
||||||
{([
|
{(
|
||||||
['today', '今日'],
|
[
|
||||||
['7d', '近7日'],
|
['today', '今日'],
|
||||||
['30d', '近30日'],
|
['7d', '近7日'],
|
||||||
] as const).map(([key, label]) => (
|
['30d', '近30日'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -86,21 +115,35 @@ export default function RecordsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="shop-records-status-chips">
|
<div className="shop-records-status-row">
|
||||||
{([
|
<div className="shop-records-status-chips">
|
||||||
['all', '全部'],
|
{(
|
||||||
['pending', '待打款'],
|
[
|
||||||
['paid', '已打款'],
|
['all', '全部'],
|
||||||
] as const).map(([key, label]) => (
|
['pending', '待打款'],
|
||||||
<button
|
['paid', '已打款'],
|
||||||
key={key}
|
] as const
|
||||||
type="button"
|
).map(([key, label]) => (
|
||||||
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
<button
|
||||||
onClick={() => setStatusFilter(key)}
|
key={key}
|
||||||
>
|
type="button"
|
||||||
{label}
|
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||||
</button>
|
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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -116,7 +159,10 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="shop-records-summary-note">
|
<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
|
check_circle
|
||||||
</span>
|
</span>
|
||||||
结算比例: {summary.rate}% (按{summary.rate / 10}折结算)
|
结算比例: {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 payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||||
|
const channel = (r.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel;
|
||||||
return (
|
return (
|
||||||
<article key={String(r.id)} className="shop-record-card">
|
<article key={String(r.id)} className="shop-record-card">
|
||||||
<div className="shop-record-card-top">
|
<div className="shop-record-card-top">
|
||||||
<div>
|
<div>
|
||||||
<div className="shop-record-order">
|
<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>
|
<span>{r.redeemNo ? String(r.redeemNo) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="shop-record-time">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
|
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
|
||||||
@@ -172,7 +231,9 @@ export default function RecordsPage() {
|
|||||||
</p>
|
</p>
|
||||||
{storeName && (
|
{storeName && (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<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}
|
{storeName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -190,6 +251,80 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export default function WithdrawPage() {
|
|||||||
|
|
||||||
const canApply =
|
const canApply =
|
||||||
!!summary?.isPrimary &&
|
!!summary?.isPrimary &&
|
||||||
!!summary.whitelistEnabled &&
|
|
||||||
summary.availableAmount > 0 &&
|
summary.availableAmount > 0 &&
|
||||||
!summary.hasPendingRequest &&
|
!summary.hasPendingRequest &&
|
||||||
summary.hasBankAccount &&
|
summary.hasBankAccount &&
|
||||||
@@ -125,8 +124,8 @@ export default function WithdrawPage() {
|
|||||||
info
|
info
|
||||||
</span>
|
</span>
|
||||||
单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)}
|
单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)}
|
||||||
{summary && !summary.whitelistEnabled ? ' · 未开通未出账提现白名单' : ''}
|
|
||||||
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
||||||
|
{!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+167
-1
@@ -587,6 +587,12 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-home-stat-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
.shop-home-scan {
|
.shop-home-scan {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1549,18 +1555,42 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-records-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-records-status-chips {
|
.shop-records-status-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-records-status-chips::-webkit-scrollbar {
|
.shop-records-status-chips::-webkit-scrollbar {
|
||||||
display: none;
|
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 {
|
.shop-records-chip {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1578,6 +1608,142 @@
|
|||||||
color: var(--color-on-primary);
|
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 {
|
.shop-records-main {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import Taro, { useReady } from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
|
||||||
type StoreRedeemMarqueeProps = {
|
type StoreRedeemMarqueeProps = {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 飘动速度 px/s */
|
const FLY_SPEED = 56;
|
||||||
const FLY_SPEED = 58;
|
const MIN_FLY_MS = 2400;
|
||||||
const MIN_FLY_MS = 2200;
|
|
||||||
const PAUSE_MIN_MS = 1000;
|
const PAUSE_MIN_MS = 1000;
|
||||||
const PAUSE_MAX_MS = 5000;
|
const PAUSE_MAX_MS = 5000;
|
||||||
|
const TICK_MS = 16;
|
||||||
|
/** 全文滚出视口后,再向左多走 10px */
|
||||||
|
const EXTRA_AFTER_EXIT_PX = 10;
|
||||||
|
|
||||||
function estimateTextWidth(text: string): number {
|
function estimateTextWidth(text: string): number {
|
||||||
let w = 0;
|
let w = 0;
|
||||||
@@ -24,18 +26,67 @@ function randomPauseMs() {
|
|||||||
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
|
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function uid(prefix: string) {
|
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
|
||||||
return `${prefix}${Math.random().toString(36).slice(2, 10)}`;
|
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() {
|
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
|
||||||
const page = Taro.getCurrentInstance().page;
|
return new Promise((resolve) => {
|
||||||
return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
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 秒随机再播下一条。
|
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
|
||||||
* H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。
|
*
|
||||||
|
* 小程序注意:
|
||||||
|
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
|
||||||
|
* - 不用 Text + transform(支持差),改用 View + left
|
||||||
|
* - 字宽用估算,避免屏外元素测宽失败
|
||||||
*/
|
*/
|
||||||
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
@@ -46,64 +97,20 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
|||||||
[lines],
|
[lines],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootIdRef = useRef(uid('smr'));
|
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
|
||||||
const textIdRef = useRef(uid('smt'));
|
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
|
||||||
const indexRef = useRef(0);
|
const indexRef = useRef(0);
|
||||||
const boxWidthRef = useRef(0);
|
const boxWidthRef = useRef(getBoxWidthFallback());
|
||||||
const itemsKey = items.join('\n');
|
const itemsKey = items.join('\n');
|
||||||
|
|
||||||
const [displayIndex, setDisplayIndex] = useState(0);
|
const [displayIndex, setDisplayIndex] = useState(0);
|
||||||
const [offset, setOffset] = useState(9999);
|
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
|
||||||
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));
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const waiters = new Set<ReturnType<typeof setTimeout>>();
|
const waiters = new Set<ReturnType<typeof setTimeout>>();
|
||||||
let rafId = 0;
|
|
||||||
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
const sleep = (ms: number) =>
|
const sleep = (ms: number) =>
|
||||||
@@ -115,78 +122,65 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
|||||||
waiters.add(id);
|
waiters.add(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearAnim = () => {
|
const clearTick = () => {
|
||||||
if (rafId) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
rafId = 0;
|
|
||||||
}
|
|
||||||
if (tickTimer) {
|
if (tickTimer) {
|
||||||
clearInterval(tickTimer);
|
clearInterval(tickTimer);
|
||||||
tickTimer = undefined;
|
tickTimer = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fly = (start: number, end: number, durationMs: number) =>
|
const fly = (from: number, to: number, durationMs: number) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
const began = Date.now();
|
const began = Date.now();
|
||||||
setOffset(start);
|
setLeftPx(from);
|
||||||
|
clearTick();
|
||||||
const step = () => {
|
tickTimer = setInterval(() => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
clearAnim();
|
clearTick();
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = Math.min(1, (Date.now() - began) / durationMs);
|
const t = Math.min(1, (Date.now() - began) / durationMs);
|
||||||
setOffset(start + (end - start) * t);
|
setLeftPx(from + (to - from) * t);
|
||||||
if (t >= 1) {
|
if (t >= 1) {
|
||||||
clearAnim();
|
clearTick();
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (typeof requestAnimationFrame === 'function') {
|
}, TICK_MS);
|
||||||
rafId = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
clearAnim();
|
|
||||||
if (typeof requestAnimationFrame === 'function') {
|
|
||||||
rafId = requestAnimationFrame(step);
|
|
||||||
} else {
|
|
||||||
tickTimer = setInterval(step, 32);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const loop = async () => {
|
const loop = async () => {
|
||||||
indexRef.current = 0;
|
indexRef.current = 0;
|
||||||
setDisplayIndex(0);
|
setDisplayIndex(0);
|
||||||
await sleep(60);
|
|
||||||
|
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
|
||||||
|
boxWidthRef.current = measured;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
while (!cancelled) {
|
while (!cancelled && items.length) {
|
||||||
if (!items.length) break;
|
|
||||||
|
|
||||||
const idx = indexRef.current % items.length;
|
const idx = indexRef.current % items.length;
|
||||||
const text = items[idx];
|
const text = items[idx];
|
||||||
|
const box = boxWidthRef.current;
|
||||||
|
|
||||||
setDisplayIndex(idx);
|
setDisplayIndex(idx);
|
||||||
setOffset(9999);
|
|
||||||
|
const from = box;
|
||||||
|
setLeftPx(from);
|
||||||
await sleep(48);
|
await sleep(48);
|
||||||
if (cancelled) break;
|
if (cancelled) break;
|
||||||
|
|
||||||
const box = await measureBox();
|
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
|
||||||
const textW = await measureText(text);
|
// 全文 left 边缘移出容器左边界后再走 10px
|
||||||
if (cancelled) break;
|
const to = -(textW + EXTRA_AFTER_EXIT_PX);
|
||||||
|
const distance = from - to;
|
||||||
const start = box;
|
|
||||||
const end = -textW;
|
|
||||||
const distance = start - end;
|
|
||||||
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
|
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;
|
if (cancelled) break;
|
||||||
|
|
||||||
// 飞出后随机停留 1~5 秒,再播下一条
|
|
||||||
await sleep(randomPauseMs());
|
await sleep(randomPauseMs());
|
||||||
if (cancelled) break;
|
if (cancelled) break;
|
||||||
|
|
||||||
@@ -198,7 +192,7 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearAnim();
|
clearTick();
|
||||||
waiters.forEach(clearTimeout);
|
waiters.forEach(clearTimeout);
|
||||||
waiters.clear();
|
waiters.clear();
|
||||||
};
|
};
|
||||||
@@ -207,17 +201,15 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
|||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
const current = items[displayIndex] || items[0];
|
const current = items[displayIndex] || items[0];
|
||||||
const textStyle: CSSProperties = {
|
const innerStyle: CSSProperties = { left: `${leftPx}px` };
|
||||||
transform: `translateX(${offset}px)`,
|
|
||||||
WebkitTransform: `translateX(${offset}px)`,
|
|
||||||
opacity: ready ? 1 : 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View id={rootIdRef.current} className="store-detail-marquee">
|
<View id={rootIdRef.current} className="store-detail-marquee">
|
||||||
<Text id={textIdRef.current} className="store-detail-marquee-text" style={textStyle}>
|
<View className="store-detail-marquee-inner" style={innerStyle}>
|
||||||
{current}
|
<Text id={textIdRef.current} className="store-detail-marquee-text">
|
||||||
</Text>
|
{current}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,8 +342,6 @@ export default function StoreDetailPage() {
|
|||||||
<View className="store-detail-info-card">
|
<View className="store-detail-info-card">
|
||||||
<Text className="store-detail-name">{store.name}</Text>
|
<Text className="store-detail-name">{store.name}</Text>
|
||||||
|
|
||||||
{marqueeLines.length > 0 ? <StoreRedeemMarquee lines={marqueeLines} /> : null}
|
|
||||||
|
|
||||||
<View className="store-detail-row">
|
<View className="store-detail-row">
|
||||||
<Text className="store-detail-meta store-detail-meta--flex">
|
<Text className="store-detail-meta store-detail-meta--flex">
|
||||||
{store.district ? `${store.district} · ` : ''}
|
{store.district ? `${store.district} · ` : ''}
|
||||||
@@ -385,6 +383,12 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{marqueeLines.length > 0 ? (
|
||||||
|
<View className="store-detail-marquee-wrap">
|
||||||
|
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{intro ? (
|
{intro ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店详情</Text>
|
<Text className="store-detail-section-title">门店详情</Text>
|
||||||
|
|||||||
@@ -127,9 +127,13 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-detail-marquee-wrap {
|
||||||
|
margin: 0 var(--space-page) 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.store-detail-marquee {
|
.store-detail-marquee {
|
||||||
margin: 4px 0 12px;
|
margin: 0;
|
||||||
padding: 10px 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff7f6;
|
background: #fff7f6;
|
||||||
border: 1px solid rgba(166, 29, 36, 0.12);
|
border: 1px solid rgba(166, 29, 36, 0.12);
|
||||||
@@ -137,22 +141,20 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
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 {
|
.store-detail-marquee-text {
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 50%;
|
|
||||||
margin-top: -10px;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: #a61d24;
|
color: #a61d24;
|
||||||
will-change: transform;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-section {
|
.store-detail-section {
|
||||||
|
|||||||
@@ -247,7 +247,6 @@ describe('sumUnbilledPayoutAmount', () => {
|
|||||||
|
|
||||||
describe('validateStoreWithdraw', () => {
|
describe('validateStoreWithdraw', () => {
|
||||||
const base = {
|
const base = {
|
||||||
whitelistEnabled: true,
|
|
||||||
availableAmount: 1000,
|
availableAmount: 1000,
|
||||||
requestAmount: 200,
|
requestAmount: 200,
|
||||||
todayApplied: 0,
|
todayApplied: 0,
|
||||||
@@ -256,10 +255,6 @@ describe('validateStoreWithdraw', () => {
|
|||||||
hasBankAccount: true,
|
hasBankAccount: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('rejects non-whitelist stores', () => {
|
|
||||||
expect(validateStoreWithdraw({ ...base, whitelistEnabled: false }).ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects when daily limit exceeded', () => {
|
it('rejects when daily limit exceeded', () => {
|
||||||
expect(
|
expect(
|
||||||
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
|
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ValidateStoreWithdrawInput = {
|
export type ValidateStoreWithdrawInput = {
|
||||||
whitelistEnabled: boolean;
|
|
||||||
availableAmount: number;
|
availableAmount: number;
|
||||||
requestAmount: number;
|
requestAmount: number;
|
||||||
todayApplied: number;
|
todayApplied: number;
|
||||||
@@ -110,13 +109,10 @@ export type ValidateStoreWithdrawInput = {
|
|||||||
hasBankAccount: boolean;
|
hasBankAccount: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 门店未出账提现护栏(FIN-001/002 + 幂等/账户) */
|
/** 门店未出账提现护栏(FIN-002 单日上限 + 幂等/账户) */
|
||||||
export function validateStoreWithdraw(
|
export function validateStoreWithdraw(
|
||||||
input: ValidateStoreWithdrawInput,
|
input: ValidateStoreWithdrawInput,
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
if (!input.whitelistEnabled) {
|
|
||||||
return { ok: false, message: '该门店未开通未出账提现(需总部白名单)' };
|
|
||||||
}
|
|
||||||
if (!input.hasBankAccount) {
|
if (!input.hasBankAccount) {
|
||||||
return { ok: false, message: '请先完善入驻收款账户后再提现' };
|
return { ok: false, message: '请先完善入驻收款账户后再提现' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,39 @@ export interface RedeemPreviewDto {
|
|||||||
boundStoreId?: string | null;
|
boundStoreId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 门店核销方式 */
|
||||||
|
export type RedeemChannel = 'SCAN' | 'PHONE';
|
||||||
|
|
||||||
|
export const REDEEM_CHANNEL_LABELS: Record<RedeemChannel, string> = {
|
||||||
|
SCAN: '扫码核销',
|
||||||
|
PHONE: '手机号核销',
|
||||||
|
};
|
||||||
|
|
||||||
export interface RedeemRecordDto {
|
export interface RedeemRecordDto {
|
||||||
id: string;
|
id: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
|
/** 核销方式:扫码 SCAN / 手机号 PHONE */
|
||||||
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
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 {
|
export interface RedeemPhoneBalanceDto {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
|
|||||||
@@ -11,6 +11,44 @@ export const STORE_WITHDRAW_STATUS_LABELS: Record<StoreWithdrawStatus, string> =
|
|||||||
PAID: '已结算',
|
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 {
|
export interface StoreWithdrawBankAccountDto {
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
@@ -23,7 +61,6 @@ export interface StoreWithdrawSummaryDto {
|
|||||||
todayAppliedAmount: number;
|
todayAppliedAmount: number;
|
||||||
dailyLimit: number;
|
dailyLimit: number;
|
||||||
remainingDailyLimit: number;
|
remainingDailyLimit: number;
|
||||||
whitelistEnabled: boolean;
|
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
hasBankAccount: boolean;
|
hasBankAccount: boolean;
|
||||||
hasPendingRequest: boolean;
|
hasPendingRequest: boolean;
|
||||||
@@ -74,6 +111,19 @@ export const FINANCE_PAY_STATUS_LABELS: Record<FinancePayStatus, string> = {
|
|||||||
PAID: '已打款',
|
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 {
|
export interface PartnerBillDto {
|
||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
|
|||||||
+8
-18
@@ -172,30 +172,12 @@ async function main() {
|
|||||||
const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId;
|
const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId;
|
||||||
if (!shopStoreId) throw new Error('Shop storeId missing after login');
|
if (!shopStoreId) throw new Error('Shop storeId missing after login');
|
||||||
|
|
||||||
const noWhitelistMsg = await expectFail('SHOP_H5', '/shop/withdraw', {
|
|
||||||
method: 'POST',
|
|
||||||
token: shopToken,
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
if (!String(noWhitelistMsg).includes('白名单')) {
|
|
||||||
throw new Error(`Expected FIN-001 whitelist reject, got: ${noWhitelistMsg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await req('HQ_WEB', `/admin/stores/${shopStoreId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
token: admin.accessToken,
|
|
||||||
body: JSON.stringify({ withdrawWhitelistEnabled: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const withdrawSummary = await req('SHOP_H5', '/shop/withdraw/summary', {
|
const withdrawSummary = await req('SHOP_H5', '/shop/withdraw/summary', {
|
||||||
token: shopToken,
|
token: shopToken,
|
||||||
});
|
});
|
||||||
if (!(withdrawSummary.availableAmount > 0)) {
|
if (!(withdrawSummary.availableAmount > 0)) {
|
||||||
throw new Error('Expected available withdraw amount after redeem');
|
throw new Error('Expected available withdraw amount after redeem');
|
||||||
}
|
}
|
||||||
if (!withdrawSummary.whitelistEnabled) {
|
|
||||||
throw new Error('Expected withdraw whitelist enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const applied = await req('SHOP_H5', '/shop/withdraw', {
|
const applied = await req('SHOP_H5', '/shop/withdraw', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -215,6 +197,14 @@ async function main() {
|
|||||||
throw new Error(`Expected pending-request reject, got: ${pendingDup}`);
|
throw new Error(`Expected pending-request reject, got: ${pendingDup}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 锁定中的明细不可再出账(T+1 排除 withdrawItem)
|
||||||
|
const summaryAfter = await req('SHOP_H5', '/shop/withdraw/summary', {
|
||||||
|
token: shopToken,
|
||||||
|
});
|
||||||
|
if (summaryAfter.availableAmount !== 0) {
|
||||||
|
throw new Error('Expected availableAmount=0 while withdraw pending');
|
||||||
|
}
|
||||||
|
|
||||||
await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, {
|
await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: admin.accessToken,
|
token: admin.accessToken,
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ enum RedeemPendingStatus {
|
|||||||
REJECTED
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redeem channel: SCAN or PHONE
|
||||||
|
enum RedeemChannel {
|
||||||
|
SCAN
|
||||||
|
PHONE
|
||||||
|
}
|
||||||
|
|
||||||
enum ResourceMediaType {
|
enum ResourceMediaType {
|
||||||
IMAGE
|
IMAGE
|
||||||
VIDEO
|
VIDEO
|
||||||
@@ -1318,14 +1324,16 @@ model BenefitCoupon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RedeemRecord {
|
model RedeemRecord {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
||||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
/// 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)
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||||
@@ -1336,6 +1344,7 @@ model RedeemRecord {
|
|||||||
allocations RedeemRecordAllocation[]
|
allocations RedeemRecordAllocation[]
|
||||||
|
|
||||||
@@index([storeId, createdAt])
|
@@index([storeId, createdAt])
|
||||||
|
@@index([storeId, channel, createdAt])
|
||||||
@@map("user_redeem_record")
|
@@map("user_redeem_record")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
|
|||||||
body: [
|
body: [
|
||||||
'门店账单:核销×60%,T+1 出账;HQ 确认打款。',
|
'门店账单:核销×60%,T+1 出账;HQ 确认打款。',
|
||||||
'合伙人月账独立确认打款。',
|
'合伙人月账独立确认打款。',
|
||||||
'未出账提现受白名单/单日上限等 FIN 护栏。',
|
'门店可对未出账核销主动提现(受单日上限);申请后锁定明细不进入次日出账,HQ 审后打款并企微提醒。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,22 +49,13 @@ export class SettlementScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FIN-003:工作日 18:05 扫描超时未审门店提现 */
|
/** FIN-003:工作日 18:05 扫描超时未审门店提现(企微提醒在 SettlementService 内发送) */
|
||||||
@Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' })
|
@Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' })
|
||||||
async handleWithdrawOverdueAlert() {
|
async handleWithdrawOverdueAlert() {
|
||||||
this.logger.log('Store withdraw overdue scan start');
|
this.logger.log('Store withdraw overdue scan start');
|
||||||
try {
|
try {
|
||||||
const summary = await this.settlementService.scanOverdueStoreWithdrawals();
|
const summary = await this.settlementService.scanOverdueStoreWithdrawals();
|
||||||
this.logger.log(`Store withdraw overdue: ${JSON.stringify(summary)}`);
|
this.logger.log(`Store withdraw overdue: ${JSON.stringify(summary)}`);
|
||||||
if (summary.overdueCount > 0) {
|
|
||||||
this.alert.notify({
|
|
||||||
level: 'P1',
|
|
||||||
category: 'finance',
|
|
||||||
title: '门店提现超时未审',
|
|
||||||
detail: `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
|
||||||
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
|
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
|
||||||
this.alert.notify({
|
this.alert.notify({
|
||||||
@@ -72,7 +63,7 @@ export class SettlementScheduler {
|
|||||||
category: 'job',
|
category: 'job',
|
||||||
title: '门店提现超时扫描失败',
|
title: '门店提现超时扫描失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export class AdminRedeemService {
|
|||||||
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
|
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
|
||||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||||
if (query.userId) where.userId = BigInt(query.userId);
|
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([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.redeemRecord.findMany({
|
this.prisma.redeemRecord.findMany({
|
||||||
|
|||||||
@@ -357,9 +357,6 @@ export class AdminStoresService {
|
|||||||
...(dto.visibilityWhitelistEnabled !== undefined
|
...(dto.visibilityWhitelistEnabled !== undefined
|
||||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
...(dto.withdrawWhitelistEnabled !== undefined
|
|
||||||
? { withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled }
|
|
||||||
: {}),
|
|
||||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -519,7 +516,6 @@ export class AdminStoresService {
|
|||||||
openTime2: openTime2 || null,
|
openTime2: openTime2 || null,
|
||||||
closeTime2: closeTime2 || null,
|
closeTime2: closeTime2 || null,
|
||||||
visibilityWhitelistEnabled: whitelistEnabled,
|
visibilityWhitelistEnabled: whitelistEnabled,
|
||||||
withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled,
|
|
||||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
auditStatus: 'APPROVED',
|
auditStatus: 'APPROVED',
|
||||||
|
|||||||
@@ -138,11 +138,6 @@ export class CreateStoreDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
|
|
||||||
/** FIN-001:允许未出账手动提现 */
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
withdrawWhitelistEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateStoreDto {
|
export class UpdateStoreDto {
|
||||||
@@ -242,11 +237,6 @@ export class UpdateStoreDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
|
|
||||||
/** FIN-001:允许未出账手动提现 */
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
withdrawWhitelistEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateStoreAccountDto {
|
export class CreateStoreAccountDto {
|
||||||
|
|||||||
@@ -242,6 +242,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
|
/** SCAN | PHONE */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
channel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
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')
|
@Post('phone/send-lookup-sms')
|
||||||
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
||||||
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export class RedeemService {
|
|||||||
) {
|
) {
|
||||||
const settlementRate = Number(account.store.settlementRate);
|
const settlementRate = Number(account.store.settlementRate);
|
||||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||||
|
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||||
|
|
||||||
let record;
|
let record;
|
||||||
try {
|
try {
|
||||||
@@ -192,6 +193,7 @@ export class RedeemService {
|
|||||||
storeId: account.storeId,
|
storeId: account.storeId,
|
||||||
amount,
|
amount,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
|
channel: redeemChannel,
|
||||||
allocations: {
|
allocations: {
|
||||||
create: normalizedAllocations.map((item, index) => ({
|
create: normalizedAllocations.map((item, index) => ({
|
||||||
couponId: BigInt(item.couponId),
|
couponId: BigInt(item.couponId),
|
||||||
@@ -1079,6 +1081,51 @@ export class RedeemService {
|
|||||||
return { list: serializeBigInt(list), total, page, pageSize };
|
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) {
|
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
@@ -1091,6 +1138,8 @@ export class RedeemService {
|
|||||||
});
|
});
|
||||||
const todayCount = records.length;
|
const todayCount = records.length;
|
||||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
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({
|
const recent = await this.prisma.redeemRecord.findMany({
|
||||||
where: { storeId },
|
where: { storeId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -1100,6 +1149,8 @@ export class RedeemService {
|
|||||||
store: binding.store,
|
store: binding.store,
|
||||||
todayCount,
|
todayCount,
|
||||||
todayAmount,
|
todayAmount,
|
||||||
|
todayScanCount,
|
||||||
|
todayPhoneCount,
|
||||||
recentRecords: recent,
|
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')
|
@Controller('admin/store-bills')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
export class AdminStoreBillController {
|
export class AdminStoreBillController {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
AdminStoreBillController,
|
AdminStoreBillController,
|
||||||
AdminStorePayoutController,
|
AdminStorePayoutController,
|
||||||
|
AdminStoreSettlementController,
|
||||||
AdminStoreWithdrawController,
|
AdminStoreWithdrawController,
|
||||||
AdminWineryBillController,
|
AdminWineryBillController,
|
||||||
PartnerMeController,
|
PartnerMeController,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
ShopWithdrawController,
|
ShopWithdrawController,
|
||||||
AdminStoreWithdrawController,
|
AdminStoreWithdrawController,
|
||||||
AdminStorePayoutController,
|
AdminStorePayoutController,
|
||||||
|
AdminStoreSettlementController,
|
||||||
AdminStoreBillController,
|
AdminStoreBillController,
|
||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
AdminWineryBillController,
|
AdminWineryBillController,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AlertService } from '../../common/alert/alert.service';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||||
@@ -72,6 +73,7 @@ export class SettlementService {
|
|||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||||||
|
private readonly alert: AlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Store payout (line) ─────────────────────────────
|
// ─── Store payout (line) ─────────────────────────────
|
||||||
@@ -168,11 +170,7 @@ export class SettlementService {
|
|||||||
|
|
||||||
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
|
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
|
||||||
await this.assertShopStoreAccess(storeAccountId, storeId);
|
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||||
const [store, account, available, pending, todayApplied] = await Promise.all([
|
const [account, available, pending, todayApplied] = await Promise.all([
|
||||||
this.prisma.store.findUniqueOrThrow({
|
|
||||||
where: { id: storeId },
|
|
||||||
select: { withdrawWhitelistEnabled: true },
|
|
||||||
}),
|
|
||||||
this.prisma.storeAccount.findUniqueOrThrow({
|
this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
select: {
|
select: {
|
||||||
@@ -205,7 +203,6 @@ export class SettlementService {
|
|||||||
todayAppliedAmount: todayApplied,
|
todayAppliedAmount: todayApplied,
|
||||||
dailyLimit,
|
dailyLimit,
|
||||||
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
|
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
|
||||||
whitelistEnabled: store.withdrawWhitelistEnabled,
|
|
||||||
isPrimary: account.isPrimary === 1,
|
isPrimary: account.isPrimary === 1,
|
||||||
hasBankAccount,
|
hasBankAccount,
|
||||||
hasPendingRequest: !!pending,
|
hasPendingRequest: !!pending,
|
||||||
@@ -227,7 +224,7 @@ export class SettlementService {
|
|||||||
const [store, account, available, pending, todayApplied] = await Promise.all([
|
const [store, account, available, pending, todayApplied] = await Promise.all([
|
||||||
this.prisma.store.findUniqueOrThrow({
|
this.prisma.store.findUniqueOrThrow({
|
||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
select: { withdrawWhitelistEnabled: true },
|
select: { id: true, name: true, phone: true, cityName: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.storeAccount.findUniqueOrThrow({
|
this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
@@ -263,7 +260,6 @@ export class SettlementService {
|
|||||||
: availableAmount;
|
: availableAmount;
|
||||||
|
|
||||||
const guard = validateStoreWithdraw({
|
const guard = validateStoreWithdraw({
|
||||||
whitelistEnabled: store.withdrawWhitelistEnabled,
|
|
||||||
availableAmount,
|
availableAmount,
|
||||||
requestAmount,
|
requestAmount,
|
||||||
todayApplied,
|
todayApplied,
|
||||||
@@ -334,6 +330,21 @@ export class SettlementService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.alert.notify({
|
||||||
|
level: 'P1',
|
||||||
|
category: 'finance',
|
||||||
|
title: '门店提现待审',
|
||||||
|
detail: [
|
||||||
|
`门店:${store.name}(${store.cityName || '-'} / ${store.phone || '-'})`,
|
||||||
|
`单号:${created.withdrawNo}`,
|
||||||
|
`金额:¥${Number(created.amount).toFixed(2)}`,
|
||||||
|
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
|
||||||
|
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
|
||||||
|
].join('\n'),
|
||||||
|
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
|
||||||
|
dedupeTtlSec: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
return serializeBigInt(created);
|
return serializeBigInt(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +444,6 @@ export class SettlementService {
|
|||||||
name: true,
|
name: true,
|
||||||
cityName: true,
|
cityName: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
withdrawWhitelistEnabled: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storeAccount: {
|
storeAccount: {
|
||||||
@@ -562,6 +572,34 @@ export class SettlementService {
|
|||||||
/** FIN-003:工作日 18:00 扫描超时未审提现 */
|
/** FIN-003:工作日 18:00 扫描超时未审提现 */
|
||||||
async scanOverdueStoreWithdrawals() {
|
async scanOverdueStoreWithdrawals() {
|
||||||
const summary = await this.getStoreWithdrawOverdueSummary();
|
const summary = await this.getStoreWithdrawOverdueSummary();
|
||||||
|
if (summary.overdueCount > 0) {
|
||||||
|
const overdueRows = await this.prisma.storeWithdrawRequest.findMany({
|
||||||
|
where: { status: 'PENDING_REVIEW' },
|
||||||
|
include: { store: { select: { name: true, phone: true } } },
|
||||||
|
orderBy: { appliedAt: 'asc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
const lines = overdueRows
|
||||||
|
.filter((r) => isWithdrawOverdue(r.appliedAt, now))
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`- ${r.store?.name || r.storeId} ${r.withdrawNo} ¥${Number(r.amount).toFixed(2)}`,
|
||||||
|
);
|
||||||
|
this.alert.notify({
|
||||||
|
level: 'P1',
|
||||||
|
category: 'finance',
|
||||||
|
title: '门店提现超时未审',
|
||||||
|
detail: [
|
||||||
|
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
|
||||||
|
...lines,
|
||||||
|
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
|
||||||
|
].join('\n'),
|
||||||
|
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
dedupeTtlSec: 6 * 3600,
|
||||||
|
});
|
||||||
|
}
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +857,152 @@ export class SettlementService {
|
|||||||
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
|
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: {
|
async listAdminStoreBills(query: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user