feat(settlement): 门店未出账手动提现与总部审核(OPT-010)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,7 @@ 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';
|
||||||
@@ -96,11 +97,13 @@ 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/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="/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,6 +74,7 @@ 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: '物流对账' },
|
||||||
@@ -157,6 +158,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/fulfillment-providers': 'partners',
|
'/fulfillment-providers': 'partners',
|
||||||
'finance-group': 'finance',
|
'finance-group': 'finance',
|
||||||
'/finance/store-bills': 'finance',
|
'/finance/store-bills': 'finance',
|
||||||
|
'/finance/store-withdrawals': 'finance',
|
||||||
'/finance/partner-bills': 'finance',
|
'/finance/partner-bills': 'finance',
|
||||||
'/finance/winery-bills': 'finance',
|
'/finance/winery-bills': 'finance',
|
||||||
'/finance/logistics-bills': 'finance',
|
'/finance/logistics-bills': 'finance',
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export type DashboardStats = {
|
|||||||
pendingBills?: number;
|
pendingBills?: number;
|
||||||
pendingPartnerDraftBills?: number;
|
pendingPartnerDraftBills?: number;
|
||||||
openTickets?: number;
|
openTickets?: number;
|
||||||
|
pendingStoreWithdrawals?: number;
|
||||||
|
overdueStoreWithdrawals?: number;
|
||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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}$/;
|
||||||
|
|||||||
@@ -717,6 +717,26 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
loading={loading}
|
||||||
|
title="待审门店提现"
|
||||||
|
extra={<Link to="/finance/store-withdrawals">去处理</Link>}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={stats?.pendingStoreWithdrawals ?? 0}
|
||||||
|
suffix="笔"
|
||||||
|
valueStyle={{
|
||||||
|
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{(stats?.overdueStoreWithdrawals ?? 0) > 0
|
||||||
|
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003)`
|
||||||
|
: '工作日 T+0 审完'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card loading={loading} title="待处理工单">
|
<Card loading={loading} title="待处理工单">
|
||||||
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/shared-types';
|
||||||
|
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;
|
||||||
|
withdrawNo: string;
|
||||||
|
amount: number;
|
||||||
|
payoutCount: number;
|
||||||
|
status: StoreWithdrawStatus;
|
||||||
|
appliedAt: string;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
paidAt?: string | null;
|
||||||
|
paymentRef?: string | null;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
overdue?: boolean;
|
||||||
|
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreOption = { id: string; name: string; phone: string };
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
PENDING_REVIEW: 'orange',
|
||||||
|
REJECTED: 'red',
|
||||||
|
PAID: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoreWithdrawalsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({ status: 'PENDING_REVIEW' });
|
||||||
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/store-withdrawals',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [overdueSummary, setOverdueSummary] = useState<{
|
||||||
|
pendingCount: number;
|
||||||
|
overdueCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setStores(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
void request<{ pendingCount: number; overdueCount: number }>(
|
||||||
|
'/admin/store-withdrawals/overdue-summary',
|
||||||
|
)
|
||||||
|
.then(setOverdueSummary)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/store-withdrawals/${id}`);
|
||||||
|
setDetail(d);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approve(id: string) {
|
||||||
|
let paymentRef = '';
|
||||||
|
Modal.confirm({
|
||||||
|
title: '审核通过并标记已结算?',
|
||||||
|
content: (
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
onChange={(e) => {
|
||||||
|
paymentRef = e.target.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
okText: '通过并结算',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
await request(`/admin/store-withdrawals/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
||||||
|
});
|
||||||
|
message.success('已通过并标记已结算');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
void request<{ pendingCount: number; overdueCount: number }>(
|
||||||
|
'/admin/store-withdrawals/overdue-summary',
|
||||||
|
)
|
||||||
|
.then(setOverdueSummary)
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '提现单号',
|
||||||
|
dataIndex: 'withdrawNo',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space>
|
||||||
|
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
|
||||||
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: ['store', 'name'],
|
||||||
|
width: 160,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div>
|
||||||
|
<div>{row.store?.name || '—'}</div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.store?.cityName} {row.store?.phone}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{ title: '明细笔数', dataIndex: 'payoutCount', width: 90 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (v: StoreWithdrawStatus) => (
|
||||||
|
<Tag color={STATUS_COLORS[v]}>{STORE_WITHDRAW_STATUS_LABELS[v] ?? v}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '申请时间',
|
||||||
|
dataIndex: 'appliedAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v) => fmtTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 180,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => void openDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
{row.status === 'PENDING_REVIEW' ? (
|
||||||
|
<>
|
||||||
|
<Button size="small" type="primary" onClick={() => approve(row.id)}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger onClick={() => reject(row.id)}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const detailItems = (detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
|
||||||
|
const storeAccount = detail?.storeAccount as
|
||||||
|
| {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
bankAccountName?: string;
|
||||||
|
bankAccountNo?: string;
|
||||||
|
bankBranch?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
门店提现审
|
||||||
|
</Typography.Title>
|
||||||
|
{overdueSummary ? (
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
待审 {overdueSummary.pendingCount} 笔
|
||||||
|
{overdueSummary.overdueCount > 0 ? (
|
||||||
|
<Typography.Text type="danger">
|
||||||
|
{' '}
|
||||||
|
· 超时未审 {overdueSummary.overdueCount} 笔(FIN-003)
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16, gap: 8 }}
|
||||||
|
initialValues={{ status: 'PENDING_REVIEW' }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
const range = v.range as [Dayjs, Dayjs] | undefined;
|
||||||
|
setFilters({
|
||||||
|
status: v.status || '',
|
||||||
|
storeId: v.storeId || '',
|
||||||
|
dateFrom: range?.[0]?.format('YYYY-MM-DD') || '',
|
||||||
|
dateTo: range?.[1]?.format('YYYY-MM-DD') || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||||
|
{ value: 'PAID', label: '已结算' },
|
||||||
|
{ value: 'REJECTED', label: '已驳回' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeId" label="门店">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
options={stores.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.name} (${s.phone})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="range" label="申请日">
|
||||||
|
<DatePicker.RangePicker />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="提现详情"
|
||||||
|
width={640}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail?.status === 'PENDING_REVIEW' ? (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button danger onClick={() => reject(String(detail.id))}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail ? (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={STATUS_COLORS[String(detail.status)]}>
|
||||||
|
{STORE_WITHDRAW_STATUS_LABELS[detail.status as StoreWithdrawStatus] ??
|
||||||
|
String(detail.status)}
|
||||||
|
</Tag>
|
||||||
|
{detail.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">¥{Number(detail.amount).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="明细笔数">{String(detail.payoutCount)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="申请时间">{fmtTime(String(detail.appliedAt))}</Descriptions.Item>
|
||||||
|
{detail.rejectReason ? (
|
||||||
|
<Descriptions.Item label="驳回理由">{String(detail.rejectReason)}</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{detail.paymentRef ? (
|
||||||
|
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
<Descriptions.Item label="收款户名">
|
||||||
|
{storeAccount?.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收款账号">
|
||||||
|
{storeAccount?.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">{storeAccount?.bankBranch || '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 24 }}>
|
||||||
|
关联结算明细
|
||||||
|
</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(r) => String((r as { id?: string }).id)}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detailItems}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '核销单号',
|
||||||
|
render: (_, r) => {
|
||||||
|
const payout = (r as { storePayout?: { redeemRecord?: { redeemNo?: string } } })
|
||||||
|
.storePayout;
|
||||||
|
return payout?.redeemRecord?.redeemNo || '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算额',
|
||||||
|
render: (_, r) => {
|
||||||
|
const payout = (r as { storePayout?: { payoutAmount?: number } }).storePayout;
|
||||||
|
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -373,6 +373,7 @@ 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 };
|
||||||
@@ -592,6 +593,7 @@ 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[])
|
||||||
: [],
|
: [],
|
||||||
@@ -635,6 +637,7 @@ 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),
|
||||||
@@ -811,6 +814,7 @@ 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),
|
||||||
@@ -1180,6 +1184,14 @@ 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>
|
||||||
@@ -1426,6 +1438,14 @@ 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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import RecordsPage from './pages/RecordsPage';
|
|||||||
import StatusPage from './pages/StatusPage';
|
import StatusPage from './pages/StatusPage';
|
||||||
import MinePage from './pages/MinePage';
|
import MinePage from './pages/MinePage';
|
||||||
import StaffPage from './pages/StaffPage';
|
import StaffPage from './pages/StaffPage';
|
||||||
|
import WithdrawPage from './pages/WithdrawPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +23,7 @@ export default function App() {
|
|||||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||||
<Route path="/select-store" element={<SelectStorePage />} />
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
|
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ export default function MinePage() {
|
|||||||
切换门店
|
切换门店
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button type="button" className="shop-mine-action" onClick={() => navigate('/withdraw')}>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
||||||
|
结算提现
|
||||||
|
</button>
|
||||||
{profile?.isPrimary ? (
|
{profile?.isPrimary ? (
|
||||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export default function RecordsPage() {
|
|||||||
return records.filter((r) => {
|
return records.filter((r) => {
|
||||||
if (!inRange(String(r.createdAt), range)) return false;
|
if (!inRange(String(r.createdAt), range)) return false;
|
||||||
if (statusFilter === 'all') return true;
|
if (statusFilter === 'all') return true;
|
||||||
const isPaid = Boolean(r.paidAt);
|
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||||
|
const isPaid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||||
if (statusFilter === 'paid') return isPaid;
|
if (statusFilter === 'paid') return isPaid;
|
||||||
return !isPaid;
|
return !isPaid;
|
||||||
});
|
});
|
||||||
@@ -134,7 +135,9 @@ export default function RecordsPage() {
|
|||||||
{filtered.map((r) => {
|
{filtered.map((r) => {
|
||||||
const amount = Number(r.amount || 0);
|
const amount = Number(r.amount || 0);
|
||||||
const settle = Number(r.settleAmount || 0);
|
const settle = Number(r.settleAmount || 0);
|
||||||
const paid = Boolean(r.paidAt);
|
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||||
|
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||||
|
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||||
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">
|
||||||
@@ -162,7 +165,11 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shop-record-footer">
|
<div className="shop-record-footer">
|
||||||
<p>{paid ? `打款时间: ${new Date(String(r.createdAt)).toLocaleDateString('zh-CN')}` : '预计打款: T+1工作日'}</p>
|
<p>
|
||||||
|
{paid
|
||||||
|
? `打款时间: ${paidAt ? new Date(String(paidAt)).toLocaleDateString('zh-CN') : '—'}`
|
||||||
|
: '预计打款: T+1工作日'}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
STORE_WITHDRAW_STATUS_LABELS,
|
||||||
|
type StoreWithdrawRequestDto,
|
||||||
|
type StoreWithdrawStatus,
|
||||||
|
type StoreWithdrawSummaryDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||||
|
|
||||||
|
function formatMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WithdrawPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [summary, setSummary] = useState<StoreWithdrawSummaryDto | null>(null);
|
||||||
|
const [items, setItems] = useState<StoreWithdrawRequestDto[]>([]);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [summaryRes, listRes] = await Promise.all([
|
||||||
|
request<StoreWithdrawSummaryDto>('SHOP_H5', '/shop/withdraw/summary'),
|
||||||
|
request<{ items: StoreWithdrawRequestDto[] }>('SHOP_H5', '/shop/withdraw/requests?pageSize=50'),
|
||||||
|
]);
|
||||||
|
setSummary(summaryRes);
|
||||||
|
setItems(listRes.items || []);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (statusFilter === 'all') return items;
|
||||||
|
return items.filter((r) => r.status === statusFilter);
|
||||||
|
}, [items, statusFilter]);
|
||||||
|
|
||||||
|
async function applyWithdraw() {
|
||||||
|
if (!summary || submitting) return;
|
||||||
|
if (!summary.isPrimary) {
|
||||||
|
setMsg('仅主账号可申请提现');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(summary.availableAmount > 0)) {
|
||||||
|
setMsg('暂无可提未出账余额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = window.confirm(
|
||||||
|
`确认申请提现 ¥${formatMoney(summary.availableAmount)}?\n审核通过后将打款至入驻收款账户。`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
await request('SHOP_H5', '/shop/withdraw', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
setMsg('提现申请已提交,请等待总部审核');
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '提现申请失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canApply =
|
||||||
|
!!summary?.isPrimary &&
|
||||||
|
!!summary.whitelistEnabled &&
|
||||||
|
summary.availableAmount > 0 &&
|
||||||
|
!summary.hasPendingRequest &&
|
||||||
|
summary.hasBankAccount &&
|
||||||
|
!submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load} className="shop-records-page shop-withdraw-page">
|
||||||
|
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-withdraw-back"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label="返回"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||||
|
arrow_back
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<h1 className="app-page-title" style={{ margin: 0 }}>
|
||||||
|
结算提现
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="shop-records-main">
|
||||||
|
<section className="shop-records-summary">
|
||||||
|
<div className="shop-records-summary-grid">
|
||||||
|
<div>
|
||||||
|
<p className="shop-records-summary-label">可提未出账余额</p>
|
||||||
|
<p className="shop-records-summary-value">
|
||||||
|
¥ {formatMoney(summary?.availableAmount ?? 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="shop-records-summary-label">今日剩余额度</p>
|
||||||
|
<p className="shop-records-summary-value">
|
||||||
|
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="shop-records-summary-note">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined shop-fill-icon"
|
||||||
|
style={{ fontSize: 16, color: 'var(--color-success-green)' }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)}
|
||||||
|
{summary && !summary.whitelistEnabled ? ' · 未开通未出账提现白名单' : ''}
|
||||||
|
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{summary && !summary.isPrimary ? (
|
||||||
|
<p className="shop-records-empty">仅主账号可申请提现,店员可查看记录</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-withdraw-btn"
|
||||||
|
disabled={!canApply}
|
||||||
|
onClick={() => void applyWithdraw()}
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : '申请提现'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
|
||||||
|
|
||||||
|
<nav className="shop-records-filters" style={{ marginTop: 16 }}>
|
||||||
|
<div className="shop-records-status-chips">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['all', '全部'],
|
||||||
|
['PENDING_REVIEW', '待审核'],
|
||||||
|
['PAID', '已结算'],
|
||||||
|
['REJECTED', '已驳回'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||||
|
onClick={() => setStatusFilter(key)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="shop-records-list-head">
|
||||||
|
<h3 className="shop-records-list-title">提现记录</h3>
|
||||||
|
<span className="shop-records-list-count">共 {filtered.length} 笔</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="shop-records-empty">暂无提现记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="shop-records-list">
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const status = r.status as StoreWithdrawStatus;
|
||||||
|
const badgeClass =
|
||||||
|
status === 'PAID' ? 'paid' : status === 'REJECTED' ? 'rejected' : 'pending';
|
||||||
|
return (
|
||||||
|
<article key={r.id} className="shop-record-card">
|
||||||
|
<div className="shop-record-card-top">
|
||||||
|
<div>
|
||||||
|
<div className="shop-record-order">
|
||||||
|
<span className="shop-record-time" style={{ margin: 0 }}>
|
||||||
|
单号
|
||||||
|
</span>
|
||||||
|
<span>{r.withdrawNo}</span>
|
||||||
|
</div>
|
||||||
|
<p className="shop-record-time">
|
||||||
|
申请时间:{' '}
|
||||||
|
{new Date(r.appliedAt)
|
||||||
|
.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
.slice(0, 16)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`shop-record-badge ${badgeClass}`}>
|
||||||
|
{STORE_WITHDRAW_STATUS_LABELS[status] ?? status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="shop-record-amounts">
|
||||||
|
<div>
|
||||||
|
<p className="shop-record-amount-label">提现金额</p>
|
||||||
|
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="shop-record-amount-label">明细笔数</p>
|
||||||
|
<p className="shop-record-amount-value">{r.payoutCount} 笔</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status === 'REJECTED' && r.rejectReason ? (
|
||||||
|
<div className="shop-record-footer">
|
||||||
|
<p>驳回原因: {r.rejectReason}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{status === 'PAID' && r.paidAt ? (
|
||||||
|
<div className="shop-record-footer">
|
||||||
|
<p>
|
||||||
|
结算时间:{' '}
|
||||||
|
{new Date(r.paidAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1714,6 +1714,46 @@
|
|||||||
color: var(--color-aged-amber);
|
color: var(--color-aged-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-record-badge.rejected {
|
||||||
|
background: rgba(180, 35, 24, 0.1);
|
||||||
|
color: var(--color-error-red, #b42318);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-withdraw-back {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-withdraw-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-primary, #8b1a1a);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-withdraw-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-withdraw-msg {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-aged-amber);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-record-amounts {
|
.shop-record-amounts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
classifyRedeemClientError,
|
classifyRedeemClientError,
|
||||||
generateRedeemPendingNo,
|
generateRedeemPendingNo,
|
||||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
|
sumUnbilledPayoutAmount,
|
||||||
|
validateStoreWithdraw,
|
||||||
|
pickPayoutsForWithdrawAmount,
|
||||||
} from './index';
|
} from './index';
|
||||||
|
|
||||||
describe('calcBenefitAmount', () => {
|
describe('calcBenefitAmount', () => {
|
||||||
@@ -235,3 +238,58 @@ describe('generateRedeemPendingNo', () => {
|
|||||||
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
|
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sumUnbilledPayoutAmount', () => {
|
||||||
|
it('sums payout amounts', () => {
|
||||||
|
expect(sumUnbilledPayoutAmount([{ payoutAmount: 60 }, { payoutAmount: 40.5 }])).toBe(100.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateStoreWithdraw', () => {
|
||||||
|
const base = {
|
||||||
|
whitelistEnabled: true,
|
||||||
|
availableAmount: 1000,
|
||||||
|
requestAmount: 200,
|
||||||
|
todayApplied: 0,
|
||||||
|
dailyLimit: 5000,
|
||||||
|
hasPendingRequest: false,
|
||||||
|
hasBankAccount: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('rejects non-whitelist stores', () => {
|
||||||
|
expect(validateStoreWithdraw({ ...base, whitelistEnabled: false }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when daily limit exceeded', () => {
|
||||||
|
expect(
|
||||||
|
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects pending request / missing bank / over available', () => {
|
||||||
|
expect(validateStoreWithdraw({ ...base, hasPendingRequest: true }).ok).toBe(false);
|
||||||
|
expect(validateStoreWithdraw({ ...base, hasBankAccount: false }).ok).toBe(false);
|
||||||
|
expect(validateStoreWithdraw({ ...base, requestAmount: 1001 }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid request', () => {
|
||||||
|
expect(validateStoreWithdraw(base).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickPayoutsForWithdrawAmount', () => {
|
||||||
|
it('picks FIFO exact match', () => {
|
||||||
|
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }, { payoutAmount: 30 }];
|
||||||
|
const r = pickPayoutsForWithdrawAmount(rows, 100);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.selected).toHaveLength(2);
|
||||||
|
expect(r.amount).toBe(100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-exact match', () => {
|
||||||
|
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }];
|
||||||
|
expect(pickPayoutsForWithdrawAmount(rows, 50).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,6 +96,85 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
|
|||||||
return Math.round(amount * settlementRate * 100) / 100;
|
return Math.round(amount * settlementRate * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>): number {
|
||||||
|
return Math.round(payouts.reduce((sum, p) => sum + Number(p.payoutAmount || 0), 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateStoreWithdrawInput = {
|
||||||
|
whitelistEnabled: boolean;
|
||||||
|
availableAmount: number;
|
||||||
|
requestAmount: number;
|
||||||
|
todayApplied: number;
|
||||||
|
dailyLimit: number;
|
||||||
|
hasPendingRequest: boolean;
|
||||||
|
hasBankAccount: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 门店未出账提现护栏(FIN-001/002 + 幂等/账户) */
|
||||||
|
export function validateStoreWithdraw(
|
||||||
|
input: ValidateStoreWithdrawInput,
|
||||||
|
): { ok: boolean; message?: string } {
|
||||||
|
if (!input.whitelistEnabled) {
|
||||||
|
return { ok: false, message: '该门店未开通未出账提现(需总部白名单)' };
|
||||||
|
}
|
||||||
|
if (!input.hasBankAccount) {
|
||||||
|
return { ok: false, message: '请先完善入驻收款账户后再提现' };
|
||||||
|
}
|
||||||
|
if (input.hasPendingRequest) {
|
||||||
|
return { ok: false, message: '已有待审核提现申请,请等待处理完成' };
|
||||||
|
}
|
||||||
|
if (!(input.requestAmount > 0)) {
|
||||||
|
return { ok: false, message: '提现金额必须大于 0' };
|
||||||
|
}
|
||||||
|
if (input.requestAmount > input.availableAmount + 1e-9) {
|
||||||
|
return { ok: false, message: '提现金额不能超过可提未出账余额' };
|
||||||
|
}
|
||||||
|
const remaining = Math.round((input.dailyLimit - input.todayApplied) * 100) / 100;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return { ok: false, message: `已达单店单日提现上限 ¥${input.dailyLimit.toFixed(2)}` };
|
||||||
|
}
|
||||||
|
if (input.requestAmount > remaining + 1e-9) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `超过单日剩余额度 ¥${remaining.toFixed(2)}(上限 ¥${input.dailyLimit.toFixed(2)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 FIFO 选取未出账 payout,使合计尽量等于 targetAmount(不超过目标)。
|
||||||
|
* 若无法精确凑齐,返回合计 ≤ target 的最长前缀子集。
|
||||||
|
*/
|
||||||
|
export function pickPayoutsForWithdrawAmount<T extends { payoutAmount: number }>(
|
||||||
|
payoutsAsc: T[],
|
||||||
|
targetAmount: number,
|
||||||
|
): { ok: true; selected: T[]; amount: number } | { ok: false; message: string } {
|
||||||
|
if (!(targetAmount > 0)) return { ok: false, message: '提现金额必须大于 0' };
|
||||||
|
const selected: T[] = [];
|
||||||
|
let sum = 0;
|
||||||
|
for (const p of payoutsAsc) {
|
||||||
|
const next = Math.round((sum + Number(p.payoutAmount)) * 100) / 100;
|
||||||
|
if (next <= targetAmount + 1e-9) {
|
||||||
|
selected.push(p);
|
||||||
|
sum = next;
|
||||||
|
if (Math.abs(sum - targetAmount) < 1e-9) break;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selected.length) {
|
||||||
|
return { ok: false, message: '无可匹配的未出账结算明细,请调整提现金额' };
|
||||||
|
}
|
||||||
|
if (Math.abs(sum - targetAmount) > 1e-9) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `无法按 ¥${targetAmount.toFixed(2)} 精确匹配明细,请按可提总额全额提现或调整金额`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, selected, amount: sum };
|
||||||
|
}
|
||||||
|
|
||||||
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
export type BusinessHoursSegment = { open: string; close: string };
|
export type BusinessHoursSegment = { open: string; close: string };
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
||||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||||
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_finance', label: '财务结算', group: '系统设置' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -61,6 +62,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
|||||||
app: 'system_settings_app',
|
app: 'system_settings_app',
|
||||||
deploy: 'system_settings_deploy',
|
deploy: 'system_settings_deploy',
|
||||||
winery_bank: 'system_settings_winery_bank',
|
winery_bank: 'system_settings_winery_bank',
|
||||||
|
finance: 'system_settings_finance',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
|
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
|
||||||
@@ -127,6 +129,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'invoices',
|
'invoices',
|
||||||
'logs',
|
'logs',
|
||||||
'system_settings_winery_bank',
|
'system_settings_winery_bank',
|
||||||
|
'system_settings_finance',
|
||||||
],
|
],
|
||||||
CUSTOMER_SERVICE: [
|
CUSTOMER_SERVICE: [
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
export type FinancePayStatus = 'UNPAID' | 'PAID';
|
export type FinancePayStatus = 'UNPAID' | 'PAID';
|
||||||
|
|
||||||
|
/** 门店未出账提现单日上限默认值(FIN-002,可配置) */
|
||||||
|
export const DEFAULT_STORE_WITHDRAW_DAILY_LIMIT = 5000;
|
||||||
|
|
||||||
|
export type StoreWithdrawStatus = 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
|
||||||
|
|
||||||
|
export const STORE_WITHDRAW_STATUS_LABELS: Record<StoreWithdrawStatus, string> = {
|
||||||
|
PENDING_REVIEW: '待审核',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
PAID: '已结算',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StoreWithdrawBankAccountDto {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreWithdrawSummaryDto {
|
||||||
|
availableAmount: number;
|
||||||
|
pendingReviewAmount: number;
|
||||||
|
todayAppliedAmount: number;
|
||||||
|
dailyLimit: number;
|
||||||
|
remainingDailyLimit: number;
|
||||||
|
whitelistEnabled: boolean;
|
||||||
|
isPrimary: boolean;
|
||||||
|
hasBankAccount: boolean;
|
||||||
|
hasPendingRequest: boolean;
|
||||||
|
bankAccount?: StoreWithdrawBankAccountDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreWithdrawRequestDto {
|
||||||
|
id: string;
|
||||||
|
withdrawNo: string;
|
||||||
|
storeId: string;
|
||||||
|
amount: number;
|
||||||
|
payoutCount: number;
|
||||||
|
status: StoreWithdrawStatus;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
appliedAt: string;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
paidAt?: string | null;
|
||||||
|
paymentRef?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorePayoutDto {
|
export interface StorePayoutDto {
|
||||||
id: string;
|
id: string;
|
||||||
redeemAmount: number;
|
redeemAmount: number;
|
||||||
|
|||||||
+79
-10
@@ -135,38 +135,107 @@ async function main() {
|
|||||||
'/shop/auth/login/sms',
|
'/shop/auth/login/sms',
|
||||||
'/shop/auth/sms/send',
|
'/shop/auth/sms/send',
|
||||||
);
|
);
|
||||||
const shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopLogin.accessToken });
|
let shopToken = shopLogin.accessToken;
|
||||||
|
let shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken });
|
||||||
if (!shopMe?.phone) throw new Error('Shop /shop/auth/me failed');
|
if (!shopMe?.phone) throw new Error('Shop /shop/auth/me failed');
|
||||||
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
|
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ refreshToken: shopLogin.refreshToken }),
|
body: JSON.stringify({ refreshToken: shopLogin.refreshToken }),
|
||||||
});
|
});
|
||||||
if (!refreshed?.accessToken) throw new Error('Shop token refresh failed');
|
if (!refreshed?.accessToken) throw new Error('Shop token refresh failed');
|
||||||
|
shopToken = refreshed.accessToken;
|
||||||
|
shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken });
|
||||||
|
if (!shopMe.storeId && shopMe.stores?.[0]?.storeId) {
|
||||||
|
const selected = await req('SHOP_H5', '/shop/auth/select-store', {
|
||||||
|
method: 'POST',
|
||||||
|
token: shopToken,
|
||||||
|
body: JSON.stringify({ storeId: shopMe.stores[0].storeId }),
|
||||||
|
});
|
||||||
|
shopToken = selected.accessToken || shopToken;
|
||||||
|
shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken });
|
||||||
|
}
|
||||||
|
if (!shopMe.storeId) throw new Error('Shop storeId missing after select-store');
|
||||||
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: shopLogin.accessToken,
|
token: shopToken,
|
||||||
body: JSON.stringify({ token: directToken.token }),
|
body: JSON.stringify({ token: directToken.token }),
|
||||||
});
|
});
|
||||||
if (!preview.amount) throw new Error('Redeem preview failed');
|
if (!preview.amount) throw new Error('Redeem preview failed');
|
||||||
await req('SHOP_H5', '/shop/redeem/confirm', {
|
await req('SHOP_H5', '/shop/redeem/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: shopLogin.accessToken,
|
token: shopToken,
|
||||||
body: JSON.stringify({ token: directToken.token }),
|
body: JSON.stringify({ token: directToken.token }),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('9. Admin login + store payout');
|
console.log('9. Store withdraw (OPT-010)');
|
||||||
const admin = await adminLogin();
|
const admin = await adminLogin();
|
||||||
const payouts = await req('HQ_WEB', '/admin/store-payouts?status=PENDING', {
|
const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId;
|
||||||
token: admin.accessToken,
|
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 (!payouts.items?.length) throw new Error('Expected pending store payout');
|
if (!String(noWhitelistMsg).includes('白名单')) {
|
||||||
const payoutId = payouts.items[0].id;
|
throw new Error(`Expected FIN-001 whitelist reject, got: ${noWhitelistMsg}`);
|
||||||
await req('HQ_WEB', `/admin/store-payouts/${payoutId}/confirm`, {
|
}
|
||||||
|
|
||||||
|
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', {
|
||||||
|
token: shopToken,
|
||||||
|
});
|
||||||
|
if (!(withdrawSummary.availableAmount > 0)) {
|
||||||
|
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', {
|
||||||
|
method: 'POST',
|
||||||
|
token: shopToken,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (applied.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new Error(`Expected PENDING_REVIEW withdraw, got ${applied.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingDup = await expectFail('SHOP_H5', '/shop/withdraw', {
|
||||||
|
method: 'POST',
|
||||||
|
token: shopToken,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!String(pendingDup).includes('待审核')) {
|
||||||
|
throw new Error(`Expected pending-request reject, got: ${pendingDup}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: admin.accessToken,
|
token: admin.accessToken,
|
||||||
body: JSON.stringify({ remark: 'smoke confirm' }),
|
body: JSON.stringify({ paymentRef: 'smoke-withdraw' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const withdrawList = await req('SHOP_H5', '/shop/withdraw/requests?status=PAID', {
|
||||||
|
token: shopToken,
|
||||||
|
});
|
||||||
|
const settled = withdrawList.items?.find((w) => w.id === applied.id);
|
||||||
|
if (!settled || settled.status !== 'PAID') {
|
||||||
|
throw new Error('Expected settled withdraw request on shop side');
|
||||||
|
}
|
||||||
|
|
||||||
|
const overdue = await req('HQ_WEB', '/admin/store-withdrawals/overdue-summary', {
|
||||||
|
token: admin.accessToken,
|
||||||
|
});
|
||||||
|
if (typeof overdue.pendingCount !== 'number') {
|
||||||
|
throw new Error('overdue-summary missing pendingCount');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('10. Refund ticket flow');
|
console.log('10. Refund ticket flow');
|
||||||
const order2 = await req('USER_H5', '/trade/orders', {
|
const order2 = await req('USER_H5', '/trade/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -314,6 +314,12 @@ enum StorePayoutStatus {
|
|||||||
PAID
|
PAID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StoreWithdrawStatus {
|
||||||
|
PENDING_REVIEW
|
||||||
|
REJECTED
|
||||||
|
PAID
|
||||||
|
}
|
||||||
|
|
||||||
enum ThirdPartyProvider {
|
enum ThirdPartyProvider {
|
||||||
WECHAT_PAY
|
WECHAT_PAY
|
||||||
WECHAT_REFUND
|
WECHAT_REFUND
|
||||||
@@ -1064,6 +1070,8 @@ model Store {
|
|||||||
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
||||||
/// Online test: only listed phones can see store on C-end when enabled
|
/// Online test: only listed phones can see store on C-end when enabled
|
||||||
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
||||||
|
/// FIN-001:允许未出账手动提现的白名单门店
|
||||||
|
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1077,6 +1085,7 @@ model Store {
|
|||||||
ratings StoreRating[]
|
ratings StoreRating[]
|
||||||
payouts StorePayout[]
|
payouts StorePayout[]
|
||||||
storeBills StoreBill[]
|
storeBills StoreBill[]
|
||||||
|
withdrawRequests StoreWithdrawRequest[]
|
||||||
visibilityPhones StoreVisibilityPhone[]
|
visibilityPhones StoreVisibilityPhone[]
|
||||||
|
|
||||||
@@index([cityId, status])
|
@@index([cityId, status])
|
||||||
@@ -1121,6 +1130,7 @@ model StoreAccount {
|
|||||||
childAccounts StoreAccount[] @relation("StoreAccountHierarchy")
|
childAccounts StoreAccount[] @relation("StoreAccountHierarchy")
|
||||||
bindings StoreAccountStore[]
|
bindings StoreAccountStore[]
|
||||||
redeemPendingRecords RedeemPendingRecord[]
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
|
withdrawRequests StoreWithdrawRequest[]
|
||||||
|
|
||||||
@@index([parentAccountId])
|
@@index([parentAccountId])
|
||||||
@@map("store_account")
|
@@map("store_account")
|
||||||
@@ -1432,12 +1442,52 @@ model StorePayout {
|
|||||||
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
|
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
|
||||||
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
storeBill StoreBill? @relation(fields: [storeBillId], references: [id], onDelete: SetNull)
|
storeBill StoreBill? @relation(fields: [storeBillId], references: [id], onDelete: SetNull)
|
||||||
|
withdrawItem StoreWithdrawPayoutItem?
|
||||||
|
|
||||||
@@index([storeId, status])
|
@@index([storeId, status])
|
||||||
@@index([storeBillId])
|
@@index([storeBillId])
|
||||||
@@map("store_payout")
|
@@map("store_payout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StoreWithdrawRequest {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
withdrawNo String @unique @map("withdraw_no") @db.VarChar(32)
|
||||||
|
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||||
|
storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
payoutCount Int @default(0) @map("payout_count")
|
||||||
|
status StoreWithdrawStatus @default(PENDING_REVIEW)
|
||||||
|
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||||
|
appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3)
|
||||||
|
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||||
|
reviewedByHqId BigInt? @map("reviewed_by_hq_id") @db.UnsignedBigInt
|
||||||
|
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||||
|
paymentRef String? @map("payment_ref") @db.VarChar(128)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
|
storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Restrict)
|
||||||
|
items StoreWithdrawPayoutItem[]
|
||||||
|
|
||||||
|
@@index([storeId, status])
|
||||||
|
@@index([status, appliedAt])
|
||||||
|
@@map("store_withdraw_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StoreWithdrawPayoutItem {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
withdrawRequestId BigInt @map("withdraw_request_id") @db.UnsignedBigInt
|
||||||
|
storePayoutId BigInt @unique @map("store_payout_id") @db.UnsignedBigInt
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
withdrawRequest StoreWithdrawRequest @relation(fields: [withdrawRequestId], references: [id], onDelete: Cascade)
|
||||||
|
storePayout StorePayout @relation(fields: [storePayoutId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([withdrawRequestId])
|
||||||
|
@@map("store_withdraw_payout_item")
|
||||||
|
}
|
||||||
|
|
||||||
model WineryBill {
|
model WineryBill {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
billNo String @unique @map("bill_no") @db.VarChar(32)
|
billNo String @unique @map("bill_no") @db.VarChar(32)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.module';
|
||||||
|
import type { AuthUser } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
/** 门店主账号专用(提现等资金操作) */
|
||||||
|
@Injectable()
|
||||||
|
export class ShopPrimaryGuard implements CanActivate {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req.user as AuthUser | undefined;
|
||||||
|
if (!user || user.actorType !== 'STORE') {
|
||||||
|
throw new ForbiddenException('仅门店主账号可操作');
|
||||||
|
}
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { id: user.actorId },
|
||||||
|
select: { isPrimary: true },
|
||||||
|
});
|
||||||
|
if (!account || account.isPrimary !== 1) {
|
||||||
|
throw new ForbiddenException('仅门店主账号可申请提现');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ export const HqOperationAction = {
|
|||||||
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
||||||
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
||||||
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
|
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
|
||||||
|
STORE_WITHDRAW_APPROVE: 'STORE_WITHDRAW_APPROVE',
|
||||||
|
STORE_WITHDRAW_REJECT: 'STORE_WITHDRAW_REJECT',
|
||||||
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
|
||||||
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
|
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
|
||||||
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
|
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
|
||||||
@@ -164,6 +166,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
||||||
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
||||||
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
|
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
|
||||||
|
[HqOperationAction.STORE_WITHDRAW_APPROVE]: '门店提现审核通过',
|
||||||
|
[HqOperationAction.STORE_WITHDRAW_REJECT]: '门店提现驳回',
|
||||||
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
|
||||||
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
|
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
|
||||||
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
|
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
|||||||
{ key: 'app', label: '应用链接' },
|
{ key: 'app', label: '应用链接' },
|
||||||
{ key: 'deploy', label: '发布部署' },
|
{ key: 'deploy', label: '发布部署' },
|
||||||
{ key: 'winery_bank', label: '酒厂银行账户' },
|
{ key: 'winery_bank', label: '酒厂银行账户' },
|
||||||
|
{ key: 'finance', label: '财务结算' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const G = {
|
const G = {
|
||||||
@@ -21,6 +22,7 @@ const G = {
|
|||||||
app: 'app',
|
app: 'app',
|
||||||
deploy: 'deploy',
|
deploy: 'deploy',
|
||||||
winery_bank: 'winery_bank',
|
winery_bank: 'winery_bank',
|
||||||
|
finance: 'finance',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
|
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
|
||||||
@@ -187,6 +189,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'STORE_WITHDRAW_DAILY_LIMIT',
|
||||||
|
label: '门店未出账提现单日上限(元)',
|
||||||
|
group: G.finance,
|
||||||
|
type: 'number',
|
||||||
|
requiresRestart: false,
|
||||||
|
description: 'FIN-002:单店单日提现上限,默认 5000',
|
||||||
|
placeholder: '5000',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
|
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AlertService } from '../common/alert/alert.service';
|
|||||||
* 财务对账单定时任务(Asia/Shanghai)
|
* 财务对账单定时任务(Asia/Shanghai)
|
||||||
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00)
|
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00)
|
||||||
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
|
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
|
||||||
|
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003)
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettlementScheduler {
|
export class SettlementScheduler {
|
||||||
@@ -48,6 +49,34 @@ export class SettlementScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FIN-003:工作日 18:05 扫描超时未审门店提现 */
|
||||||
|
@Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' })
|
||||||
|
async handleWithdrawOverdueAlert() {
|
||||||
|
this.logger.log('Store withdraw overdue scan start');
|
||||||
|
try {
|
||||||
|
const summary = await this.settlementService.scanOverdueStoreWithdrawals();
|
||||||
|
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) {
|
||||||
|
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
|
||||||
|
this.alert.notify({
|
||||||
|
level: 'P1',
|
||||||
|
category: 'job',
|
||||||
|
title: '门店提现超时扫描失败',
|
||||||
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
|
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' })
|
@Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' })
|
||||||
async handleMonthlyPartnerBills() {
|
async handleMonthlyPartnerBills() {
|
||||||
this.logger.log('Monthly partner bills job start');
|
this.logger.log('Monthly partner bills job start');
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
|||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||||
|
import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard';
|
||||||
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
||||||
import {
|
import {
|
||||||
HqPermissionGuard,
|
HqPermissionGuard,
|
||||||
@@ -63,6 +64,7 @@ import { CommonModule } from '../common/common.module';
|
|||||||
PartnerPrimaryGuard,
|
PartnerPrimaryGuard,
|
||||||
PartnerPermissionGuard,
|
PartnerPermissionGuard,
|
||||||
ShopStoreGuard,
|
ShopStoreGuard,
|
||||||
|
ShopPrimaryGuard,
|
||||||
HqPermissionsResolver,
|
HqPermissionsResolver,
|
||||||
HqPermissionGuard,
|
HqPermissionGuard,
|
||||||
],
|
],
|
||||||
@@ -80,6 +82,7 @@ import { CommonModule } from '../common/common.module';
|
|||||||
PartnerPrimaryGuard,
|
PartnerPrimaryGuard,
|
||||||
PartnerPermissionGuard,
|
PartnerPermissionGuard,
|
||||||
ShopStoreGuard,
|
ShopStoreGuard,
|
||||||
|
ShopPrimaryGuard,
|
||||||
HqPermissionsResolver,
|
HqPermissionsResolver,
|
||||||
HqPermissionGuard,
|
HqPermissionGuard,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ function num(v: Prisma.Decimal | number | string | null | undefined): number {
|
|||||||
return typeof v === 'number' ? v : Number(v);
|
return typeof v === 'number' ? v : Number(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
||||||
|
const day = appliedAt.getDay();
|
||||||
|
if (day === 0 || day === 6) return false;
|
||||||
|
const deadline = new Date(
|
||||||
|
appliedAt.getFullYear(),
|
||||||
|
appliedAt.getMonth(),
|
||||||
|
appliedAt.getDate(),
|
||||||
|
18,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return now.getTime() > deadline.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminDashboardService {
|
export class AdminDashboardService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -63,6 +78,7 @@ export class AdminDashboardService {
|
|||||||
pendingBills,
|
pendingBills,
|
||||||
pendingPartnerDraftBills,
|
pendingPartnerDraftBills,
|
||||||
openTickets,
|
openTickets,
|
||||||
|
pendingWithdrawRows,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||||
this.prisma.user.count({
|
this.prisma.user.count({
|
||||||
@@ -85,8 +101,18 @@ export class AdminDashboardService {
|
|||||||
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
|
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
|
||||||
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
|
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||||
|
this.prisma.storeWithdrawRequest.findMany({
|
||||||
|
where: { status: 'PENDING_REVIEW' },
|
||||||
|
select: { appliedAt: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const pendingStoreWithdrawals = pendingWithdrawRows.length;
|
||||||
|
const overdueStoreWithdrawals = pendingWithdrawRows.filter((r) =>
|
||||||
|
isWithdrawOverdue(r.appliedAt, now),
|
||||||
|
).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
usersTotal,
|
usersTotal,
|
||||||
guestUsers,
|
guestUsers,
|
||||||
@@ -101,6 +127,8 @@ export class AdminDashboardService {
|
|||||||
pendingBills,
|
pendingBills,
|
||||||
pendingPartnerDraftBills,
|
pendingPartnerDraftBills,
|
||||||
openTickets,
|
openTickets,
|
||||||
|
pendingStoreWithdrawals,
|
||||||
|
overdueStoreWithdrawals,
|
||||||
ordersByStatus: ordersByStatus.map((row) => ({
|
ordersByStatus: ordersByStatus.map((row) => ({
|
||||||
status: row.status,
|
status: row.status,
|
||||||
count: row._count.status,
|
count: row._count.status,
|
||||||
|
|||||||
@@ -357,6 +357,9 @@ 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 } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -516,6 +519,7 @@ 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,6 +138,11 @@ 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 {
|
||||||
@@ -237,6 +242,11 @@ 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 {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SettlementService } from './settlement.service';
|
|||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||||
|
import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
@@ -59,6 +60,98 @@ export class ShopPayoutController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('shop/withdraw')
|
||||||
|
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||||
|
export class ShopWithdrawController {
|
||||||
|
constructor(private readonly settlementService: SettlementService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
summary(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.settlementService.getShopWithdrawSummary(user.actorId, user.storeId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('requests')
|
||||||
|
requests(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('pageSize') pageSize = '20',
|
||||||
|
@Query('status') status?: string,
|
||||||
|
) {
|
||||||
|
return this.settlementService.listShopWithdrawRequests(user.actorId, user.storeId!, {
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(ShopPrimaryGuard)
|
||||||
|
apply(@CurrentUser() user: AuthUser, @Body() body: { amount?: number }) {
|
||||||
|
return this.settlementService.createStoreWithdrawRequest(user.actorId, user.storeId!, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/store-withdrawals')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminStoreWithdrawController {
|
||||||
|
constructor(private readonly settlementService: SettlementService) {}
|
||||||
|
|
||||||
|
@Get('overdue-summary')
|
||||||
|
overdueSummary() {
|
||||||
|
return this.settlementService.getStoreWithdrawOverdueSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: Record<string, string>) {
|
||||||
|
return this.settlementService.listAdminStoreWithdrawals({
|
||||||
|
page: query.page ? Number(query.page) : 1,
|
||||||
|
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||||
|
status: query.status,
|
||||||
|
storeId: query.storeId,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.settlementService.getAdminStoreWithdrawal(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_WITHDRAW_APPROVE,
|
||||||
|
refType: 'STORE_WITHDRAW',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
approve(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { paymentRef?: string },
|
||||||
|
) {
|
||||||
|
return this.settlementService.approveStoreWithdraw(BigInt(id), user.actorId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_WITHDRAW_REJECT,
|
||||||
|
refType: 'STORE_WITHDRAW',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
reject(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { reason?: string },
|
||||||
|
) {
|
||||||
|
if (!body?.reason?.trim()) {
|
||||||
|
throw new BadRequestException('请填写驳回理由');
|
||||||
|
}
|
||||||
|
return this.settlementService.rejectStoreWithdraw(BigInt(id), user.actorId, body.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('admin/store-payouts')
|
@Controller('admin/store-payouts')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
export class AdminStorePayoutController {
|
export class AdminStorePayoutController {
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
AdminStoreBillController,
|
AdminStoreBillController,
|
||||||
AdminStorePayoutController,
|
AdminStorePayoutController,
|
||||||
|
AdminStoreWithdrawController,
|
||||||
AdminWineryBillController,
|
AdminWineryBillController,
|
||||||
PartnerMeController,
|
PartnerMeController,
|
||||||
SettlementController,
|
SettlementController,
|
||||||
ShopPayoutController,
|
ShopPayoutController,
|
||||||
|
ShopWithdrawController,
|
||||||
} from './settlement.controller';
|
} from './settlement.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -21,6 +23,8 @@ import {
|
|||||||
SettlementController,
|
SettlementController,
|
||||||
PartnerMeController,
|
PartnerMeController,
|
||||||
ShopPayoutController,
|
ShopPayoutController,
|
||||||
|
ShopWithdrawController,
|
||||||
|
AdminStoreWithdrawController,
|
||||||
AdminStorePayoutController,
|
AdminStorePayoutController,
|
||||||
AdminStoreBillController,
|
AdminStoreBillController,
|
||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
import {
|
||||||
import { calcLogisticsFeeByBottles, type LogisticsPricingRule } from '@dukang/domain';
|
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
|
||||||
|
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||||
|
WINERY_SETTLEMENT_RATE,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import {
|
||||||
|
calcLogisticsFeeByBottles,
|
||||||
|
pickPayoutsForWithdrawAmount,
|
||||||
|
sumUnbilledPayoutAmount,
|
||||||
|
validateStoreWithdraw,
|
||||||
|
type LogisticsPricingRule,
|
||||||
|
} 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 { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
@@ -33,6 +43,28 @@ function round2(n: number) {
|
|||||||
return Math.round(n * 100) / 100;
|
return Math.round(n * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoreWithdrawDailyLimit(): number {
|
||||||
|
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
|
||||||
|
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */
|
||||||
|
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
||||||
|
const day = appliedAt.getDay(); // 0 Sun … 6 Sat
|
||||||
|
if (day === 0 || day === 6) return false;
|
||||||
|
const deadline = new Date(
|
||||||
|
appliedAt.getFullYear(),
|
||||||
|
appliedAt.getMonth(),
|
||||||
|
appliedAt.getDate(),
|
||||||
|
18,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return now.getTime() > deadline.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettlementService {
|
export class SettlementService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -99,6 +131,440 @@ export class SettlementService {
|
|||||||
return serializeBigInt({ items, total, page, pageSize });
|
return serializeBigInt({ items, total, page, pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Store withdraw (未出账手动提现) ─────────────────
|
||||||
|
|
||||||
|
private async assertShopStoreAccess(storeAccountId: bigint, storeId: bigint) {
|
||||||
|
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAvailableUnbilledPayouts(storeId: bigint) {
|
||||||
|
return this.prisma.storePayout.findMany({
|
||||||
|
where: {
|
||||||
|
storeId,
|
||||||
|
status: 'PENDING',
|
||||||
|
storeBillId: null,
|
||||||
|
withdrawItem: null,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
|
||||||
|
const start = startOfDay(now);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
const agg = await this.prisma.storeWithdrawRequest.aggregate({
|
||||||
|
where: {
|
||||||
|
storeId,
|
||||||
|
status: { in: ['PENDING_REVIEW', 'PAID'] },
|
||||||
|
appliedAt: { gte: start, lt: end },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
return Number(agg._sum.amount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
|
||||||
|
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||||
|
const [store, account, available, pending, todayApplied] = await Promise.all([
|
||||||
|
this.prisma.store.findUniqueOrThrow({
|
||||||
|
where: { id: storeId },
|
||||||
|
select: { withdrawWhitelistEnabled: true },
|
||||||
|
}),
|
||||||
|
this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
select: {
|
||||||
|
isPrimary: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
bankAccountNo: true,
|
||||||
|
bankBranch: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.listAvailableUnbilledPayouts(storeId),
|
||||||
|
this.prisma.storeWithdrawRequest.findFirst({
|
||||||
|
where: { storeId, status: 'PENDING_REVIEW' },
|
||||||
|
select: { id: true, amount: true },
|
||||||
|
}),
|
||||||
|
this.todayWithdrawAppliedAmount(storeId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const availableAmount = sumUnbilledPayoutAmount(
|
||||||
|
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
||||||
|
);
|
||||||
|
const dailyLimit = getStoreWithdrawDailyLimit();
|
||||||
|
const hasBankAccount = !!(
|
||||||
|
account.bankAccountName?.trim() &&
|
||||||
|
account.bankAccountNo?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableAmount,
|
||||||
|
pendingReviewAmount: pending ? Number(pending.amount) : 0,
|
||||||
|
todayAppliedAmount: todayApplied,
|
||||||
|
dailyLimit,
|
||||||
|
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
|
||||||
|
whitelistEnabled: store.withdrawWhitelistEnabled,
|
||||||
|
isPrimary: account.isPrimary === 1,
|
||||||
|
hasBankAccount,
|
||||||
|
hasPendingRequest: !!pending,
|
||||||
|
bankAccount: {
|
||||||
|
bankAccountName: account.bankAccountName,
|
||||||
|
bankAccountNo: account.bankAccountNo,
|
||||||
|
bankBranch: account.bankBranch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStoreWithdrawRequest(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
dto?: { amount?: number },
|
||||||
|
) {
|
||||||
|
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||||
|
|
||||||
|
const [store, account, available, pending, todayApplied] = await Promise.all([
|
||||||
|
this.prisma.store.findUniqueOrThrow({
|
||||||
|
where: { id: storeId },
|
||||||
|
select: { withdrawWhitelistEnabled: true },
|
||||||
|
}),
|
||||||
|
this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
select: {
|
||||||
|
isPrimary: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
bankAccountNo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.listAvailableUnbilledPayouts(storeId),
|
||||||
|
this.prisma.storeWithdrawRequest.findFirst({
|
||||||
|
where: { storeId, status: 'PENDING_REVIEW' },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
this.todayWithdrawAppliedAmount(storeId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (account.isPrimary !== 1) {
|
||||||
|
throw new BadRequestException('仅门店主账号可申请提现');
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableAmount = sumUnbilledPayoutAmount(
|
||||||
|
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
||||||
|
);
|
||||||
|
const dailyLimit = getStoreWithdrawDailyLimit();
|
||||||
|
const hasBankAccount = !!(
|
||||||
|
account.bankAccountName?.trim() &&
|
||||||
|
account.bankAccountNo?.trim()
|
||||||
|
);
|
||||||
|
const requestAmount =
|
||||||
|
dto?.amount != null && Number.isFinite(Number(dto.amount))
|
||||||
|
? round2(Number(dto.amount))
|
||||||
|
: availableAmount;
|
||||||
|
|
||||||
|
const guard = validateStoreWithdraw({
|
||||||
|
whitelistEnabled: store.withdrawWhitelistEnabled,
|
||||||
|
availableAmount,
|
||||||
|
requestAmount,
|
||||||
|
todayApplied,
|
||||||
|
dailyLimit,
|
||||||
|
hasPendingRequest: !!pending,
|
||||||
|
hasBankAccount,
|
||||||
|
});
|
||||||
|
if (!guard.ok) throw new BadRequestException(guard.message);
|
||||||
|
|
||||||
|
const picked = pickPayoutsForWithdrawAmount(
|
||||||
|
available.map((p) => ({ id: p.id, payoutAmount: Number(p.payoutAmount) })),
|
||||||
|
requestAmount,
|
||||||
|
);
|
||||||
|
if (!picked.ok) throw new BadRequestException(picked.message);
|
||||||
|
|
||||||
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const stillPending = await tx.storeWithdrawRequest.findFirst({
|
||||||
|
where: { storeId, status: 'PENDING_REVIEW' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (stillPending) {
|
||||||
|
throw new BadRequestException('已有待审核提现申请,请等待处理完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payoutIds = picked.selected.map((p) => p.id);
|
||||||
|
const locked = await tx.storePayout.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: payoutIds },
|
||||||
|
storeId,
|
||||||
|
status: 'PENDING',
|
||||||
|
storeBillId: null,
|
||||||
|
withdrawItem: null,
|
||||||
|
},
|
||||||
|
select: { id: true, payoutAmount: true },
|
||||||
|
});
|
||||||
|
if (locked.length !== payoutIds.length) {
|
||||||
|
throw new BadRequestException('可提余额已变化,请刷新后重试');
|
||||||
|
}
|
||||||
|
const amount = round2(locked.reduce((s, p) => s + Number(p.payoutAmount), 0));
|
||||||
|
|
||||||
|
const req = await tx.storeWithdrawRequest.create({
|
||||||
|
data: {
|
||||||
|
withdrawNo: generateBillNo('SW'),
|
||||||
|
storeId,
|
||||||
|
storeAccountId,
|
||||||
|
amount,
|
||||||
|
payoutCount: locked.length,
|
||||||
|
status: 'PENDING_REVIEW',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.storeWithdrawPayoutItem.createMany({
|
||||||
|
data: locked.map((p) => ({
|
||||||
|
withdrawRequestId: req.id,
|
||||||
|
storePayoutId: p.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||||||
|
storeId,
|
||||||
|
eventName: 'store_withdraw_applied',
|
||||||
|
refType: 'STORE_WITHDRAW',
|
||||||
|
refId: created.id,
|
||||||
|
extraJson: {
|
||||||
|
amount: Number(created.amount),
|
||||||
|
payoutCount: created.payoutCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listShopWithdrawRequests(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
query: { page?: number; pageSize?: number; status?: string },
|
||||||
|
) {
|
||||||
|
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: Prisma.StoreWithdrawRequestWhereInput = { storeId };
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.storeWithdrawRequest.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { appliedAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.storeWithdrawRequest.count({ where }),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({ items, total, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminStoreWithdrawals(query: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
status?: string;
|
||||||
|
storeId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: Prisma.StoreWithdrawRequestWhereInput = {};
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total, aggregates] = await Promise.all([
|
||||||
|
this.prisma.storeWithdrawRequest.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ appliedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.storeWithdrawRequest.count({ where }),
|
||||||
|
this.prisma.storeWithdrawRequest.aggregate({
|
||||||
|
where,
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const mapped = items.map((row) => ({
|
||||||
|
...row,
|
||||||
|
overdue:
|
||||||
|
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt, now) : false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items: mapped,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
summary: {
|
||||||
|
count: aggregates._count,
|
||||||
|
totalAmount: Number(aggregates._sum.amount ?? 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminStoreWithdrawal(id: bigint) {
|
||||||
|
const row = await this.prisma.storeWithdrawRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
cityName: true,
|
||||||
|
phone: true,
|
||||||
|
withdrawWhitelistEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storeAccount: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
bankAccountNo: true,
|
||||||
|
bankBranch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
storePayout: {
|
||||||
|
include: {
|
||||||
|
redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('提现申请不存在');
|
||||||
|
return serializeBigInt({
|
||||||
|
...row,
|
||||||
|
overdue:
|
||||||
|
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveStoreWithdraw(
|
||||||
|
id: bigint,
|
||||||
|
hqAccountId: bigint,
|
||||||
|
dto?: { paymentRef?: string },
|
||||||
|
) {
|
||||||
|
const row = await this.prisma.storeWithdrawRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { items: { select: { storePayoutId: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('提现申请不存在');
|
||||||
|
if (row.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new BadRequestException('仅待审核提现可审核通过');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidAt = new Date();
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const req = await tx.storeWithdrawRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'PAID',
|
||||||
|
reviewedAt: paidAt,
|
||||||
|
reviewedByHqId: hqAccountId,
|
||||||
|
paidAt,
|
||||||
|
paymentRef: dto?.paymentRef?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.storePayout.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: row.items.map((i) => i.storePayoutId) },
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
data: { status: 'PAID', paidAt },
|
||||||
|
});
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', {
|
||||||
|
storeId: row.storeId,
|
||||||
|
eventName: 'store_withdraw_paid',
|
||||||
|
refType: 'STORE_WITHDRAW',
|
||||||
|
refId: id,
|
||||||
|
extraJson: {
|
||||||
|
amount: Number(row.amount),
|
||||||
|
paymentRef: dto?.paymentRef,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectStoreWithdraw(id: bigint, hqAccountId: bigint, reason: string) {
|
||||||
|
const row = await this.prisma.storeWithdrawRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('提现申请不存在');
|
||||||
|
if (row.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new BadRequestException('仅待审核提现可驳回');
|
||||||
|
}
|
||||||
|
const rejectReason = reason.trim();
|
||||||
|
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const req = await tx.storeWithdrawRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectReason,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedByHqId: hqAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 释放 payout 锁定,允许再次提现
|
||||||
|
await tx.storeWithdrawPayoutItem.deleteMany({ where: { withdrawRequestId: id } });
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStoreWithdrawOverdueSummary() {
|
||||||
|
const pending = await this.prisma.storeWithdrawRequest.findMany({
|
||||||
|
where: { status: 'PENDING_REVIEW' },
|
||||||
|
select: { id: true, appliedAt: true, amount: true },
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
const overdue = pending.filter((r) => isWithdrawOverdue(r.appliedAt, now));
|
||||||
|
return {
|
||||||
|
pendingCount: pending.length,
|
||||||
|
overdueCount: overdue.length,
|
||||||
|
overdueAmount: round2(overdue.reduce((s, r) => s + Number(r.amount), 0)),
|
||||||
|
pendingAmount: round2(pending.reduce((s, r) => s + Number(r.amount), 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FIN-003:工作日 18:00 扫描超时未审提现 */
|
||||||
|
async scanOverdueStoreWithdrawals() {
|
||||||
|
const summary = await this.getStoreWithdrawOverdueSummary();
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
async listAdminStorePayouts(query: {
|
async listAdminStorePayouts(query: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -277,6 +743,9 @@ export class SettlementService {
|
|||||||
where: {
|
where: {
|
||||||
storeBillId: null,
|
storeBillId: null,
|
||||||
createdAt: { gte: start, lt: end },
|
createdAt: { gte: start, lt: end },
|
||||||
|
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
|
||||||
|
withdrawItem: null,
|
||||||
|
status: 'PENDING',
|
||||||
},
|
},
|
||||||
include: { store: { select: { id: true, settlementRate: true } } },
|
include: { store: { select: { id: true, settlementRate: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user