Compare commits
3 Commits
450dc44d0e
...
9403d22544
| Author | SHA1 | Date | |
|---|---|---|---|
| 9403d22544 | |||
| e0f5977dd9 | |||
| d61a43cc8c |
@@ -28,6 +28,7 @@ import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
@@ -79,9 +80,12 @@ export default function App() {
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||
<Route path="/store-bills" element={<StoreBillsPage />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FileTextOutlined,
|
||||
LockOutlined,
|
||||
SettingOutlined,
|
||||
AccountBookOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
@@ -44,7 +45,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-bills', label: '门店账单' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
],
|
||||
},
|
||||
@@ -56,7 +56,16 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/cities', label: '城市' },
|
||||
{ key: '/city-partners', label: '城市合伙人' },
|
||||
{ key: '/city-warehouses', label: '仓库' },
|
||||
{ key: '/partner-bills', label: '合伙人结算' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <AccountBookOutlined />,
|
||||
label: '财务',
|
||||
children: [
|
||||
{ key: '/finance/store-bills', label: '门店账单' },
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -144,7 +153,7 @@ export default function AdminLayout() {
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
||||
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
||||
items={MENU_ITEMS}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('/')) navigate(key);
|
||||
|
||||
@@ -57,6 +57,16 @@ export type Paginated<T> = {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary?: {
|
||||
count: number;
|
||||
redeemAmount?: number;
|
||||
payoutAmount?: number;
|
||||
orderAmount?: number;
|
||||
orderCommission?: number;
|
||||
redeemCommission?: number;
|
||||
wineryAmount?: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type DashboardStats = {
|
||||
|
||||
@@ -38,6 +38,12 @@ export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||
CLOSED: '已关闭',
|
||||
};
|
||||
|
||||
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const ACCOUNT_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '正常',
|
||||
DISABLED: '停用',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */
|
||||
export function downloadExcelCsv(csv: string, filename: string) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -15,34 +29,66 @@ type Row = {
|
||||
status: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
partner?: { companyName: string };
|
||||
partner?: { companyName?: string; phone?: string };
|
||||
partnerAccount?: { companyName?: string; phone?: string };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
CONFIRMED: '已确认',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
CONFIRMED: 'blue',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
export default function PartnerBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) {
|
||||
await request('/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: values.partnerId,
|
||||
year: values.month.year(),
|
||||
month: values.month.month() + 1,
|
||||
}),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
useEffect(() => {
|
||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setPartners(res.items))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
|
||||
const year = values.month.year();
|
||||
const month = values.month.month() + 1;
|
||||
if (values.all || !values.partnerId) {
|
||||
const result = await request<{ success: number; failed: number; total: number }>(
|
||||
'/admin/partner-bills/generate-all',
|
||||
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
||||
);
|
||||
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
|
||||
} else {
|
||||
await request('/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
}
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
@@ -62,61 +108,216 @@ export default function PartnerBillsPage() {
|
||||
reload();
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const result = await request<{ csv: string }>('/admin/partner-bills/export');
|
||||
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'partner-bills.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
|
||||
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '操作', width: 180,
|
||||
title: '合伙人',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, r) => partnerName(r),
|
||||
},
|
||||
{
|
||||
title: '账期',
|
||||
width: 200,
|
||||
render: (_, r) =>
|
||||
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
|
||||
},
|
||||
{
|
||||
title: '酒单佣金',
|
||||
dataIndex: 'orderCommission',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销佣金',
|
||||
dataIndex: 'redeemCommission',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '合计应付',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<>
|
||||
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}>确认</Button>}
|
||||
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}>标记打款</Button>}
|
||||
</>
|
||||
<Space size={0}>
|
||||
{row.status === 'DRAFT' && (
|
||||
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
{row.status === 'CONFIRMED' && (
|
||||
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
||||
标记打款
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>合伙人结算(T+30)</Typography.Title>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Button type="primary" onClick={() => setGenOpen(true)}>生成账单</Button>
|
||||
<Button onClick={exportCsv}>导出 CSV</Button>
|
||||
</div>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'PAID', label: '已打款' },
|
||||
]} />
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
合伙人账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:按自然月汇总酒单佣金(酒单价 × 酒单佣金比例)与门店核销佣金(核销额 × 核销佣金比例)
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
|
||||
setFilters({
|
||||
partnerId: v.partnerId || '',
|
||||
status: v.status || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="partnerId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="全部合伙人"
|
||||
style={{ width: 220 }}
|
||||
optionFilterProp="label"
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.phone || p.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={() => setGenOpen(true)}>
|
||||
生成账单
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form form={genForm} layout="vertical" onFinish={generateBill}>
|
||||
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>生成</Button>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal title="生成合伙人账单(T+30)" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form
|
||||
form={genForm}
|
||||
layout="vertical"
|
||||
initialValues={{ month: dayjs().subtract(1, 'month') }}
|
||||
onFinish={generateBill}
|
||||
>
|
||||
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
|
||||
<DatePicker picker="month" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部主合伙人"
|
||||
options={partners.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.companyName || p.phone || p.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
生成
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -15,7 +31,7 @@ type Row = {
|
||||
paidAt?: string;
|
||||
createdAt: string;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||
redeemRecord?: { redeemNo: string; amount?: number };
|
||||
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string; phone: string };
|
||||
@@ -40,12 +56,15 @@ export default function StoreBillsPage() {
|
||||
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 [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -62,7 +81,33 @@ export default function StoreBillsPage() {
|
||||
reload();
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
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);
|
||||
const result = await request<{ csv: string; count: number }>(
|
||||
`/admin/store-payouts/export?${qs}`,
|
||||
);
|
||||
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '核销日期',
|
||||
width: 110,
|
||||
render: (_, r) =>
|
||||
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||
@@ -75,10 +120,16 @@ export default function StoreBillsPage() {
|
||||
},
|
||||
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{
|
||||
title: '到账金额',
|
||||
title: '核销比例',
|
||||
dataIndex: 'settlementRate',
|
||||
width: 90,
|
||||
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
|
||||
},
|
||||
{
|
||||
title: '应付门店',
|
||||
dataIndex: 'payoutAmount',
|
||||
width: 100,
|
||||
render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`,
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
{
|
||||
title: '打款状态',
|
||||
@@ -86,8 +137,7 @@ export default function StoreBillsPage() {
|
||||
width: 100,
|
||||
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
|
||||
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
@@ -117,17 +167,39 @@ export default function StoreBillsPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店核销账单</Typography.Title>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
门店账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每笔核销对应一条 T+1 打款账单,可按门店筛选查看
|
||||
T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="明细笔数" value={summary.count} />
|
||||
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
onFinish={(v: {
|
||||
storeId?: string;
|
||||
status?: string;
|
||||
dateRange?: [Dayjs, Dayjs];
|
||||
}) => {
|
||||
setFilters({
|
||||
storeId: v.storeId || '',
|
||||
status: v.status || '',
|
||||
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
|
||||
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -152,20 +224,39 @@ export default function StoreBillsPage() {
|
||||
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Form.Item name="dateRange" label="核销日期">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}>重置</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -177,15 +268,18 @@ export default function StoreBillsPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="门店">{String((detail.store as { name?: string })?.name ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{String((detail.store as { name?: string })?.name ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销单号">
|
||||
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="到账金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算比例">
|
||||
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
||||
</Descriptions.Item>
|
||||
@@ -196,7 +290,6 @@ export default function StoreBillsPage() {
|
||||
<Descriptions.Item label="实际打款">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import {
|
||||
validateStoreCreateStep1,
|
||||
validateStoreCreateStep3,
|
||||
@@ -42,6 +42,8 @@ type StoreRow = {
|
||||
name: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
auditStatus?: string;
|
||||
rejectReason?: string | null;
|
||||
cityName: string;
|
||||
district: string;
|
||||
address: string;
|
||||
@@ -73,6 +75,7 @@ export default function StoresPage() {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
return qs;
|
||||
},
|
||||
@@ -80,6 +83,9 @@ export default function StoresPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createStep, setCreateStep] = useState(0);
|
||||
const [createError, setCreateError] = useState('');
|
||||
@@ -228,9 +234,26 @@ export default function StoresPage() {
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
title: '营业状态', dataIndex: 'status', width: 90,
|
||||
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '审核', dataIndex: 'auditStatus', width: 100,
|
||||
render: (s, row) => {
|
||||
const status = s || 'APPROVED';
|
||||
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
||||
{status === 'REJECTED' && row.rejectReason ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{row.rejectReason}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
@@ -268,9 +291,17 @@ export default function StoresPage() {
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Form.Item name="status" label="营业状态">
|
||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auditStatus" label="审核">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
placeholder="全部"
|
||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
@@ -278,7 +309,41 @@ export default function StoresPage() {
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space>
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={auditing}
|
||||
onClick={async () => {
|
||||
setAuditing(true);
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ approved: true, remark: '审核通过' }),
|
||||
});
|
||||
message.success('已通过审核,合伙人可开门营业');
|
||||
setDetail({ ...detail, ...updated, auditStatus: 'APPROVED', rejectReason: null });
|
||||
void reload();
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={auditing}
|
||||
onClick={() => {
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
@@ -304,6 +369,17 @@ export default function StoresPage() {
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
|
||||
}>
|
||||
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{String(detail.auditStatus) === 'REJECTED' ? (
|
||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销结算比例">
|
||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||
@@ -319,6 +395,17 @@ export default function StoresPage() {
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
|
||||
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
|
||||
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
@@ -449,6 +536,40 @@ export default function StoresPage() {
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="驳回门店审核"
|
||||
open={rejectOpen}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true, loading: auditing, disabled: !rejectReason.trim() }}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail || !rejectReason.trim()) return;
|
||||
setAuditing(true);
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ approved: false, remark: rejectReason.trim() }),
|
||||
});
|
||||
message.success('已驳回,原因已同步合伙人端');
|
||||
setDetail({ ...detail, ...updated, auditStatus: 'REJECTED', rejectReason: rejectReason.trim() });
|
||||
setRejectOpen(false);
|
||||
void reload();
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">驳回原因将展示给合伙人,请说明需修改的内容。</Typography.Paragraph>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
maxLength={200}
|
||||
showCount
|
||||
placeholder="请填写驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
cityName?: string;
|
||||
receiverCity?: string;
|
||||
payAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
productName?: string;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
const DELIVERY_LABELS: Record<string, string> = {
|
||||
LOCAL: '同城',
|
||||
CROSS_CITY: '跨城',
|
||||
};
|
||||
|
||||
export default function WineryBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/winery-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '配送类型',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 90,
|
||||
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
|
||||
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
|
||||
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||
{ title: '数量', dataIndex: 'quantity', width: 70 },
|
||||
{
|
||||
title: '酒单实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '酒厂比例',
|
||||
dataIndex: 'wineryRate',
|
||||
width: 90,
|
||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '酒厂应付',
|
||||
dataIndex: 'wineryAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 170,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="酒单数" value={summary.count} />
|
||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
|
||||
setFilters({
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
deliveryType: v.deliveryType || '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deliveryType" label="配送类型">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'LOCAL', label: '同城' },
|
||||
{ value: 'CROSS_CITY', label: '跨城' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="orderId"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string;
|
||||
|
||||
export function storeAuditLabel(auditStatus?: string | null): string {
|
||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||
if (s === 'PENDING') return '待总部审核';
|
||||
if (s === 'REJECTED') return '审核驳回';
|
||||
if (s === 'APPROVED') return '审核通过';
|
||||
return auditStatus || '—';
|
||||
}
|
||||
|
||||
export function storeAuditPillClass(auditStatus?: string | null): string {
|
||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||
if (s === 'PENDING') return 'partner-status-pill--paused';
|
||||
if (s === 'REJECTED') return 'partner-status-pill--closed';
|
||||
return 'partner-status-pill--open';
|
||||
}
|
||||
|
||||
export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
|
||||
|
||||
export function storeStatusLabel(status: string): string {
|
||||
@@ -14,3 +31,7 @@ export function storeStatusPillClass(status: string): string {
|
||||
if (s === 'PAUSED') return 'partner-status-pill--paused';
|
||||
return 'partner-status-pill--closed';
|
||||
}
|
||||
|
||||
export function canPartnerOpenStore(auditStatus?: string | null): boolean {
|
||||
return String(auditStatus || '').toUpperCase() === 'APPROVED';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function HomePage() {
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
@@ -29,6 +30,10 @@ export default function HomePage() {
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderCount = Number(dash?.orderCount || 0);
|
||||
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
||||
const notifications = Array.isArray(dash?.notifications)
|
||||
? (dash.notifications as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const hasUnread = notifications.length > 0;
|
||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||
const revenue = orderCount * 128.45;
|
||||
@@ -49,9 +54,14 @@ export default function HomePage() {
|
||||
<header className="partner-home-header">
|
||||
<h1 className="app-page-title">工作台</h1>
|
||||
<div className="partner-home-header-actions">
|
||||
<button type="button" className="partner-notif-btn" aria-label="通知">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-notif-btn"
|
||||
aria-label="通知"
|
||||
onClick={() => setNotifOpen((v) => !v)}
|
||||
>
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
<span className="partner-notif-dot" />
|
||||
{hasUnread && <span className="partner-notif-dot" />}
|
||||
</button>
|
||||
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
|
||||
@@ -59,6 +69,33 @@ export default function HomePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notifOpen && (
|
||||
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h2 className="headline-md">审核通知</h2>
|
||||
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}>关闭</button>
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无审核通知</p>
|
||||
) : (
|
||||
notifications.slice(0, 8).map((n) => (
|
||||
<Link
|
||||
key={String(n.id)}
|
||||
to={`/stores/${n.storeId}`}
|
||||
className="partner-home-store-row"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={() => setNotifOpen(false)}
|
||||
>
|
||||
<div className="partner-home-store-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
|
||||
<p className="label-md text-muted">{String(n.content || '')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<main className="partner-home-body">
|
||||
<section className="partner-revenue-card">
|
||||
<p className="partner-revenue-label">
|
||||
@@ -172,14 +209,15 @@ export default function HomePage() {
|
||||
previewStores.map((s) => {
|
||||
const storeId = String(s.id);
|
||||
const status = String(s.status || 'PAUSED').toUpperCase();
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
return (
|
||||
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
|
||||
<div className="partner-home-store-info">
|
||||
<p className="headline-md">{String(s.name || '未命名门店')}</p>
|
||||
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(status)}`}>
|
||||
{storeStatusLabel(status)}
|
||||
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
|
||||
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,14 @@ import { request } from '../lib/api';
|
||||
import { toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isSubAccount } from '../lib/partnerAccess';
|
||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
storeAuditPillClass,
|
||||
storeStatusLabel,
|
||||
storeStatusPillClass,
|
||||
type StoreStatusValue,
|
||||
} from '../lib/storeStatus';
|
||||
|
||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||
|
||||
@@ -48,6 +55,15 @@ export default function StoreDetailPage() {
|
||||
async function changeStatus(next: StoreStatusValue) {
|
||||
if (!id || next === status || statusSaving) return;
|
||||
if (status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||
setActionError(
|
||||
auditStatus === 'REJECTED'
|
||||
? `门店审核未通过${store?.rejectReason ? `:${String(store.rejectReason)}` : ''}`
|
||||
: '门店尚在总部审核中,通过后方可开门',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
@@ -71,6 +87,11 @@ export default function StoreDetailPage() {
|
||||
|
||||
async function saveBasic() {
|
||||
if (!id || saving || status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (auditStatus === 'PENDING') {
|
||||
setActionError('门店审核中,暂不可修改资料');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
@@ -84,7 +105,7 @@ export default function StoreDetailPage() {
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
toastSuccess('已保存');
|
||||
toastSuccess(auditStatus === 'REJECTED' ? '已保存并重新提交审核' : '已保存');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
@@ -106,7 +127,11 @@ export default function StoreDetailPage() {
|
||||
const envPhotos = Array.isArray(store.media)
|
||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
||||
: [];
|
||||
const readOnly = subReadonly || status === 'CLOSED';
|
||||
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||
const auditPending = auditStatus === 'PENDING';
|
||||
const auditRejected = auditStatus === 'REJECTED';
|
||||
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
@@ -114,6 +139,37 @@ export default function StoreDetailPage() {
|
||||
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>审核状态</h3>
|
||||
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
</span>
|
||||
</div>
|
||||
{auditPending && (
|
||||
<p className="body-md" style={{ color: 'var(--color-secondary)' }}>
|
||||
资料已提交总部审核,通过后即可开门营业。审核期间不可修改资料。
|
||||
</p>
|
||||
)}
|
||||
{auditRejected && (
|
||||
<div>
|
||||
<p className="body-md" style={{ color: 'var(--color-heritage-red)', fontWeight: 600, marginBottom: 8 }}>
|
||||
审核未通过
|
||||
</p>
|
||||
<p className="body-md" style={{ background: 'rgba(180,35,24,0.06)', padding: 12, borderRadius: 8 }}>
|
||||
{String(store.rejectReason || '未填写驳回原因')}
|
||||
</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请按驳回原因修改资料并保存,将自动重新提交审核。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{auditStatus === 'APPROVED' && (
|
||||
<p className="label-md text-muted">总部审核已通过,可将门店设为营业中。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!subReadonly && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
@@ -129,10 +185,10 @@ export default function StoreDetailPage() {
|
||||
key={s}
|
||||
type="button"
|
||||
className={status === s ? 'active' : ''}
|
||||
disabled={readOnly || statusSaving}
|
||||
disabled={readOnly || statusSaving || (s === 'OPEN' && !canOpen) || status === 'CLOSED'}
|
||||
onClick={() => void changeStatus(s)}
|
||||
>
|
||||
{storeStatusLabel(s)}
|
||||
{s === 'OPEN' && !canOpen ? '待审核' : storeStatusLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -195,10 +251,14 @@ export default function StoreDetailPage() {
|
||||
|
||||
<section className="partner-form-card" style={{ margin: 0, background: 'var(--color-surface-container)' }}>
|
||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-subtle-gray)', paddingLeft: 12, marginBottom: 16 }}>管理信息</h3>
|
||||
<div>
|
||||
<label className="label-md text-muted">状态</label>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label className="label-md text-muted">营业状态</label>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{storeStatusLabel(status)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-md text-muted">审核状态</label>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>{storeAuditLabel(auditStatus)}</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -207,7 +267,7 @@ export default function StoreDetailPage() {
|
||||
{!subReadonly && (
|
||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{saving ? '保存中…' : '保存修改'}
|
||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
@@ -3,14 +3,23 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isSubAccount } from '../lib/partnerAccess';
|
||||
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
storeAuditLabel,
|
||||
storeAuditPillClass,
|
||||
storeStatusLabel,
|
||||
storeStatusPillClass,
|
||||
type StoreStatusValue,
|
||||
} from '../lib/storeStatus';
|
||||
|
||||
type StatusFilter = 'ALL' | StoreStatusValue;
|
||||
type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
|
||||
|
||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'OPEN', label: '营业中' },
|
||||
{ key: 'PAUSED', label: '暂时闭店' },
|
||||
{ key: 'PENDING_AUDIT', label: '待审核' },
|
||||
{ key: 'REJECTED', label: '已驳回' },
|
||||
{ key: 'CLOSED', label: '关闭' },
|
||||
];
|
||||
|
||||
@@ -35,11 +44,20 @@ export default function StoreListPage() {
|
||||
|
||||
const filtered = useMemo(() => stores.filter((s) => {
|
||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||
const matchStatus = filter === 'ALL' || String(s.status).toUpperCase() === filter;
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
const status = String(s.status).toUpperCase();
|
||||
let matchStatus = true;
|
||||
if (filter === 'PENDING_AUDIT') matchStatus = audit === 'PENDING';
|
||||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||||
else if (filter !== 'ALL') matchStatus = status === filter;
|
||||
return matchQ && matchStatus;
|
||||
}), [stores, q, filter]);
|
||||
|
||||
async function updateStatus(storeId: string, next: StoreStatusValue) {
|
||||
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
@@ -98,9 +116,11 @@ export default function StoreListPage() {
|
||||
{filtered.map((s) => {
|
||||
const storeId = String(s.id);
|
||||
const currentStatus = String(s.status).toUpperCase() as StoreStatusValue;
|
||||
const auditStatus = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
const dim = currentStatus === 'CLOSED';
|
||||
const storeName = String(s.name || '未命名门店');
|
||||
const busy = updatingId === storeId;
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
return (
|
||||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
@@ -109,10 +129,24 @@ export default function StoreListPage() {
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||
<h3 className="headline-md">{storeName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
{auditStatus !== 'APPROVED' && (
|
||||
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
{auditStatus === 'REJECTED' && s.rejectReason ? `:${String(s.rejectReason)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
{auditStatus !== 'APPROVED' ? (
|
||||
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
||||
{storeAuditLabel(auditStatus)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
||||
{storeStatusLabel(currentStatus)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
||||
{storeStatusLabel(currentStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
{!readonly && (
|
||||
@@ -121,8 +155,8 @@ export default function StoreListPage() {
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED'}
|
||||
onClick={() => void updateStatus(storeId, 'PAUSED')}
|
||||
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
|
||||
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
|
||||
>
|
||||
暂时闭店
|
||||
</button>
|
||||
@@ -131,7 +165,7 @@ export default function StoreListPage() {
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
|
||||
disabled={busy || currentStatus === 'CLOSED'}
|
||||
onClick={() => void updateStatus(storeId, 'CLOSED')}
|
||||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
@@ -140,10 +174,10 @@ export default function StoreListPage() {
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||
disabled={busy}
|
||||
onClick={() => void updateStatus(storeId, 'OPEN')}
|
||||
disabled={busy || !canOpen}
|
||||
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
|
||||
>
|
||||
恢复营业
|
||||
{canOpen ? '开门营业' : '待审核通过'}
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
|
||||
|
||||
@@ -33,10 +33,26 @@ export default defineConfig(async () => ({
|
||||
framework: 'react',
|
||||
compiler: {
|
||||
type: 'vite',
|
||||
vitePlugins: [],
|
||||
vitePlugins: [
|
||||
{
|
||||
name: 'dukang-mini-user-vite-memory',
|
||||
config() {
|
||||
return {
|
||||
// H5 Vite watch 预构建 Stencil 组件时峰值极易 >8GB;noDiscovery 降低冷启动成本
|
||||
optimizeDeps: {
|
||||
noDiscovery: true,
|
||||
include: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cache: {
|
||||
enable: false,
|
||||
enable: true,
|
||||
},
|
||||
mini: {
|
||||
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||
@@ -47,7 +63,11 @@ export default defineConfig(async () => ({
|
||||
},
|
||||
},
|
||||
h5: {
|
||||
publicPath: '/',
|
||||
publicPath: process.env.TARO_H5_PUBLIC_PATH || '/',
|
||||
router: {
|
||||
mode: 'browser',
|
||||
basename: process.env.TARO_H5_ROUTER_BASENAME || '/',
|
||||
},
|
||||
staticDirectory: 'static',
|
||||
devServer: {
|
||||
port: 5177,
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
"dev": "taro build --type h5 --watch",
|
||||
"dev:weapp": "taro build --type weapp --watch --mode development",
|
||||
"build": "taro build --type h5",
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
|
||||
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
|
||||
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
|
||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,6 +33,9 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.1",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@tarojs/cli": "4.2.0",
|
||||
"@tarojs/vite-runner": "4.2.0",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 服务器路径: /opt/dukang-haoke/deploy/auto-release.env
|
||||
|
||||
APP_ROOT=/opt/dukang-haoke
|
||||
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
|
||||
GIT_REMOTE=origin
|
||||
GIT_BRANCH=dev
|
||||
DEPLOY_GIT_REF=refs/heads/dev
|
||||
|
||||
@@ -45,6 +45,15 @@ if [[ ! -d .git ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 统一远程仓库为 SSH 地址
|
||||
GIT_REPO_URL="${GIT_REPO_URL:-git@git.yqidian.com:jacy/dukang.git}"
|
||||
CURRENT_URL="$(git remote get-url "$GIT_REMOTE" 2>/dev/null || true)"
|
||||
if [[ "$CURRENT_URL" != "$GIT_REPO_URL" ]]; then
|
||||
log "SET remote $GIT_REMOTE -> $GIT_REPO_URL (was: ${CURRENT_URL:-none})"
|
||||
git remote set-url "$GIT_REMOTE" "$GIT_REPO_URL" 2>/dev/null \
|
||||
|| git remote add "$GIT_REMOTE" "$GIT_REPO_URL"
|
||||
fi
|
||||
|
||||
git fetch "$GIT_REMOTE"
|
||||
git checkout "$GIT_BRANCH"
|
||||
|
||||
|
||||
@@ -5,5 +5,7 @@ DEPLOY_PORT=22
|
||||
# DEPLOY_SSH_KEY=~/.ssh/id_rsa
|
||||
|
||||
APP_ROOT=/opt/dukang-haoke
|
||||
# 生产仓库(SSH)
|
||||
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
|
||||
GIT_REMOTE=origin
|
||||
GIT_BRANCH=dev
|
||||
|
||||
@@ -15,8 +15,9 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
{
|
||||
// C 端改为 mini-user H5 产物(端口仍 8091,进程名保持兼容旧脚本)
|
||||
name: 'dukang-h5-user',
|
||||
cwd: `${APP_ROOT}/apps/h5-user`,
|
||||
cwd: `${APP_ROOT}/apps/mini-user`,
|
||||
script: 'npx',
|
||||
args: 'serve -s dist -l 8091',
|
||||
interpreter: 'none',
|
||||
|
||||
@@ -42,7 +42,7 @@ server {
|
||||
client_max_body_size 20m;
|
||||
|
||||
location ~ ^/MP_verify_.*\.txt$ {
|
||||
root /opt/dukang-haoke/apps/h5-user/dist;
|
||||
root /opt/dukang-haoke/apps/mini-user/dist;
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ server {
|
||||
}
|
||||
|
||||
location ~ ^/MP_verify_.*\.txt$ {
|
||||
root /opt/dukang-haoke/apps/h5-user/dist;
|
||||
root /opt/dukang-haoke/apps/mini-user/dist;
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ corepack enable 2>/dev/null || true
|
||||
corepack prepare pnpm@11.2.2 --activate 2>/dev/null || true
|
||||
|
||||
echo "==> 2. 安装依赖"
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}"
|
||||
# mini-user H5(Taro)构建峰值内存较高
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
|
||||
# 生产部署 path:nginx 入口为 /user/
|
||||
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
||||
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
||||
pnpm approve-builds --all 2>/dev/null || true
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
|
||||
@@ -68,16 +72,26 @@ else
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_BUILD" == false ]]; then
|
||||
echo "==> 4. 构建"
|
||||
pnpm build
|
||||
echo "==> 4. 构建(C 端使用 mini-user H5,跳过 h5-user)"
|
||||
pnpm --filter @dukang/shared-types \
|
||||
--filter @dukang/domain \
|
||||
--filter @dukang/api \
|
||||
--filter @dukang/mini-user \
|
||||
--filter @dukang/h5-shop \
|
||||
--filter @dukang/h5-partner \
|
||||
--filter @dukang/admin-web \
|
||||
build
|
||||
else
|
||||
echo "==> 4. 跳过构建"
|
||||
fi
|
||||
|
||||
echo "==> 5. 重启 PM2"
|
||||
if pm2 describe dukang-api &>/dev/null; then
|
||||
pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner dukang-admin-web 2>/dev/null \
|
||||
|| pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner
|
||||
# delete+start 以确保 cwd 从 h5-user 切到 mini-user
|
||||
pm2 delete dukang-h5-user 2>/dev/null || true
|
||||
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs" --only dukang-h5-user
|
||||
pm2 restart dukang-api dukang-h5-shop dukang-h5-partner dukang-admin-web 2>/dev/null \
|
||||
|| pm2 restart dukang-api dukang-h5-shop dukang-h5-partner
|
||||
else
|
||||
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
|
||||
fi
|
||||
@@ -86,8 +100,8 @@ pm2 save
|
||||
echo "==> 6. 健康检查"
|
||||
sleep 2
|
||||
pm2 list
|
||||
ss -tlnp | grep -E '809[0-3]' || true
|
||||
curl -sf -o /dev/null -w "h5-user: %{http_code}\n" http://127.0.0.1:8091/ || echo "h5-user: FAIL"
|
||||
ss -tlnp | grep -E '809[0-4]' || true
|
||||
curl -sf -o /dev/null -w "mini-user(h5): %{http_code}\n" http://127.0.0.1:8091/ || echo "mini-user(h5): FAIL"
|
||||
curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null \
|
||||
|| curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/ \
|
||||
|| echo "api: FAIL"
|
||||
|
||||
@@ -105,6 +105,18 @@ export enum StoreStatus {
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
|
||||
export enum StoreAuditStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
export const STORE_AUDIT_STATUS_LABELS: Record<StoreAuditStatus, string> = {
|
||||
[StoreAuditStatus.PENDING]: '待审核',
|
||||
[StoreAuditStatus.APPROVED]: '已通过',
|
||||
[StoreAuditStatus.REJECTED]: '已驳回',
|
||||
};
|
||||
|
||||
export enum PartnerStaffRole {
|
||||
PARTNER = 'PARTNER',
|
||||
INTERNAL = 'INTERNAL',
|
||||
|
||||
@@ -18,7 +18,12 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
|
||||
'partner_staff_delete',
|
||||
'partner_staff_permission_update',
|
||||
],
|
||||
store_ops: ['partner_store_create', 'partner_store_status_change'],
|
||||
store_ops: [
|
||||
'partner_store_create',
|
||||
'partner_store_status_change',
|
||||
'partner_store_audit_approved',
|
||||
'partner_store_audit_rejected',
|
||||
],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
|
||||
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
||||
|
||||
@@ -23,3 +23,47 @@ export interface PartnerBillDetailDto extends PartnerBillDto {
|
||||
confirmedAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
/** 门店结算默认比例(核销金额 × 比例) */
|
||||
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
|
||||
|
||||
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
|
||||
export const WINERY_SETTLEMENT_RATE = 0.3;
|
||||
|
||||
export type FinanceBillSummary = {
|
||||
count: number;
|
||||
redeemAmount?: number;
|
||||
payoutAmount?: number;
|
||||
orderAmount?: number;
|
||||
orderCommission?: number;
|
||||
redeemCommission?: number;
|
||||
wineryAmount?: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
|
||||
export type StoreDailyBillRow = {
|
||||
billDate: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
storePhone?: string;
|
||||
cityName?: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
pendingCount: number;
|
||||
paidCount: number;
|
||||
};
|
||||
|
||||
export type WineryOrderBillRow = {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
cityName?: string;
|
||||
payAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
receiverCity?: string;
|
||||
};
|
||||
|
||||
|
||||
Generated
+9
@@ -320,6 +320,15 @@ importers:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.24.4
|
||||
version: 7.29.7
|
||||
'@babel/plugin-proposal-decorators':
|
||||
specifier: ^7.24.1
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-class-properties':
|
||||
specifier: ^7.24.1
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/preset-react':
|
||||
specifier: ^7.24.1
|
||||
version: 7.29.7(@babel/core@7.29.7)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* mini-user H5 本地预览:
|
||||
* Taro Vite `--watch` 在本机易 OOM(峰值 >8GB),改为:
|
||||
* 1) 先完整 build(约 30s)
|
||||
* 2) 静态托管 dist + /api 代理
|
||||
* 3) 监听 src 变更后防抖重建
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
|
||||
const DIST = path.resolve(APP_ROOT, 'dist');
|
||||
const PORT = Number(process.env.PORT || 5177);
|
||||
const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top').replace(/\/$/, '');
|
||||
const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.map': 'application/json',
|
||||
};
|
||||
|
||||
function runBuild() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--max-old-space-size=8192', TARO_BIN, 'build', '--type', 'h5'],
|
||||
{
|
||||
cwd: APP_ROOT,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`taro build failed with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendFile(res, filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
|
||||
function proxyApi(req, res) {
|
||||
const target = new URL(req.url || '/', API_ORIGIN);
|
||||
const lib = target.protocol === 'https:' ? https : http;
|
||||
const headers = { ...req.headers, host: target.host };
|
||||
delete headers['accept-encoding'];
|
||||
const upstream = lib.request(
|
||||
{
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port || undefined,
|
||||
path: target.pathname + target.search,
|
||||
method: req.method,
|
||||
headers,
|
||||
},
|
||||
(up) => {
|
||||
res.writeHead(up.statusCode || 502, up.headers);
|
||||
up.pipe(res);
|
||||
},
|
||||
);
|
||||
upstream.on('error', (err) => {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end(`API proxy error: ${err.message}`);
|
||||
});
|
||||
req.pipe(upstream);
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||
if (urlPath.startsWith('/api')) {
|
||||
proxyApi(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(DIST, urlPath),
|
||||
path.join(DIST, urlPath, 'index.html'),
|
||||
path.join(DIST, 'index.html'),
|
||||
];
|
||||
const file = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
|
||||
if (!file) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
sendFile(res, file);
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`\n mini-user H5 preview: http://localhost:${PORT}`);
|
||||
console.log(` API proxy -> ${API_ORIGIN}`);
|
||||
console.log(' Watching apps/mini-user/src for changes...\n');
|
||||
});
|
||||
}
|
||||
|
||||
function watchSrc() {
|
||||
const srcDir = path.join(APP_ROOT, 'src');
|
||||
let timer = null;
|
||||
let building = false;
|
||||
let queued = false;
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(async () => {
|
||||
if (building) {
|
||||
queued = true;
|
||||
return;
|
||||
}
|
||||
building = true;
|
||||
try {
|
||||
console.log('\n[dev] src changed, rebuilding...');
|
||||
await runBuild();
|
||||
console.log('[dev] rebuild done — refresh browser');
|
||||
} catch (e) {
|
||||
console.error('[dev] rebuild failed:', e instanceof Error ? e.message : e);
|
||||
} finally {
|
||||
building = false;
|
||||
if (queued) {
|
||||
queued = false;
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
}, 800);
|
||||
};
|
||||
|
||||
fs.watch(srcDir, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
if (/\.(tsx?|jsx?|css|scss|json|png|jpg|svg)$/i.test(filename)) {
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(TARO_BIN)) {
|
||||
throw new Error(`Taro CLI not found: ${TARO_BIN}`);
|
||||
}
|
||||
console.log('[dev] initial H5 build...');
|
||||
await runBuild();
|
||||
startServer();
|
||||
watchSrc();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -22,6 +22,8 @@ ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
# 开启总部人工审核时改为 false:合伙人录店 → 待审核 → HQ 通过后可开门;驳回须填原因并在合伙人端展示
|
||||
# AUTO_APPROVE_STORE=false
|
||||
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code;
|
||||
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 并配置 WX_APP_ID / WX_APP_SECRET。
|
||||
MOCK_WECHAT=true
|
||||
|
||||
@@ -176,6 +176,12 @@ enum StoreStatus {
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum StoreAuditStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum UserSourceType {
|
||||
ORGANIC
|
||||
PROMO_CODE
|
||||
@@ -734,7 +740,10 @@ model Store {
|
||||
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
|
||||
rating Decimal? @db.Decimal(3, 2)
|
||||
tags Json?
|
||||
status StoreStatus @default(PAUSED)
|
||||
status StoreStatus @default(PAUSED)
|
||||
auditStatus StoreAuditStatus @default(APPROVED) @map("audit_status")
|
||||
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||
auditedAt DateTime? @map("audited_at") @db.DateTime(3)
|
||||
openTime String? @map("open_time") @db.VarChar(8)
|
||||
closeTime String? @map("close_time") @db.VarChar(8)
|
||||
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
||||
@@ -753,6 +762,7 @@ model Store {
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
@@index([auditStatus, createdAt])
|
||||
@@map("store_store")
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ export class AdminStoresService {
|
||||
const where: Prisma.StoreWhereInput = {};
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
|
||||
if (query.auditStatus) {
|
||||
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
|
||||
}
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
@@ -57,6 +60,7 @@ export class AdminStoresService {
|
||||
items: items.map((s) =>
|
||||
mapStoreCompat({
|
||||
...s,
|
||||
partner: s.partnerAccount,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
}),
|
||||
@@ -118,11 +122,33 @@ export class AdminStoresService {
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const status = dto.approved ? 'OPEN' : 'PAUSED';
|
||||
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
|
||||
// 允许对已通过门店再次驳回/通过(总部纠错);PENDING/REJECTED/APPROVED 均可审核
|
||||
}
|
||||
if (!dto.approved) {
|
||||
const reason = dto.remark?.trim();
|
||||
if (!reason) throw new BadRequestException('驳回时必须填写原因');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
data: dto.approved
|
||||
? {
|
||||
// 审核通过后保持闭店,由合伙人自行开门
|
||||
status: store.status === 'CLOSED' ? 'CLOSED' : 'PAUSED',
|
||||
auditStatus: 'APPROVED',
|
||||
rejectReason: null,
|
||||
auditedAt: now,
|
||||
}
|
||||
: {
|
||||
status: 'PAUSED',
|
||||
auditStatus: 'REJECTED',
|
||||
rejectReason: dto.remark!.trim(),
|
||||
auditedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
@@ -130,10 +156,20 @@ export class AdminStoresService {
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
||||
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
|
||||
remark: dto.approved
|
||||
? (dto.remark?.trim() || '审核通过,可开门营业')
|
||||
: dto.remark!.trim(),
|
||||
param1: dto.approved ? 'APPROVE' : 'REJECT',
|
||||
param1Desc: 'audit_action',
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
|
||||
return serializeBigInt({
|
||||
...updated,
|
||||
notifyHint: dto.approved
|
||||
? '已通过审核,合伙人可在端内开门营业'
|
||||
: '已驳回,驳回原因已同步至合伙人端',
|
||||
});
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
@@ -217,6 +253,9 @@ export class AdminStoresService {
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
status: 'OPEN',
|
||||
auditStatus: 'APPROVED',
|
||||
auditedAt: new Date(),
|
||||
rejectReason: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
auditStatus?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -59,9 +59,49 @@ export class AdminStorePayoutController {
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('daily')
|
||||
daily(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminStoreDailyBills({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 50,
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
export(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.exportAdminStorePayouts({
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('batch-confirm')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM,
|
||||
refType: 'STORE_PAYOUT',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
|
||||
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
|
||||
}
|
||||
|
||||
@Post('scan-due')
|
||||
scanDue() {
|
||||
return this.settlementService.scanDueStorePayouts();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminStorePayout(BigInt(id));
|
||||
@@ -77,22 +117,6 @@ export class AdminStorePayoutController {
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
return this.settlementService.confirmStorePayout(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post('batch-confirm')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM,
|
||||
refType: 'STORE_PAYOUT',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
|
||||
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
|
||||
}
|
||||
|
||||
@Post('scan-due')
|
||||
scanDue() {
|
||||
return this.settlementService.scanDueStorePayouts();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/partner-bills')
|
||||
@@ -111,6 +135,17 @@ export class AdminPartnerBillController {
|
||||
return this.settlementService.generatePartnerBill(body);
|
||||
}
|
||||
|
||||
@Post('generate-all')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.PARTNER_BILL_GENERATE,
|
||||
refType: 'PARTNER_BILL',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
generateAll(@Body() body: { year: number; month: number }) {
|
||||
return this.settlementService.generateAllPartnerBills(body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminPartnerBills({
|
||||
@@ -118,6 +153,8 @@ export class AdminPartnerBillController {
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
partnerId: query.partnerId,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,6 +163,8 @@ export class AdminPartnerBillController {
|
||||
return this.settlementService.exportPartnerBills({
|
||||
partnerId: query.partnerId,
|
||||
status: query.status,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +195,32 @@ export class AdminPartnerBillController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/winery-bills')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminWineryBillController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminWineryBills({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
deliveryType: query.deliveryType,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
export(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.exportAdminWineryBills({
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
deliveryType: query.deliveryType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SettlementService } from './settlement.service';
|
||||
import {
|
||||
AdminPartnerBillController,
|
||||
AdminStorePayoutController,
|
||||
AdminWineryBillController,
|
||||
PartnerMeController,
|
||||
SettlementController,
|
||||
ShopPayoutController,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
ShopPayoutController,
|
||||
AdminStorePayoutController,
|
||||
AdminPartnerBillController,
|
||||
AdminWineryBillController,
|
||||
],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
@@ -9,6 +10,11 @@ function generateBillNo() {
|
||||
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
function csvEscape(value: string) {
|
||||
if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
|
||||
return value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(
|
||||
@@ -77,27 +83,192 @@ export class SettlementService {
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StorePayoutWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
const where = this.buildStorePayoutWhere(query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
const [items, total, aggregates] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { expectedPayAt: 'asc' },
|
||||
orderBy: [{ createdAt: 'desc' }, { expectedPayAt: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
||||
redeemRecord: { select: { redeemNo: true, amount: true, userId: true, createdAt: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
this.prisma.storePayout.aggregate({
|
||||
where,
|
||||
_sum: { redeemAmount: true, payoutAmount: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
|
||||
const summary = {
|
||||
count: aggregates._count,
|
||||
redeemAmount: Number(aggregates._sum.redeemAmount ?? 0),
|
||||
payoutAmount: Number(aggregates._sum.payoutAmount ?? 0),
|
||||
totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
|
||||
};
|
||||
|
||||
return serializeBigInt({ items, total, page, pageSize, summary });
|
||||
}
|
||||
|
||||
/** 门店账单按日汇总(T+1:按核销日归集) */
|
||||
async listAdminStoreDailyBills(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 50;
|
||||
const where = this.buildStorePayoutWhere(query);
|
||||
|
||||
const payouts = await this.prisma.storePayout.findMany({
|
||||
where,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||
redeemRecord: { select: { createdAt: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
type DailyAgg = {
|
||||
billDate: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
storePhone?: string;
|
||||
cityName?: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
pendingCount: number;
|
||||
paidCount: number;
|
||||
};
|
||||
|
||||
const map = new Map<string, DailyAgg>();
|
||||
for (const p of payouts) {
|
||||
const billDate = (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10);
|
||||
const storeId = p.storeId.toString();
|
||||
const key = `${billDate}:${storeId}`;
|
||||
const existing = map.get(key);
|
||||
if (!existing) {
|
||||
map.set(key, {
|
||||
billDate,
|
||||
storeId,
|
||||
storeName: p.store?.name ?? '—',
|
||||
storePhone: p.store?.phone ?? undefined,
|
||||
cityName: p.store?.cityName ?? undefined,
|
||||
redeemCount: 1,
|
||||
redeemAmount: Number(p.redeemAmount),
|
||||
settlementRate: Number(p.settlementRate),
|
||||
payoutAmount: Number(p.payoutAmount),
|
||||
pendingCount: p.status === 'PENDING' ? 1 : 0,
|
||||
paidCount: p.status === 'PAID' ? 1 : 0,
|
||||
});
|
||||
} else {
|
||||
existing.redeemCount += 1;
|
||||
existing.redeemAmount += Number(p.redeemAmount);
|
||||
existing.payoutAmount += Number(p.payoutAmount);
|
||||
if (p.status === 'PENDING') existing.pendingCount += 1;
|
||||
if (p.status === 'PAID') existing.paidCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const all = Array.from(map.values()).sort((a, b) => {
|
||||
if (a.billDate === b.billDate) return a.storeName.localeCompare(b.storeName, 'zh-CN');
|
||||
return b.billDate.localeCompare(a.billDate);
|
||||
});
|
||||
|
||||
const summary = {
|
||||
count: all.length,
|
||||
redeemAmount: Math.round(all.reduce((s, r) => s + r.redeemAmount, 0) * 100) / 100,
|
||||
payoutAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100,
|
||||
totalAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100,
|
||||
};
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = all.slice(start, start + pageSize).map((row) => ({
|
||||
...row,
|
||||
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
|
||||
payoutAmount: Math.round(row.payoutAmount * 100) / 100,
|
||||
}));
|
||||
|
||||
return { items, total: all.length, page, pageSize, summary };
|
||||
}
|
||||
|
||||
async exportAdminStorePayouts(query: {
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
const where = this.buildStorePayoutWhere(query);
|
||||
const payouts = await this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
store: { select: { name: true, phone: true, cityName: true } },
|
||||
redeemRecord: { select: { redeemNo: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const header = [
|
||||
'核销日期',
|
||||
'门店',
|
||||
'登录手机',
|
||||
'城市',
|
||||
'核销单号',
|
||||
'核销面额',
|
||||
'结算比例',
|
||||
'应付金额',
|
||||
'状态',
|
||||
'预计打款',
|
||||
'实际打款',
|
||||
].join(',');
|
||||
const rows = payouts.map((p) =>
|
||||
[
|
||||
(p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10),
|
||||
csvEscape(p.store?.name ?? ''),
|
||||
csvEscape(p.store?.phone ?? ''),
|
||||
csvEscape(p.store?.cityName ?? ''),
|
||||
csvEscape(p.redeemRecord?.redeemNo ?? ''),
|
||||
Number(p.redeemAmount),
|
||||
`${Math.round(Number(p.settlementRate) * 100)}%`,
|
||||
Number(p.payoutAmount),
|
||||
p.status,
|
||||
p.expectedPayAt.toISOString().slice(0, 10),
|
||||
p.paidAt ? p.paidAt.toISOString().slice(0, 10) : '',
|
||||
].join(','),
|
||||
);
|
||||
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: payouts.length };
|
||||
}
|
||||
|
||||
private buildStorePayoutWhere(query: {
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}): Prisma.StorePayoutWhereInput {
|
||||
const where: Prisma.StorePayoutWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.dateFrom || query.dateTo) {
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
if (query.dateFrom) createdAt.gte = new Date(`${query.dateFrom}T00:00:00.000`);
|
||||
if (query.dateTo) createdAt.lte = new Date(`${query.dateTo}T23:59:59.999`);
|
||||
where.createdAt = createdAt;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
async getAdminStorePayout(id: bigint) {
|
||||
@@ -172,24 +343,95 @@ export class SettlementService {
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where = this.buildPartnerBillWhere(query);
|
||||
|
||||
const [rawItems, total, aggregates] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
orderBy: { periodStart: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
partnerAccount: {
|
||||
select: { id: true, companyName: true, name: true, phone: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.partnerBill.count({ where }),
|
||||
this.prisma.partnerBill.aggregate({
|
||||
where,
|
||||
_sum: { orderCommission: true, redeemCommission: true, totalAmount: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const items = rawItems.map((b) => ({
|
||||
...b,
|
||||
partner: b.partnerAccount,
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
count: aggregates._count,
|
||||
orderCommission: Number(aggregates._sum.orderCommission ?? 0),
|
||||
redeemCommission: Number(aggregates._sum.redeemCommission ?? 0),
|
||||
totalAmount: Number(aggregates._sum.totalAmount ?? 0),
|
||||
};
|
||||
|
||||
return serializeBigInt({ items, total, page, pageSize, summary });
|
||||
}
|
||||
|
||||
private buildPartnerBillWhere(query: {
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}): Prisma.PartnerBillWhereInput {
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.year && query.month) {
|
||||
const periodStart = new Date(query.year, query.month - 1, 1);
|
||||
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
||||
where.periodStart = { gte: periodStart, lte: periodEnd };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { partnerAccount: { select: { companyName: true } } },
|
||||
}),
|
||||
this.prisma.partnerBill.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
/** 为所有主合伙人生成指定自然月账单 */
|
||||
async generateAllPartnerBills(body: { year: number; month: number }) {
|
||||
const partners = await this.prisma.partnerAccount.findMany({
|
||||
where: { isPrimary: 1, status: 'ACTIVE' },
|
||||
select: { id: true, companyName: true },
|
||||
});
|
||||
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
|
||||
for (const p of partners) {
|
||||
try {
|
||||
await this.generatePartnerBill({
|
||||
partnerId: p.id.toString(),
|
||||
year: body.year,
|
||||
month: body.month,
|
||||
});
|
||||
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
|
||||
} catch (e) {
|
||||
results.push({
|
||||
partnerId: p.id.toString(),
|
||||
companyName: p.companyName ?? '',
|
||||
ok: false,
|
||||
message: e instanceof Error ? e.message : '失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: partners.length,
|
||||
success: results.filter((r) => r.ok).length,
|
||||
failed: results.filter((r) => !r.ok).length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async getAdminPartnerBill(id: bigint) {
|
||||
@@ -310,31 +552,170 @@ export class SettlementService {
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
|
||||
async exportPartnerBills(query: {
|
||||
partnerId?: string;
|
||||
status?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
}) {
|
||||
const where = this.buildPartnerBillWhere(query);
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
include: { partnerAccount: { select: { companyName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { partnerAccount: { select: { companyName: true, phone: true } } },
|
||||
orderBy: { periodStart: 'desc' },
|
||||
});
|
||||
|
||||
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
|
||||
const header = [
|
||||
'账单号',
|
||||
'合伙人',
|
||||
'登录手机',
|
||||
'账期起',
|
||||
'账期止',
|
||||
'酒单佣金',
|
||||
'核销佣金',
|
||||
'合计应付',
|
||||
'状态',
|
||||
'确认时间',
|
||||
'打款时间',
|
||||
].join(',');
|
||||
const rows = bills.map((b) =>
|
||||
[
|
||||
b.billNo,
|
||||
b.partnerAccount.companyName,
|
||||
csvEscape(b.billNo),
|
||||
csvEscape(b.partnerAccount.companyName ?? ''),
|
||||
csvEscape(b.partnerAccount.phone ?? ''),
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
Number(b.orderCommission),
|
||||
Number(b.redeemCommission),
|
||||
Number(b.totalAmount),
|
||||
b.status,
|
||||
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
|
||||
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
|
||||
].join(','),
|
||||
);
|
||||
return { csv: [header, ...rows].join('\n'), count: bills.length };
|
||||
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
|
||||
}
|
||||
|
||||
/** 酒厂账单:T+30,按自然月列出已付酒单,应付 = 实付 × WINERY_SETTLEMENT_RATE */
|
||||
async listAdminWineryBills(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
year?: number;
|
||||
month?: number;
|
||||
deliveryType?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where = this.buildWineryOrderWhere(query);
|
||||
|
||||
const [orders, total, aggregates] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { paidAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { city: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
this.prisma.order.aggregate({
|
||||
where,
|
||||
_sum: { payAmount: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const rate = WINERY_SETTLEMENT_RATE;
|
||||
const items = orders.map((o) => {
|
||||
const payAmount = Number(o.payAmount);
|
||||
const wineryAmount = Math.round(payAmount * rate * 100) / 100;
|
||||
return {
|
||||
orderId: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
deliveryType: o.deliveryType,
|
||||
cityName: o.city?.name,
|
||||
payAmount,
|
||||
wineryRate: rate,
|
||||
wineryAmount,
|
||||
paidAt: o.paidAt?.toISOString() ?? '',
|
||||
receiverCity: o.receiverCity,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
};
|
||||
});
|
||||
|
||||
const orderAmount = Number(aggregates._sum.payAmount ?? 0);
|
||||
const summary = {
|
||||
count: aggregates._count,
|
||||
orderAmount,
|
||||
wineryAmount: Math.round(orderAmount * rate * 100) / 100,
|
||||
totalAmount: Math.round(orderAmount * rate * 100) / 100,
|
||||
};
|
||||
|
||||
return { items, total, page, pageSize, summary };
|
||||
}
|
||||
|
||||
async exportAdminWineryBills(query: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
deliveryType?: string;
|
||||
}) {
|
||||
const where = this.buildWineryOrderWhere(query);
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { paidAt: 'desc' },
|
||||
include: { city: { select: { name: true } } },
|
||||
});
|
||||
const rate = WINERY_SETTLEMENT_RATE;
|
||||
const header = [
|
||||
'订单号',
|
||||
'配送类型',
|
||||
'开城城市',
|
||||
'收货城市',
|
||||
'商品',
|
||||
'数量',
|
||||
'实付金额',
|
||||
'酒厂比例',
|
||||
'酒厂应付',
|
||||
'支付时间',
|
||||
].join(',');
|
||||
const rows = orders.map((o) => {
|
||||
const payAmount = Number(o.payAmount);
|
||||
return [
|
||||
csvEscape(o.orderNo),
|
||||
o.deliveryType === 'LOCAL' ? '同城' : o.deliveryType === 'CROSS_CITY' ? '跨城' : o.deliveryType,
|
||||
csvEscape(o.city?.name ?? ''),
|
||||
csvEscape(o.receiverCity ?? ''),
|
||||
csvEscape(o.productName ?? ''),
|
||||
o.quantity,
|
||||
payAmount,
|
||||
`${Math.round(rate * 100)}%`,
|
||||
Math.round(payAmount * rate * 100) / 100,
|
||||
o.paidAt ? o.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
|
||||
].join(',');
|
||||
});
|
||||
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: orders.length };
|
||||
}
|
||||
|
||||
private buildWineryOrderWhere(query: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
deliveryType?: string;
|
||||
}): Prisma.OrderWhereInput {
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
payStatus: 'PAID',
|
||||
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
|
||||
};
|
||||
if (query.deliveryType === 'LOCAL' || query.deliveryType === 'CROSS_CITY') {
|
||||
where.deliveryType = query.deliveryType;
|
||||
}
|
||||
if (query.year && query.month) {
|
||||
const start = new Date(query.year, query.month - 1, 1);
|
||||
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
||||
where.paidAt = { gte: start, lte: end };
|
||||
} else {
|
||||
where.paidAt = { not: null };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
async scanDueStorePayouts() {
|
||||
|
||||
@@ -197,6 +197,9 @@ export class StoreService {
|
||||
openTime: body.openTime ? String(body.openTime) : '10:00',
|
||||
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||||
auditedAt: this.config.autoApproveStore ? new Date() : null,
|
||||
rejectReason: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -325,6 +328,18 @@ export class StoreService {
|
||||
if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
|
||||
throw new BadRequestException('无效的门店状态');
|
||||
}
|
||||
if (status === 'OPEN') {
|
||||
if (store.auditStatus === 'PENDING') {
|
||||
throw new BadRequestException('门店尚在总部审核中,通过后方可开门');
|
||||
}
|
||||
if (store.auditStatus === 'REJECTED') {
|
||||
throw new BadRequestException(
|
||||
store.rejectReason
|
||||
? `门店审核未通过:${store.rejectReason}`
|
||||
: '门店审核未通过,请查看驳回原因并重新提交',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
@@ -358,6 +373,9 @@ export class StoreService {
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可编辑');
|
||||
}
|
||||
if (store.auditStatus === 'PENDING') {
|
||||
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||||
}
|
||||
|
||||
const name = body.name !== undefined ? String(body.name).trim() : undefined;
|
||||
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
|
||||
@@ -373,6 +391,7 @@ export class StoreService {
|
||||
throw new BadRequestException('门店简介须为 10~500 字');
|
||||
}
|
||||
|
||||
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||||
await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: {
|
||||
@@ -380,8 +399,33 @@ export class StoreService {
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||
...(resubmitAudit
|
||||
? {
|
||||
auditStatus: 'PENDING' as const,
|
||||
rejectReason: null,
|
||||
auditedAt: null,
|
||||
status: store.status === 'OPEN' ? ('PAUSED' as const) : store.status,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (resubmitAudit) {
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
refType: 'STORE',
|
||||
refId: storeId,
|
||||
actorType: 'PARTNER',
|
||||
actorId: partnerAccountId,
|
||||
status: 'PENDING',
|
||||
param1: 'RESUBMIT',
|
||||
param1Desc: 'audit_type',
|
||||
remark: '合伙人修改资料后重新提交审核',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
}
|
||||
|
||||
@@ -398,6 +442,13 @@ export class StoreService {
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
include: { store: true },
|
||||
});
|
||||
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
|
||||
throw new BadRequestException(
|
||||
binding.store.auditStatus === 'REJECTED'
|
||||
? `门店审核未通过${binding.store.rejectReason ? `:${binding.store.rejectReason}` : ''}`
|
||||
: '门店尚在总部审核中,通过后方可营业',
|
||||
);
|
||||
}
|
||||
const previousStatus = binding.store.status;
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
@@ -423,34 +474,70 @@ export class StoreService {
|
||||
select: { id: true },
|
||||
});
|
||||
const storeIds = partnerStoreIds.map((s) => s.id);
|
||||
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
|
||||
const [storeCount, orderCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([
|
||||
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
|
||||
this.prisma.order.count({
|
||||
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
|
||||
}),
|
||||
this.prisma.store.findMany({
|
||||
where: { partnerAccountId: primaryId },
|
||||
select: { id: true, name: true, status: true, createdAt: true },
|
||||
select: { id: true, name: true, status: true, auditStatus: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.store.count({
|
||||
where: { partnerAccountId: primaryId, auditStatus: 'PENDING' },
|
||||
}),
|
||||
storeIds.length === 0
|
||||
? Promise.resolve(0)
|
||||
: this.prisma.commonEvent.count({
|
||||
? Promise.resolve([])
|
||||
: this.prisma.commonEvent.findMany({
|
||||
where: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
status: 'PENDING',
|
||||
refType: 'STORE',
|
||||
refId: { in: storeIds },
|
||||
status: { in: ['APPROVED', 'REJECTED'] },
|
||||
actorType: 'HQ',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
const storeNameMap = new Map(
|
||||
(
|
||||
await this.prisma.store.findMany({
|
||||
where: { id: { in: recentNotices.map((n) => n.refId) } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
).map((s) => [s.id.toString(), s.name]),
|
||||
);
|
||||
|
||||
return {
|
||||
storeCount,
|
||||
orderCount,
|
||||
companyName: account.companyName ?? '',
|
||||
recentStores: serializeBigInt(recentStores),
|
||||
pendingAuditCount,
|
||||
notifications: serializeBigInt(
|
||||
recentNotices.map((n) => ({
|
||||
id: n.id,
|
||||
storeId: n.refId,
|
||||
storeName: storeNameMap.get(n.refId.toString()) ?? '门店',
|
||||
status: n.status,
|
||||
remark: n.remark,
|
||||
createdAt: n.createdAt,
|
||||
title:
|
||||
n.status === 'APPROVED'
|
||||
? '门店审核已通过'
|
||||
: n.status === 'REJECTED'
|
||||
? '门店审核已驳回'
|
||||
: '门店审核更新',
|
||||
content:
|
||||
n.status === 'APPROVED'
|
||||
? `${storeNameMap.get(n.refId.toString()) ?? '门店'} 已通过总部审核,可开门营业`
|
||||
: `${storeNameMap.get(n.refId.toString()) ?? '门店'} 未通过审核${n.remark ? `:${n.remark}` : ''}`,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user