Compare commits

...

10 Commits

Author SHA1 Message Date
jacy a7d55b7293 merge(dev): 走马灯滚出终点调整
CI / verify (push) Has been cancelled
2026-08-03 17:52:18 +08:00
jacy 7e3dc131ad fix(mini-user): 走马灯全文滚出视口后再多走 10px
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:52:10 +08:00
jacy f09e00e16f merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled
2026-08-03 16:34:36 +08:00
jacy ff462bca9a fix(mini-user): 门店核销走马灯小程序轮播与布局优化
单条从右向左位移至 -10px 后停留,位置移至门店详情上方;修复小程序 opacity/transform 不生效问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:34:28 +08:00
jacy aa0e140fec merge(dev): 核销方式标记与统计
CI / verify (push) Has been cancelled
2026-08-03 16:21:47 +08:00
jacy dc30932012 feat(redeem): 核销记录标记扫码/手机号并支持方式统计
落库 RedeemChannel,门店记录页增加统计按钮,HQ 可按方式筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:21:38 +08:00
jacy f8d8e2d6d5 merge(dev): 统一门店结算账单
CI / verify (push) Has been cancelled
2026-08-03 16:04:55 +08:00
jacy 54fca99208 feat(settlement): 统一门店账单列表合并 T+1 与手动提现
总部财务以门店账单为唯一入口;新增 store-settlements API,去掉独立提现审菜单并更新企微引导文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:04:45 +08:00
jacy 9859abba7e merge(dev): 全部门店可提现+企微提醒
CI / verify (push) Has been cancelled
2026-08-03 15:43:34 +08:00
jacy 26472f8678 fix(settlement): 全部门店可未出账提现并企微提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:43:24 +08:00
31 changed files with 1266 additions and 322 deletions
+8 -3
View File
@@ -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: '物流对账' },
-1
View File
@@ -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}$/;
+1 -1
View File
@@ -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}
+88 -12
View File
@@ -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>
+332 -64
View File
@@ -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>
); );
-20
View File
@@ -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
+3
View File
@@ -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>
+162 -27
View File
@@ -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>
); );
} }
+1 -2
View File
@@ -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
View File
@@ -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>
+13 -11
View File
@@ -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 {
-5
View File
@@ -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,
+1 -5
View File
@@ -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: '请先完善入驻收款账户后再提现' };
} }
+25
View File
@@ -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;
+51 -1
View File
@@ -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
View File
@@ -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,
+17 -8
View File
@@ -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;