后端显示门店账单

This commit is contained in:
2026-07-07 00:04:07 +08:00
parent 7ca9fc8a35
commit 1fcafac2a7
12 changed files with 233 additions and 126 deletions
+3 -1
View File
@@ -47,8 +47,10 @@ pnpm dev:partner # http://localhost:5175
| 端 | 手机号 |
|----|--------|
| C端用户 | 13800000001 |
| 门店 | 13900000001 |
| 合伙人 | 13700000001 |
| HQ | 13600000001 |
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`
## 主链路冒烟
+3 -2
View File
@@ -21,7 +21,7 @@ import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
import StorePayoutsPage from './pages/StorePayoutsPage';
import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import TicketsPage from './pages/TicketsPage';
import UserLogsPage from './pages/UserLogsPage';
@@ -60,7 +60,8 @@ export default function App() {
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/store-payouts" element={<StorePayoutsPage />} />
<Route path="/store-bills" element={<StoreBillsPage />} />
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
<Route path="/partner-bills" element={<PartnerBillsPage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
+1 -1
View File
@@ -40,6 +40,7 @@ const MENU_ITEMS: MenuProps['items'] = [
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-bills', label: '门店账单' },
{ key: '/store-media', label: '门店资源' },
],
},
@@ -62,7 +63,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/benefit/ledgers', label: '流水' },
{ key: '/redeem-records', label: '核销记录' },
{ key: '/redeem/debug', label: '核销调试' },
{ key: '/store-payouts', label: '门店打款' },
],
},
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
redeemAmount: number;
payoutAmount: number;
settlementRate: number;
status: string;
expectedPayAt: string;
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number };
};
type StoreOption = { id: string; name: string; phone: string };
const PAYOUT_STATUS_LABELS: Record<string, string> = {
PENDING: '待打款',
PAID: '已打款',
};
const PAYOUT_STATUS_COLORS: Record<string, string> = {
PENDING: 'orange',
PAID: 'green',
};
export default function StoreBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-payouts',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items))
.catch(() => {});
}, []);
async function confirmPayout(id: string) {
await request(`/admin/store-payouts/${id}/confirm`, {
method: 'POST',
body: JSON.stringify({ remark: '财务确认打款' }),
});
message.success('已确认打款');
reload();
}
const columns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '核销单号',
dataIndex: ['redeemRecord', 'redeemNo'],
width: 160,
ellipsis: true,
render: (v) => v || '—',
},
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{
title: '到账金额',
dataIndex: 'payoutAmount',
width: 100,
render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`,
},
{
title: '打款状态',
dataIndex: 'status',
width: 100,
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
{
title: '操作',
width: 140,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
{row.status === 'PENDING' && (
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
T+1
</Typography.Text>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="storeId" label="门店">
<Select
allowClear
showSearch
placeholder="全部门店"
style={{ width: 200 }}
optionFilterProp="label"
options={stores.map((s) => ({
value: s.id,
label: `${s.name}${s.phone}`,
}))}
/>
</Form.Item>
<Form.Item name="status" label="打款状态">
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form.Item>
<Form.Item>
<Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}></Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="门店">{String((detail.store as { name?: string })?.name ?? '—')}</Descriptions.Item>
<Descriptions.Item label="核销单号">
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
<Descriptions.Item label="到账金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="结算比例">
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
</Descriptions.Item>
<Descriptions.Item label="状态">
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
</Descriptions.Item>
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
<Descriptions.Item label="实际打款">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
@@ -1,95 +0,0 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
redeemAmount: number;
payoutAmount: number;
status: string;
expectedPayAt: string;
paidAt?: string;
store?: { name: string; cityName: string };
redeemRecord?: { redeemNo: string };
};
export default function StorePayoutsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-payouts',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
async function confirmPayout(id: string) {
await request(`/admin/store-payouts/${id}/confirm`, {
method: 'POST',
body: JSON.stringify({ remark: '财务确认打款' }),
});
message.success('已确认打款');
reload();
}
const columns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'] },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 100 },
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
{
title: '操作', width: 140,
render: (_, row) => (
<>
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}></Button>
{row.status === 'PENDING' && (
<Button type="link" size="small" onClick={() => confirmPayout(row.id)}></Button>
)}
</>
),
},
];
return (
<div>
<Typography.Title level={4}>T+1</Typography.Title>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'PENDING', label: '待打款' },
{ value: 'PAID', label: '已打款' },
]} />
</Form.Item>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="打款详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销额">¥{String(detail.redeemAmount)}</Descriptions.Item>
<Descriptions.Item label="打款额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+1 -2
View File
@@ -9,7 +9,6 @@ function maskPhone(phone: string) {
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
const DEV_DEFAULT_PHONE = import.meta.env.DEV ? '13900000001' : '';
const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : '';
export default function LoginPage() {
@@ -18,7 +17,7 @@ export default function LoginPage() {
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone() || DEV_DEFAULT_PHONE);
const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(DEV_DEFAULT_CODE);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
+1 -1
View File
@@ -64,7 +64,7 @@ async function main() {
console.log('5. Shop login + redeem preview');
const shopLogin = await loginSms(
'SHOP_H5',
'13900000001',
'13910000001',
'STORE_LOGIN',
'/shop/auth/login/sms',
'/shop/auth/sms/send',
+1 -1
View File
@@ -133,7 +133,7 @@ async function main() {
console.log('8. Shop confirm redeem');
const shopLogin = await loginSms(
'SHOP_H5',
'13900000001',
'13910000001',
'STORE_LOGIN',
'/shop/auth/login/sms',
'/shop/auth/sms/send',
+4 -4
View File
@@ -40,17 +40,17 @@ async function main() {
console.log('2. STORE_LOGIN bound phone');
await req('SHOP_H5', '/shop/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone: '13900000001', scene: 'STORE_LOGIN' }),
body: JSON.stringify({ phone: '13910000001', scene: 'STORE_LOGIN' }),
});
const login = await req('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13900000001', code: MOCK_SMS_CODE }),
body: JSON.stringify({ phone: '13910000001', code: MOCK_SMS_CODE }),
});
if (!login.accessToken || !login.refreshToken) throw new Error('login missing tokens');
console.log('3. Shop session me + refresh');
const me = await req('SHOP_H5', '/shop/auth/me', { token: login.accessToken });
if (me.phone !== '13900000001') throw new Error('me phone mismatch');
if (me.phone !== '13910000001') throw new Error('me phone mismatch');
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: login.refreshToken }),
@@ -69,7 +69,7 @@ async function main() {
const occupied = await expectFail('HQ_WEB', '/admin/stores/phone/sms/send', {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({ phone: '13900000001' }),
body: JSON.stringify({ phone: '13910000001' }),
});
if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`);
+4 -8
View File
@@ -143,7 +143,7 @@ async function main() {
partnerId: partner.id,
categoryId: categories[0].id,
name: '郑州老城店',
phone: '0371-88880001',
phone: '13910000001',
province: '河南省',
cityName: '郑州市',
district: '金水区',
@@ -165,7 +165,7 @@ async function main() {
partnerId: partner.id,
categoryId: categories[1].id,
name: '郑州美食城店',
phone: '0371-88880002',
phone: '13910000002',
province: '河南省',
cityName: '郑州市',
district: '二七区',
@@ -179,11 +179,7 @@ async function main() {
});
await prisma.storeAccount.create({
data: { storeId: store1.id, phone: '13900000001', name: '老城店店长' },
});
await prisma.storeAccount.create({
data: { storeId: store2.id, phone: '13900000002', name: '美食城店长' },
data: { storeId: store1.id, phone: '13910000001', name: '郑州老城店' },
});
await prisma.user.create({
@@ -233,7 +229,7 @@ async function main() {
stores: 2,
testPhones: {
user: '13800000001',
store: '13900000001',
store: '13910000001',
partner: '13700000001',
},
});
+9 -10
View File
@@ -153,25 +153,23 @@ async function main() {
const storeDefs = [
{
name: '郑州老城店',
phone: '0371-88880001',
phone: '13910000001',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
img: 'https://picsum.photos/seed/store1/400/300',
categoryId: categories[0].id,
accountPhone: '13900000001',
accountName: '老城店店长',
withAccount: true,
},
{
name: '郑州美食城店',
phone: '0371-88880002',
phone: '13910000002',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
img: 'https://picsum.photos/seed/store2/400/300',
categoryId: categories[1].id,
accountPhone: '13900000002',
accountName: '美食城店长',
withAccount: false,
},
];
@@ -198,9 +196,11 @@ async function main() {
});
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.accountPhone, name: def.accountName },
});
if (def.withAccount) {
await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.phone, name: def.name },
});
}
}
await prisma.user.create({
@@ -252,7 +252,6 @@ async function main() {
stores: storeDefs.length,
testPhones: {
user: '13800000001',
store: '13900000001',
partner: '13700000001',
hq: '13600000001',
},
@@ -73,7 +73,7 @@ export class SettlementService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true } },
store: { select: { id: true, name: true, cityName: true, phone: true } },
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
},
}),