概览页增加表格统计
This commit is contained in:
@@ -11,6 +11,10 @@ server/dukang-api/.env.production.local
|
|||||||
server/dukang-api/.env.staging
|
server/dukang-api/.env.staging
|
||||||
server/dukang-api/.env.staging.local
|
server/dukang-api/.env.staging.local
|
||||||
deploy/deploy.env
|
deploy/deploy.env
|
||||||
|
deploy/.staging-mysql.pass
|
||||||
|
deploy/_ops-*.log
|
||||||
|
deploy/_ops-*.sh
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -88,6 +88,51 @@ export type DashboardStats = {
|
|||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DashboardAnalytics = {
|
||||||
|
summary: {
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
payingUsers: number;
|
||||||
|
partners: number;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
};
|
||||||
|
byDate: Array<{
|
||||||
|
date: string;
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
partners: number;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
}>;
|
||||||
|
byCity: Array<{
|
||||||
|
cityId: string;
|
||||||
|
cityName: string;
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
partners: number;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
}>;
|
||||||
|
byPromo: Array<{
|
||||||
|
promoCodeId: string | null;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
}>;
|
||||||
|
byPartner: Array<{
|
||||||
|
partnerAccountId: string;
|
||||||
|
companyName: string;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type SystemVersion = {
|
export type SystemVersion = {
|
||||||
id: string;
|
id: string;
|
||||||
gitTag: string | null;
|
gitTag: string | null;
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
|
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
import {
|
import {
|
||||||
request,
|
request,
|
||||||
|
type DashboardAnalytics,
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
type DeployTriggerResult,
|
type DeployTriggerResult,
|
||||||
type HqProfile,
|
type HqProfile,
|
||||||
|
type Paginated,
|
||||||
type SystemVersion,
|
type SystemVersion,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
@@ -31,6 +36,26 @@ const DEPLOYED_BY_LABELS: Record<string, string> = {
|
|||||||
admin: 'Admin 发布',
|
admin: 'Admin 发布',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CityOption = { id: string; name: string; code: string };
|
||||||
|
type PromoOption = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
type AnalyticsFilters = {
|
||||||
|
range: [Dayjs, Dayjs];
|
||||||
|
cityId?: string;
|
||||||
|
promoCodeId?: string;
|
||||||
|
partnerAccountId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildAnalyticsQs(f: AnalyticsFilters) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
|
||||||
|
if (f.cityId) qs.set('cityId', f.cityId);
|
||||||
|
if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId);
|
||||||
|
if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId);
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [version, setVersion] = useState<SystemVersion | null>(null);
|
const [version, setVersion] = useState<SystemVersion | null>(null);
|
||||||
@@ -40,6 +65,21 @@ export default function DashboardPage() {
|
|||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
const [filterForm] = Form.useForm<{
|
||||||
|
range: [Dayjs, Dayjs];
|
||||||
|
cityId?: string;
|
||||||
|
promoCodeId?: string;
|
||||||
|
partnerAccountId?: string;
|
||||||
|
}>();
|
||||||
|
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||||
|
range: [dayjs().subtract(29, 'day'), dayjs()],
|
||||||
|
});
|
||||||
|
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
||||||
|
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [promos, setPromos] = useState<PromoOption[]>([]);
|
||||||
|
const [partners, setPartners] = useState<Array<{ id: string; companyName?: string | null; name: string }>>([]);
|
||||||
|
|
||||||
const loadVersion = useCallback(() => {
|
const loadVersion = useCallback(() => {
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
return request<SystemVersion | null>('/admin/dashboard/version')
|
return request<SystemVersion | null>('/admin/dashboard/version')
|
||||||
@@ -48,6 +88,17 @@ export default function DashboardPage() {
|
|||||||
.finally(() => setVersionLoading(false));
|
.finally(() => setVersionLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadAnalytics = useCallback((f: AnalyticsFilters) => {
|
||||||
|
setAnalyticsLoading(true);
|
||||||
|
return request<DashboardAnalytics>(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`)
|
||||||
|
.then(setAnalytics)
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载统计失败');
|
||||||
|
setAnalytics(null);
|
||||||
|
})
|
||||||
|
.finally(() => setAnalyticsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<DashboardStats>('/admin/dashboard/stats')
|
request<DashboardStats>('/admin/dashboard/stats')
|
||||||
.then(setStats)
|
.then(setStats)
|
||||||
@@ -64,8 +115,64 @@ export default function DashboardPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
});
|
});
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => setCities([]));
|
||||||
|
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setPromos(res.items ?? []))
|
||||||
|
.catch(() => setPromos([]));
|
||||||
|
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
||||||
|
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
||||||
|
)
|
||||||
|
.then((res) => setPartners(res.items ?? []))
|
||||||
|
.catch(() => setPartners([]));
|
||||||
}, [loadVersion]);
|
}, [loadVersion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAnalytics(filters);
|
||||||
|
}, [filters, loadAnalytics]);
|
||||||
|
|
||||||
|
function applyFilters(values: {
|
||||||
|
range?: [Dayjs, Dayjs];
|
||||||
|
cityId?: string;
|
||||||
|
promoCodeId?: string;
|
||||||
|
partnerAccountId?: string;
|
||||||
|
}) {
|
||||||
|
const next: AnalyticsFilters = {
|
||||||
|
range: values.range ?? filters.range,
|
||||||
|
cityId: values.cityId || undefined,
|
||||||
|
promoCodeId: values.promoCodeId || undefined,
|
||||||
|
partnerAccountId: values.partnerAccountId || undefined,
|
||||||
|
};
|
||||||
|
setFilters(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCityFilter(cityId: string) {
|
||||||
|
const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId };
|
||||||
|
filterForm.setFieldsValue({ cityId: next.cityId });
|
||||||
|
setFilters(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPromoFilter(promoCodeId: string) {
|
||||||
|
const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId;
|
||||||
|
const next = {
|
||||||
|
...filters,
|
||||||
|
promoCodeId: key === filters.promoCodeId ? undefined : key,
|
||||||
|
};
|
||||||
|
filterForm.setFieldsValue({ promoCodeId: next.promoCodeId });
|
||||||
|
setFilters(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPartnerFilter(partnerAccountId: string) {
|
||||||
|
const next = {
|
||||||
|
...filters,
|
||||||
|
partnerAccountId:
|
||||||
|
partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId,
|
||||||
|
};
|
||||||
|
filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId });
|
||||||
|
setFilters(next);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeploy() {
|
function handleDeploy() {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认发布更新?',
|
title: '确认发布更新?',
|
||||||
@@ -89,6 +196,176 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
||||||
|
|
||||||
|
const ordersDrillQs = useMemo(() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('createdTo', filters.range[1].format('YYYY-MM-DD'));
|
||||||
|
if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId);
|
||||||
|
return qs.toString();
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const byDateOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byDate ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['新增用户', '订单数'] },
|
||||||
|
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.date.slice(5)),
|
||||||
|
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
|
||||||
|
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
const byCityOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byCity ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['用户', '订单'] },
|
||||||
|
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.cityName),
|
||||||
|
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||||
|
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
const byPromoOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byPromo ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['用户', '订单'] },
|
||||||
|
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.name || r.code),
|
||||||
|
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||||
|
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
const opsByDateOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byDate ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
|
||||||
|
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.date.slice(5)),
|
||||||
|
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '数量', minInterval: 1 },
|
||||||
|
{ type: 'value', name: '金额', minInterval: 1 },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
|
||||||
|
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
|
||||||
|
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
|
||||||
|
{
|
||||||
|
name: '核销金额',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: rows.map((r) => r.redeemAmount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
const opsByCityOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byCity ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['合伙人', '门店', '核销笔数'] },
|
||||||
|
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.cityName),
|
||||||
|
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
|
||||||
|
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||||
|
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
const opsByPartnerOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byPartner ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['门店', '核销笔数', '核销金额'] },
|
||||||
|
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.companyName),
|
||||||
|
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '数量', minInterval: 1 },
|
||||||
|
{ type: 'value', name: '金额' },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||||
|
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||||
|
{
|
||||||
|
name: '核销金额',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: rows.map((r) => r.redeemAmount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
||||||
|
const rows = analytics?.byCity ?? [];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['核销笔数', '核销金额'] },
|
||||||
|
grid: { left: 48, right: 48, top: 40, bottom: 48 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.cityName),
|
||||||
|
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '笔数', minInterval: 1 },
|
||||||
|
{ type: 'value', name: '金额' },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 },
|
||||||
|
{
|
||||||
|
name: '核销金额',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: rows.map((r) => r.redeemAmount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||||
@@ -176,6 +453,237 @@ export default function DashboardPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="数据统计筛选"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => void loadAnalytics(filters)}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
initialValues={{ range: filters.range }}
|
||||||
|
onFinish={applyFilters}
|
||||||
|
>
|
||||||
|
<Form.Item name="range" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
|
<DatePicker.RangePicker allowClear={false} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部开城"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: '未选城' },
|
||||||
|
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="promoCodeId" label="推广码">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部来源"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: '自然量 / 无推广码' },
|
||||||
|
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="partnerAccountId" label="合伙人">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部合伙人"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
options={partners.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.companyName || p.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="用户 / 订单统计"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Link to={`/orders?${ordersDrillQs}`}>查看订单</Link>
|
||||||
|
<Link to="/users">查看用户</Link>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
|
||||||
|
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title="按城市(点击柱联动筛选)"
|
||||||
|
loading={analyticsLoading}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ReactECharts
|
||||||
|
option={byCityOption}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
notMerge
|
||||||
|
onEvents={{
|
||||||
|
click: (params: { name?: string }) => {
|
||||||
|
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
||||||
|
if (row) setCityFilter(row.cityId);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title="按推广码(点击柱联动筛选)"
|
||||||
|
loading={analyticsLoading}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ReactECharts
|
||||||
|
option={byPromoOption}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
notMerge
|
||||||
|
onEvents={{
|
||||||
|
click: (params: { name?: string }) => {
|
||||||
|
const row = (analytics?.byPromo ?? []).find(
|
||||||
|
(r) => (r.name || r.code) === params.name,
|
||||||
|
);
|
||||||
|
if (row) setPromoFilter(row.promoCodeId ?? 'none');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="合伙人 / 门店 / 核销统计"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Link to="/city-partners">查看合伙人</Link>
|
||||||
|
<Link to="/stores">查看门店</Link>
|
||||||
|
<Link to="/redeem-records">查看核销</Link>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small">
|
||||||
|
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title="按城市(点击柱联动筛选)"
|
||||||
|
loading={analyticsLoading}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ReactECharts
|
||||||
|
option={opsByCityOption}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
notMerge
|
||||||
|
onEvents={{
|
||||||
|
click: (params: { name?: string }) => {
|
||||||
|
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
||||||
|
if (row) setCityFilter(row.cityId);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title="按城市核销金额"
|
||||||
|
loading={analyticsLoading}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ReactECharts
|
||||||
|
option={redeemByCityOption}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
notMerge
|
||||||
|
onEvents={{
|
||||||
|
click: (params: { name?: string }) => {
|
||||||
|
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
|
||||||
|
if (row) setCityFilter(row.cityId);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title="按合伙人(门店 / 核销,点击联动筛选)"
|
||||||
|
loading={analyticsLoading}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ReactECharts
|
||||||
|
option={opsByPartnerOption}
|
||||||
|
style={{ height: 320 }}
|
||||||
|
notMerge
|
||||||
|
onEvents={{
|
||||||
|
click: (params: { name?: string }) => {
|
||||||
|
const row = (analytics?.byPartner ?? []).find(
|
||||||
|
(r) => r.companyName === params.name,
|
||||||
|
);
|
||||||
|
if (row) setPartnerFilter(row.partnerAccountId);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
Generated
+44
@@ -35,6 +35,12 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.21
|
version: 1.11.21
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
|
echarts-for-react:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6(echarts@6.1.0)(react@18.3.1)
|
||||||
element-china-area-data:
|
element-china-area-data:
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
@@ -3579,6 +3585,15 @@ packages:
|
|||||||
ecdsa-sig-formatter@1.0.11:
|
ecdsa-sig-formatter@1.0.11:
|
||||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
|
echarts-for-react@3.0.6:
|
||||||
|
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
|
||||||
|
peerDependencies:
|
||||||
|
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
|
||||||
|
react: ^15.0.0 || >=16.0.0
|
||||||
|
|
||||||
|
echarts@6.1.0:
|
||||||
|
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -5835,6 +5850,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
size-sensor@1.0.3:
|
||||||
|
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
|
||||||
|
|
||||||
slash@3.0.0:
|
slash@3.0.0:
|
||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6189,6 +6207,9 @@ packages:
|
|||||||
tslib@1.14.1:
|
tslib@1.14.1:
|
||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -6597,6 +6618,9 @@ packages:
|
|||||||
yup@1.7.1:
|
yup@1.7.1:
|
||||||
resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
|
resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
|
||||||
|
|
||||||
|
zrender@6.1.0:
|
||||||
|
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alicloud/credentials@2.4.5':
|
'@alicloud/credentials@2.4.5':
|
||||||
@@ -10333,6 +10357,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
echarts-for-react@3.0.6(echarts@6.1.0)(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
echarts: 6.1.0
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
react: 18.3.1
|
||||||
|
size-sensor: 1.0.3
|
||||||
|
|
||||||
|
echarts@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.1.0
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.380: {}
|
electron-to-chromium@1.5.380: {}
|
||||||
@@ -12806,6 +12842,8 @@ snapshots:
|
|||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
size-sensor@1.0.3: {}
|
||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|
||||||
slice-ansi@4.0.0:
|
slice-ansi@4.0.0:
|
||||||
@@ -13170,6 +13208,8 @@ snapshots:
|
|||||||
|
|
||||||
tslib@1.14.1: {}
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
@@ -13665,3 +13705,7 @@ snapshots:
|
|||||||
tiny-case: 1.0.3
|
tiny-case: 1.0.3
|
||||||
toposort: 2.0.2
|
toposort: 2.0.2
|
||||||
type-fest: 2.19.0
|
type-fest: 2.19.0
|
||||||
|
|
||||||
|
zrender@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"prisma:validate": "prisma validate",
|
"prisma:validate": "prisma validate",
|
||||||
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
||||||
"prisma:seed-finance": "ts-node --transpile-only prisma/seed-finance-mock.ts",
|
"prisma:seed-finance": "ts-node --transpile-only prisma/seed-finance-mock.ts",
|
||||||
|
"prisma:seed-stats": "ts-node --transpile-only prisma/seed-stats-mock.ts",
|
||||||
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
|
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
|
||||||
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
|
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
|
||||||
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* 概览页统计 Mock(用户/订单/合伙人/门店/核销,幂等可重复跑)
|
||||||
|
* 用法:cd server/dukang-api && pnpm prisma:seed-stats
|
||||||
|
*/
|
||||||
|
import { PrismaClient, type OrderStatus, type PayStatus } from '@prisma/client';
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const USER_PREFIX = 'STAT';
|
||||||
|
const ORDER_PREFIX = 'STAT';
|
||||||
|
const COUPON_PREFIX = 'STATCPN';
|
||||||
|
const REDEEM_PREFIX = 'STATRD';
|
||||||
|
const STORE_NAME_PREFIX = 'STAT门店';
|
||||||
|
const PARTNER_PHONE_PREFIX = '13788';
|
||||||
|
|
||||||
|
const PROMO_CODES = [
|
||||||
|
{ code: 'STAT_A', name: '统计演示·品鉴会A', scene: 'EVENT' as const },
|
||||||
|
{ code: 'STAT_B', name: '统计演示·线下提货B', scene: 'OFFLINE_PICKUP' as const },
|
||||||
|
{ code: 'STAT_C', name: '统计演示·线上渠道C', scene: 'ONLINE_LINK' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CITY_DEFS = [
|
||||||
|
{ code: '410100', name: '郑州市', province: '河南省', district: '金水区' },
|
||||||
|
{ code: '410300', name: '洛阳市', province: '河南省', district: '涧西区' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function startOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qrcodeIdFor(code: string) {
|
||||||
|
return createHash('sha256').update(`stats-mock:${code}`).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
return () => {
|
||||||
|
let t = (seed += 0x6d2b79f5);
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
console.log('Cleaning previous STAT* mock...');
|
||||||
|
|
||||||
|
const redeemIds = (
|
||||||
|
await prisma.redeemRecord.findMany({
|
||||||
|
where: { redeemNo: { startsWith: REDEEM_PREFIX } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((r) => r.id);
|
||||||
|
if (redeemIds.length) {
|
||||||
|
await prisma.redeemRecordAllocation.deleteMany({
|
||||||
|
where: { redeemRecordId: { in: redeemIds } },
|
||||||
|
});
|
||||||
|
await prisma.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await prisma.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
||||||
|
await prisma.redeemPendingRecord.deleteMany({
|
||||||
|
where: { redeemRecordId: { in: redeemIds } },
|
||||||
|
});
|
||||||
|
await prisma.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.benefitCoupon.deleteMany({
|
||||||
|
where: { couponNo: { startsWith: COUPON_PREFIX } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statOrders = await prisma.order.findMany({
|
||||||
|
where: { orderNo: { startsWith: ORDER_PREFIX } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const orderIds = statOrders.map((o) => o.id);
|
||||||
|
if (orderIds.length) {
|
||||||
|
await prisma.benefitCoupon.deleteMany({ where: { orderId: { in: orderIds } } });
|
||||||
|
await prisma.orderDelivery.deleteMany({ where: { orderId: { in: orderIds } } });
|
||||||
|
await prisma.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
|
||||||
|
await prisma.order.deleteMany({ where: { id: { in: orderIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statUsers = await prisma.user.findMany({
|
||||||
|
where: { userNo: { startsWith: USER_PREFIX } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const userIds = statUsers.map((u) => u.id);
|
||||||
|
if (userIds.length) {
|
||||||
|
await prisma.userPromoAttribution.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.userCityPreference.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.userAddress.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.benefitCoupon.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.commonPromoCode.deleteMany({
|
||||||
|
where: { code: { in: PROMO_CODES.map((p) => p.code) } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statStores = await prisma.store.findMany({
|
||||||
|
where: { name: { startsWith: STORE_NAME_PREFIX } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const storeIds = statStores.map((s) => s.id);
|
||||||
|
if (storeIds.length) {
|
||||||
|
await prisma.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
|
||||||
|
await prisma.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
|
||||||
|
await prisma.store.deleteMany({ where: { id: { in: storeIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statPartners = await prisma.partnerAccount.findMany({
|
||||||
|
where: { phone: { startsWith: PARTNER_PHONE_PREFIX } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const partnerIds = statPartners.map((p) => p.id);
|
||||||
|
if (partnerIds.length) {
|
||||||
|
await prisma.partnerBill.deleteMany({ where: { partnerAccountId: { in: partnerIds } } });
|
||||||
|
await prisma.partnerAccount.deleteMany({
|
||||||
|
where: { OR: [{ id: { in: partnerIds } }, { parentAccountId: { in: partnerIds } }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCities() {
|
||||||
|
const cities = [];
|
||||||
|
for (const def of CITY_DEFS) {
|
||||||
|
const city = await prisma.commonCity.upsert({
|
||||||
|
where: { code: def.code },
|
||||||
|
create: {
|
||||||
|
code: def.code,
|
||||||
|
name: def.name,
|
||||||
|
province: def.province,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: def.name,
|
||||||
|
province: def.province,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cities.push({ ...city, district: def.district });
|
||||||
|
}
|
||||||
|
return cities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding stats mock (users/orders/partners/stores/redeems)...');
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
const cities = await ensureCities();
|
||||||
|
const product = await prisma.commonProductItem.findFirst({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
barcode69: true,
|
||||||
|
name: true,
|
||||||
|
spec: true,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!product) throw new Error('没有商品,请先 pnpm prisma:seed');
|
||||||
|
|
||||||
|
const category = await prisma.commonStoreCategory.findFirst({ orderBy: { id: 'asc' } });
|
||||||
|
|
||||||
|
const promos = [];
|
||||||
|
for (const def of PROMO_CODES) {
|
||||||
|
const promo = await prisma.commonPromoCode.create({
|
||||||
|
data: {
|
||||||
|
code: def.code,
|
||||||
|
name: def.name,
|
||||||
|
scene: def.scene,
|
||||||
|
qrcodeId: qrcodeIdFor(def.code),
|
||||||
|
status: 'ACTIVE',
|
||||||
|
scanCount: 0,
|
||||||
|
orderCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
promos.push(promo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rand = mulberry32(20260731);
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
|
||||||
|
// ── 城市合伙人 + 门店(近 30 天分散创建) ──
|
||||||
|
const createdPartners: Array<{
|
||||||
|
id: bigint;
|
||||||
|
cityId: bigint;
|
||||||
|
city: (typeof cities)[0];
|
||||||
|
}> = [];
|
||||||
|
const createdStores: Array<{
|
||||||
|
id: bigint;
|
||||||
|
cityId: bigint;
|
||||||
|
partnerAccountId: bigint;
|
||||||
|
settlementRate: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < cities.length; i++) {
|
||||||
|
const city = cities[i];
|
||||||
|
// 每城 2 个主合伙人
|
||||||
|
for (let j = 0; j < 2; j++) {
|
||||||
|
const dayOffset = Math.floor(rand() * 28);
|
||||||
|
const createdAt = new Date(today);
|
||||||
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
||||||
|
createdAt.setHours(9, 0, 0, 0);
|
||||||
|
const phone = `${PARTNER_PHONE_PREFIX}${String(i * 10 + j + 1).padStart(5, '0')}`;
|
||||||
|
const partner = await prisma.partnerAccount.create({
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
name: `${city.name}统计合伙人${j + 1}`,
|
||||||
|
companyName: `STAT${city.name}合伙人${j + 1}`,
|
||||||
|
isPrimary: 1,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
cityId: city.id,
|
||||||
|
scopeType: 'CITY_WIDE',
|
||||||
|
bindingStatus: 'ACTIVE',
|
||||||
|
orderCommissionRate: 0,
|
||||||
|
redeemCommissionRate: 0.03,
|
||||||
|
bankAccountName: `${city.name}统计合伙人${j + 1}`,
|
||||||
|
bankAccountNo: `622202${String(1000000000 + i * 10 + j)}`,
|
||||||
|
bankBranch: `${city.name}工商银行`,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdPartners.push({ id: partner.id, cityId: city.id, city });
|
||||||
|
|
||||||
|
// 每位合伙人 3~5 家门店
|
||||||
|
const storeN = 3 + Math.floor(rand() * 3);
|
||||||
|
for (let k = 0; k < storeN; k++) {
|
||||||
|
const sDay = Math.floor(rand() * Math.max(1, dayOffset + 1));
|
||||||
|
const sCreated = new Date(today);
|
||||||
|
sCreated.setDate(sCreated.getDate() - sDay);
|
||||||
|
sCreated.setHours(11, Math.floor(rand() * 40), 0, 0);
|
||||||
|
const rate = 0.6;
|
||||||
|
const store = await prisma.store.create({
|
||||||
|
data: {
|
||||||
|
cityId: city.id,
|
||||||
|
partnerAccountId: partner.id,
|
||||||
|
categoryId: category?.id,
|
||||||
|
name: `${STORE_NAME_PREFIX}-${city.name}-${j + 1}-${k + 1}`,
|
||||||
|
phone: `1399${String(100000 + i * 100 + j * 10 + k).slice(-7)}`,
|
||||||
|
province: city.province,
|
||||||
|
cityName: city.name,
|
||||||
|
district: city.district,
|
||||||
|
address: `统计路${k + 1}号`,
|
||||||
|
settlementRate: rate,
|
||||||
|
status: 'OPEN',
|
||||||
|
auditStatus: 'APPROVED',
|
||||||
|
openTime: '10:00',
|
||||||
|
closeTime: '22:00',
|
||||||
|
createdAt: sCreated,
|
||||||
|
updatedAt: sCreated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdStores.push({
|
||||||
|
id: store.id,
|
||||||
|
cityId: city.id,
|
||||||
|
partnerAccountId: partner.id,
|
||||||
|
settlementRate: rate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 用户 + 订单 ──
|
||||||
|
const userCount = 100;
|
||||||
|
const createdUsers: Array<{
|
||||||
|
id: bigint;
|
||||||
|
cityId: bigint;
|
||||||
|
city: (typeof cities)[0];
|
||||||
|
promoId: bigint | null;
|
||||||
|
phone: string;
|
||||||
|
}> = [];
|
||||||
|
const paidOrders: Array<{
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
cityId: bigint;
|
||||||
|
payAmount: number;
|
||||||
|
productName: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < userCount; i++) {
|
||||||
|
const dayOffset = Math.floor(rand() * 30);
|
||||||
|
const createdAt = new Date(today);
|
||||||
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
||||||
|
createdAt.setHours(8 + Math.floor(rand() * 12), Math.floor(rand() * 60), 0, 0);
|
||||||
|
|
||||||
|
const city = cities[Math.floor(rand() * cities.length)];
|
||||||
|
const promo = rand() < 0.7 ? promos[Math.floor(rand() * promos.length)] : null;
|
||||||
|
const seq = String(i + 1).padStart(4, '0');
|
||||||
|
const phone = `13888${String(10000 + i).slice(-5)}`;
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userNo: `${USER_PREFIX}${seq}`,
|
||||||
|
phone,
|
||||||
|
phoneVerifiedAt: createdAt,
|
||||||
|
nickname: `统计用户${seq}`,
|
||||||
|
deviceKey: `stat-device-${seq}-${randomBytes(4).toString('hex')}`,
|
||||||
|
sourceType: promo ? 'PROMO_CODE' : 'ORGANIC',
|
||||||
|
sourceRefId: promo?.id ?? null,
|
||||||
|
sourceLabel: promo?.name ?? null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
cityPreference: {
|
||||||
|
create: {
|
||||||
|
selectedCityCode: city.code,
|
||||||
|
selectedDistrict: city.district,
|
||||||
|
locateCityCode: city.code,
|
||||||
|
locateDistrict: city.district,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(promo
|
||||||
|
? {
|
||||||
|
promoTouch: {
|
||||||
|
create: {
|
||||||
|
promoCodeId: promo.id,
|
||||||
|
channelName: promo.name,
|
||||||
|
firstTouchAt: createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdUsers.push({
|
||||||
|
id: user.id,
|
||||||
|
cityId: city.id,
|
||||||
|
city,
|
||||||
|
promoId: promo?.id ?? null,
|
||||||
|
phone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses: Array<{ status: OrderStatus; payStatus: PayStatus }> = [
|
||||||
|
{ status: 'COMPLETED', payStatus: 'PAID' },
|
||||||
|
{ status: 'COMPLETED', payStatus: 'PAID' },
|
||||||
|
{ status: 'PENDING_SHIP', payStatus: 'PAID' },
|
||||||
|
{ status: 'SHIPPING', payStatus: 'PAID' },
|
||||||
|
{ status: 'PENDING_PAY', payStatus: 'UNPAID' },
|
||||||
|
{ status: 'CANCELLED', payStatus: 'UNPAID' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let orderCount = 0;
|
||||||
|
const promoOrderInc = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const u of createdUsers) {
|
||||||
|
const n = 1 + Math.floor(rand() * 3);
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
const dayOffset = Math.floor(rand() * 30);
|
||||||
|
const createdAt = new Date(today);
|
||||||
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
||||||
|
createdAt.setHours(10 + Math.floor(rand() * 10), Math.floor(rand() * 60), 0, 0);
|
||||||
|
|
||||||
|
const qty = 2 + Math.floor(rand() * 3);
|
||||||
|
const unit = Number(product.price);
|
||||||
|
const listAmount = unit * qty;
|
||||||
|
const st = statuses[Math.floor(rand() * statuses.length)];
|
||||||
|
const paid = st.payStatus === 'PAID';
|
||||||
|
orderCount += 1;
|
||||||
|
const orderNo = `${ORDER_PREFIX}${String(orderCount).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
orderNo,
|
||||||
|
userId: u.id,
|
||||||
|
cityId: u.cityId,
|
||||||
|
promoCodeId: u.promoId,
|
||||||
|
channelSource: u.promoId ? 'STATS_MOCK' : null,
|
||||||
|
status: st.status,
|
||||||
|
payStatus: st.payStatus,
|
||||||
|
deliveryType: 'LOCAL',
|
||||||
|
productId: product.id,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
productName: product.name,
|
||||||
|
productSpec: product.spec,
|
||||||
|
quantity: qty,
|
||||||
|
listUnitPrice: unit,
|
||||||
|
listAmount,
|
||||||
|
productAmount: listAmount,
|
||||||
|
payAmount: listAmount,
|
||||||
|
benefitAmount: listAmount,
|
||||||
|
receiverName: `统计用户`,
|
||||||
|
receiverPhone: u.phone,
|
||||||
|
receiverAddress: `${u.city.name}${u.city.district}统计路1号`,
|
||||||
|
receiverProvince: u.city.province,
|
||||||
|
receiverCity: u.city.name,
|
||||||
|
receiverDistrict: u.city.district,
|
||||||
|
paidAt: paid ? createdAt : null,
|
||||||
|
completedAt: st.status === 'COMPLETED' ? createdAt : null,
|
||||||
|
cancelledAt: st.status === 'CANCELLED' ? createdAt : null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paid) {
|
||||||
|
paidOrders.push({
|
||||||
|
id: order.id,
|
||||||
|
userId: u.id,
|
||||||
|
cityId: u.cityId,
|
||||||
|
payAmount: listAmount,
|
||||||
|
productName: product.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (u.promoId) {
|
||||||
|
const key = u.promoId.toString();
|
||||||
|
promoOrderInc.set(key, (promoOrderInc.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, inc] of promoOrderInc) {
|
||||||
|
await prisma.commonPromoCode.update({
|
||||||
|
where: { id: BigInt(id) },
|
||||||
|
data: { orderCount: { increment: inc }, scanCount: { increment: Math.floor(inc * 1.5) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 权益券 + 核销(约 80 笔,分散近 30 天) ──
|
||||||
|
const storesByCity = new Map<string, typeof createdStores>();
|
||||||
|
for (const s of createdStores) {
|
||||||
|
const key = s.cityId.toString();
|
||||||
|
const list = storesByCity.get(key) ?? [];
|
||||||
|
list.push(s);
|
||||||
|
storesByCity.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
let redeemCount = 0;
|
||||||
|
const redeemTarget = Math.min(80, paidOrders.length);
|
||||||
|
for (let i = 0; i < redeemTarget; i++) {
|
||||||
|
const order = paidOrders[i];
|
||||||
|
const cityStores = storesByCity.get(order.cityId.toString()) ?? createdStores;
|
||||||
|
if (!cityStores.length) continue;
|
||||||
|
const store = cityStores[Math.floor(rand() * cityStores.length)];
|
||||||
|
|
||||||
|
const dayOffset = Math.floor(rand() * 30);
|
||||||
|
const createdAt = new Date(today);
|
||||||
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
||||||
|
createdAt.setHours(14 + Math.floor(rand() * 6), Math.floor(rand() * 60), 0, 0);
|
||||||
|
|
||||||
|
const amount = Math.round((40 + rand() * 200) * 100) / 100;
|
||||||
|
const settleAmount = Math.round(amount * store.settlementRate * 100) / 100;
|
||||||
|
|
||||||
|
const coupon = await prisma.benefitCoupon.create({
|
||||||
|
data: {
|
||||||
|
couponNo: `${COUPON_PREFIX}${String(i + 1).padStart(5, '0')}`,
|
||||||
|
userId: order.userId,
|
||||||
|
orderId: order.id,
|
||||||
|
totalAmount: order.payAmount,
|
||||||
|
usedAmount: amount,
|
||||||
|
balance: Math.max(0, order.payAmount - amount),
|
||||||
|
status: amount >= order.payAmount ? 'USED_UP' : 'ACTIVE',
|
||||||
|
sourceProduct: order.productName,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redeemCount += 1;
|
||||||
|
await prisma.redeemRecord.create({
|
||||||
|
data: {
|
||||||
|
redeemNo: `${REDEEM_PREFIX}${String(redeemCount).padStart(5, '0')}`,
|
||||||
|
userId: order.userId,
|
||||||
|
couponId: coupon.id,
|
||||||
|
storeId: store.id,
|
||||||
|
amount,
|
||||||
|
settleAmount,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Done: cities=${cities.length}, promos=${promos.length}, partners=${createdPartners.length}, ` +
|
||||||
|
`stores=${createdStores.length}, users=${createdUsers.length}, orders=${orderCount}, redeems=${redeemCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const apiRoot = path.join(__dirname, '..');
|
||||||
|
const prod = fs.readFileSync(path.join(apiRoot, '.env.production'), 'utf8');
|
||||||
|
const m = prod.match(/^DATABASE_URL=(.*)$/m);
|
||||||
|
if (!m) throw new Error('no prod DATABASE_URL');
|
||||||
|
let url = m[1].trim();
|
||||||
|
if (
|
||||||
|
(url.startsWith('"') && url.endsWith('"')) ||
|
||||||
|
(url.startsWith("'") && url.endsWith("'"))
|
||||||
|
) {
|
||||||
|
url = url.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!url.includes('/dukang_prod')) throw new Error('unexpected prod url');
|
||||||
|
const stg = url.replace('/dukang_prod', '/dukang_staging');
|
||||||
|
|
||||||
|
const envPath = path.join(apiRoot, '.env.staging');
|
||||||
|
let t = fs.readFileSync(envPath, 'utf8');
|
||||||
|
if (!/^DATABASE_URL=/m.test(t)) {
|
||||||
|
t = `DATABASE_URL=\n${t}`;
|
||||||
|
}
|
||||||
|
t = t.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${JSON.stringify(stg)}`);
|
||||||
|
fs.writeFileSync(envPath, t);
|
||||||
|
|
||||||
|
const u = new URL(stg);
|
||||||
|
console.log(`ok ${u.hostname}:${u.port || 3306}${u.pathname}`);
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
|
import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
@Controller('admin/dashboard')
|
@Controller('admin/dashboard')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -13,6 +14,11 @@ export class AdminDashboardController {
|
|||||||
return this.dashboardService.getStats();
|
return this.dashboardService.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('analytics')
|
||||||
|
analytics(@Query() query: AdminDashboardAnalyticsQueryDto) {
|
||||||
|
return this.dashboardService.getAnalytics(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('version')
|
@Get('version')
|
||||||
@UseGuards(SuperAdminGuard)
|
@UseGuards(SuperAdminGuard)
|
||||||
version() {
|
version() {
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
|
function startOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYmd(s: string): Date | null {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
||||||
|
const d = new Date(`${s}T00:00:00`);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYmd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eachDate(from: Date, to: Date): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = startOfDay(from);
|
||||||
|
const end = startOfDay(to);
|
||||||
|
while (cur <= end) {
|
||||||
|
out.push(formatYmd(cur));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: Prisma.Decimal | number | string | null | undefined): number {
|
||||||
|
if (v == null) return 0;
|
||||||
|
return typeof v === 'number' ? v : Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminDashboardService {
|
export class AdminDashboardService {
|
||||||
@@ -59,7 +98,6 @@ export class AdminDashboardService {
|
|||||||
redeemToday,
|
redeemToday,
|
||||||
deliveriesTotal,
|
deliveriesTotal,
|
||||||
pendingPayouts,
|
pendingPayouts,
|
||||||
/** 合伙人已确认、待总部审核打款 */
|
|
||||||
pendingBills,
|
pendingBills,
|
||||||
pendingPartnerDraftBills,
|
pendingPartnerDraftBills,
|
||||||
openTickets,
|
openTickets,
|
||||||
@@ -85,4 +123,426 @@ export class AdminDashboardService {
|
|||||||
deployedAt: row.deployedAt.toISOString(),
|
deployedAt: row.deployedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAnalytics(query: AdminDashboardAnalyticsQueryDto) {
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const defaultFrom = new Date(today);
|
||||||
|
defaultFrom.setDate(defaultFrom.getDate() - 29);
|
||||||
|
|
||||||
|
const from =
|
||||||
|
(query.dateFrom ? parseYmd(query.dateFrom) : null) ?? defaultFrom;
|
||||||
|
const to =
|
||||||
|
(query.dateTo ? parseYmd(query.dateTo) : null) ?? today;
|
||||||
|
const rangeStart = startOfDay(from <= to ? from : to);
|
||||||
|
const rangeEnd = endOfDay(from <= to ? to : from);
|
||||||
|
|
||||||
|
let filterCityCode: string | null | undefined;
|
||||||
|
let filterCityId: bigint | null | undefined;
|
||||||
|
if (query.cityId === 'none') {
|
||||||
|
filterCityCode = null;
|
||||||
|
filterCityId = null;
|
||||||
|
} else if (query.cityId) {
|
||||||
|
const city = await this.prisma.commonCity.findUnique({
|
||||||
|
where: { id: BigInt(query.cityId) },
|
||||||
|
select: { id: true, code: true },
|
||||||
|
});
|
||||||
|
if (city) {
|
||||||
|
filterCityCode = city.code;
|
||||||
|
filterCityId = city.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterPromoNone = query.promoCodeId === 'none';
|
||||||
|
const filterPromoId =
|
||||||
|
query.promoCodeId && query.promoCodeId !== 'none'
|
||||||
|
? BigInt(query.promoCodeId)
|
||||||
|
: undefined;
|
||||||
|
const filterPartnerId = query.partnerAccountId
|
||||||
|
? BigInt(query.partnerAccountId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const userWhere: Prisma.UserWhereInput = {
|
||||||
|
status: 1,
|
||||||
|
mergedIntoUserId: null,
|
||||||
|
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||||
|
};
|
||||||
|
if (filterCityCode === null) {
|
||||||
|
userWhere.OR = [
|
||||||
|
{ cityPreference: null },
|
||||||
|
{ cityPreference: { selectedCityCode: null } },
|
||||||
|
];
|
||||||
|
} else if (filterCityCode) {
|
||||||
|
userWhere.cityPreference = { selectedCityCode: filterCityCode };
|
||||||
|
}
|
||||||
|
if (filterPromoNone) {
|
||||||
|
userWhere.promoTouch = { is: null };
|
||||||
|
} else if (filterPromoId !== undefined) {
|
||||||
|
userWhere.promoTouch = { promoCodeId: filterPromoId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderWhere: Prisma.OrderWhereInput = {
|
||||||
|
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||||
|
};
|
||||||
|
if (filterCityId !== undefined && filterCityId !== null) {
|
||||||
|
orderWhere.cityId = filterCityId;
|
||||||
|
}
|
||||||
|
if (filterPromoNone) {
|
||||||
|
orderWhere.promoCodeId = null;
|
||||||
|
} else if (filterPromoId !== undefined) {
|
||||||
|
orderWhere.promoCodeId = filterPromoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerWhere: Prisma.PartnerAccountWhereInput = {
|
||||||
|
isPrimary: 1,
|
||||||
|
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||||
|
};
|
||||||
|
if (filterCityId === null) {
|
||||||
|
partnerWhere.cityId = null;
|
||||||
|
} else if (filterCityId !== undefined) {
|
||||||
|
partnerWhere.cityId = filterCityId;
|
||||||
|
}
|
||||||
|
if (filterPartnerId !== undefined) {
|
||||||
|
partnerWhere.id = filterPartnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeWhere: Prisma.StoreWhereInput = {
|
||||||
|
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||||
|
};
|
||||||
|
if (filterCityId === null) {
|
||||||
|
// 门店必有 cityId
|
||||||
|
storeWhere.id = { in: [] };
|
||||||
|
} else if (filterCityId !== undefined) {
|
||||||
|
storeWhere.cityId = filterCityId;
|
||||||
|
}
|
||||||
|
if (filterPartnerId !== undefined) {
|
||||||
|
storeWhere.partnerAccountId = filterPartnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redeemWhere: Prisma.RedeemRecordWhereInput = {
|
||||||
|
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||||
|
};
|
||||||
|
if (filterCityId === null) {
|
||||||
|
redeemWhere.id = { in: [] };
|
||||||
|
} else {
|
||||||
|
const storeFilter: Prisma.StoreWhereInput = {};
|
||||||
|
if (filterCityId !== undefined) storeFilter.cityId = filterCityId;
|
||||||
|
if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId;
|
||||||
|
if (Object.keys(storeFilter).length) {
|
||||||
|
redeemWhere.store = storeFilter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipOrders = filterCityId === null;
|
||||||
|
|
||||||
|
const [users, orders, partners, stores, redeems, cities, promos, partnerNames] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.user.findMany({
|
||||||
|
where: userWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
cityPreference: { select: { selectedCityCode: true } },
|
||||||
|
promoTouch: { select: { promoCodeId: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
skipOrders
|
||||||
|
? Promise.resolve([])
|
||||||
|
: this.prisma.order.findMany({
|
||||||
|
where: orderWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
createdAt: true,
|
||||||
|
cityId: true,
|
||||||
|
promoCodeId: true,
|
||||||
|
payStatus: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.partnerAccount.findMany({
|
||||||
|
where: partnerWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
cityId: true,
|
||||||
|
companyName: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.store.findMany({
|
||||||
|
where: storeWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
cityId: true,
|
||||||
|
partnerAccountId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.redeemRecord.findMany({
|
||||||
|
where: redeemWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
amount: true,
|
||||||
|
settleAmount: true,
|
||||||
|
store: { select: { cityId: true, partnerAccountId: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.commonCity.findMany({
|
||||||
|
select: { id: true, code: true, name: true },
|
||||||
|
}),
|
||||||
|
this.prisma.commonPromoCode.findMany({
|
||||||
|
select: { id: true, code: true, name: true },
|
||||||
|
}),
|
||||||
|
this.prisma.partnerAccount.findMany({
|
||||||
|
where: { isPrimary: 1 },
|
||||||
|
select: { id: true, companyName: true, name: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cityByCode = new Map(cities.map((c) => [c.code, c]));
|
||||||
|
const cityById = new Map(cities.map((c) => [c.id.toString(), c]));
|
||||||
|
const promoById = new Map(promos.map((p) => [p.id.toString(), p]));
|
||||||
|
const partnerLabel = new Map(
|
||||||
|
partnerNames.map((p) => [
|
||||||
|
p.id.toString(),
|
||||||
|
p.companyName || p.name || `合伙人#${p.id}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateKeys = eachDate(rangeStart, rangeEnd);
|
||||||
|
type DateBucket = {
|
||||||
|
date: string;
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
partners: number;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
};
|
||||||
|
const byDateMap = new Map<string, DateBucket>(
|
||||||
|
dateKeys.map((d) => [
|
||||||
|
d,
|
||||||
|
{ date: d, users: 0, orders: 0, partners: 0, stores: 0, redeems: 0, redeemAmount: 0 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
type CityBucket = {
|
||||||
|
cityId: string;
|
||||||
|
cityName: string;
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
partners: number;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
};
|
||||||
|
const byCityMap = new Map<string, CityBucket>();
|
||||||
|
|
||||||
|
type PromoBucket = {
|
||||||
|
promoCodeId: string | null;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
users: number;
|
||||||
|
orders: number;
|
||||||
|
};
|
||||||
|
const byPromoMap = new Map<string, PromoBucket>();
|
||||||
|
|
||||||
|
type PartnerBucket = {
|
||||||
|
partnerAccountId: string;
|
||||||
|
companyName: string;
|
||||||
|
stores: number;
|
||||||
|
redeems: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
};
|
||||||
|
const byPartnerMap = new Map<string, PartnerBucket>();
|
||||||
|
|
||||||
|
const ensureCity = (key: string, cityId: string, cityName: string) => {
|
||||||
|
let b = byCityMap.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = {
|
||||||
|
cityId,
|
||||||
|
cityName,
|
||||||
|
users: 0,
|
||||||
|
orders: 0,
|
||||||
|
partners: 0,
|
||||||
|
stores: 0,
|
||||||
|
redeems: 0,
|
||||||
|
redeemAmount: 0,
|
||||||
|
};
|
||||||
|
byCityMap.set(key, b);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePromo = (
|
||||||
|
key: string,
|
||||||
|
promoCodeId: string | null,
|
||||||
|
code: string,
|
||||||
|
name: string,
|
||||||
|
) => {
|
||||||
|
let b = byPromoMap.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = { promoCodeId, code, name, users: 0, orders: 0 };
|
||||||
|
byPromoMap.set(key, b);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePartner = (key: string, companyName: string) => {
|
||||||
|
let b = byPartnerMap.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = {
|
||||||
|
partnerAccountId: key,
|
||||||
|
companyName,
|
||||||
|
stores: 0,
|
||||||
|
redeems: 0,
|
||||||
|
redeemAmount: 0,
|
||||||
|
};
|
||||||
|
byPartnerMap.set(key, b);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const d = formatYmd(u.createdAt);
|
||||||
|
const day = byDateMap.get(d);
|
||||||
|
if (day) day.users += 1;
|
||||||
|
|
||||||
|
const code = u.cityPreference?.selectedCityCode ?? null;
|
||||||
|
if (code && cityByCode.has(code)) {
|
||||||
|
const city = cityByCode.get(code)!;
|
||||||
|
ensureCity(city.id.toString(), city.id.toString(), city.name).users += 1;
|
||||||
|
} else {
|
||||||
|
ensureCity('none', 'none', '未选城').users += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = u.promoTouch?.promoCodeId?.toString() ?? null;
|
||||||
|
if (pid && promoById.has(pid)) {
|
||||||
|
const p = promoById.get(pid)!;
|
||||||
|
ensurePromo(pid, pid, p.code, p.name).users += 1;
|
||||||
|
} else {
|
||||||
|
ensurePromo('none', null, 'ORGANIC', '自然量').users += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payingUserIds = new Set<string>();
|
||||||
|
for (const o of orders) {
|
||||||
|
const d = formatYmd(o.createdAt);
|
||||||
|
const day = byDateMap.get(d);
|
||||||
|
if (day) day.orders += 1;
|
||||||
|
|
||||||
|
const cid = o.cityId.toString();
|
||||||
|
const city = cityById.get(cid);
|
||||||
|
ensureCity(cid, cid, city?.name ?? `城市#${cid}`).orders += 1;
|
||||||
|
|
||||||
|
const pid = o.promoCodeId?.toString() ?? null;
|
||||||
|
if (pid && promoById.has(pid)) {
|
||||||
|
const p = promoById.get(pid)!;
|
||||||
|
ensurePromo(pid, pid, p.code, p.name).orders += 1;
|
||||||
|
} else {
|
||||||
|
ensurePromo('none', null, 'NONE', '无推广码').orders += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o.payStatus === 'PAID') {
|
||||||
|
payingUserIds.add(o.userId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of partners) {
|
||||||
|
const d = formatYmd(p.createdAt);
|
||||||
|
const day = byDateMap.get(d);
|
||||||
|
if (day) day.partners += 1;
|
||||||
|
|
||||||
|
if (p.cityId) {
|
||||||
|
const cid = p.cityId.toString();
|
||||||
|
const city = cityById.get(cid);
|
||||||
|
ensureCity(cid, cid, city?.name ?? `城市#${cid}`).partners += 1;
|
||||||
|
} else {
|
||||||
|
ensureCity('none', 'none', '未绑定城市').partners += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = p.id.toString();
|
||||||
|
ensurePartner(key, p.companyName || p.name || `合伙人#${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of stores) {
|
||||||
|
const d = formatYmd(s.createdAt);
|
||||||
|
const day = byDateMap.get(d);
|
||||||
|
if (day) day.stores += 1;
|
||||||
|
|
||||||
|
const cid = s.cityId.toString();
|
||||||
|
const city = cityById.get(cid);
|
||||||
|
ensureCity(cid, cid, city?.name ?? `城市#${cid}`).stores += 1;
|
||||||
|
|
||||||
|
const pid = s.partnerAccountId.toString();
|
||||||
|
ensurePartner(pid, partnerLabel.get(pid) || `合伙人#${pid}`).stores += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let redeemAmountTotal = 0;
|
||||||
|
for (const r of redeems) {
|
||||||
|
const amount = num(r.amount);
|
||||||
|
redeemAmountTotal += amount;
|
||||||
|
|
||||||
|
const d = formatYmd(r.createdAt);
|
||||||
|
const day = byDateMap.get(d);
|
||||||
|
if (day) {
|
||||||
|
day.redeems += 1;
|
||||||
|
day.redeemAmount += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cid = r.store.cityId.toString();
|
||||||
|
const city = cityById.get(cid);
|
||||||
|
const cityBucket = ensureCity(cid, cid, city?.name ?? `城市#${cid}`);
|
||||||
|
cityBucket.redeems += 1;
|
||||||
|
cityBucket.redeemAmount += amount;
|
||||||
|
|
||||||
|
const pid = r.store.partnerAccountId.toString();
|
||||||
|
const partnerBucket = ensurePartner(
|
||||||
|
pid,
|
||||||
|
partnerLabel.get(pid) || `合伙人#${pid}`,
|
||||||
|
);
|
||||||
|
partnerBucket.redeems += 1;
|
||||||
|
partnerBucket.redeemAmount += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCity = [...byCityMap.values()].sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.users + b.orders + b.partners + b.stores + b.redeems -
|
||||||
|
(a.users + a.orders + a.partners + a.stores + a.redeems),
|
||||||
|
);
|
||||||
|
const byPromo = [...byPromoMap.values()].sort(
|
||||||
|
(a, b) => b.users + b.orders - (a.users + a.orders),
|
||||||
|
);
|
||||||
|
const byPartner = [...byPartnerMap.values()].sort(
|
||||||
|
(a, b) => b.stores + b.redeems - (a.stores + a.redeems),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
users: users.length,
|
||||||
|
orders: orders.length,
|
||||||
|
payingUsers: payingUserIds.size,
|
||||||
|
partners: partners.length,
|
||||||
|
stores: stores.length,
|
||||||
|
redeems: redeems.length,
|
||||||
|
redeemAmount: Math.round(redeemAmountTotal * 100) / 100,
|
||||||
|
},
|
||||||
|
byDate: dateKeys.map((d) => {
|
||||||
|
const row = byDateMap.get(d)!;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
byCity: byCity.map((row) => ({
|
||||||
|
...row,
|
||||||
|
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
|
||||||
|
})),
|
||||||
|
byPromo,
|
||||||
|
byPartner: byPartner.map((row) => ({
|
||||||
|
...row,
|
||||||
|
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,33 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
|||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||||
|
export class AdminDashboardAnalyticsQueryDto {
|
||||||
|
/** YYYY-MM-DD,默认近 30 天 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
/** 开城城市 id;`none` = 用户未选城 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cityId?: string;
|
||||||
|
|
||||||
|
/** 推广码 id;`none` = 无归因 / 订单无推广码 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
promoCodeId?: string;
|
||||||
|
|
||||||
|
/** 城市合伙人(主账号)id */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
partnerAccountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class AdminStoresQueryDto extends PaginationQueryDto {
|
export class AdminStoresQueryDto extends PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
Reference in New Issue
Block a user