概览页增加表格统计

This commit is contained in:
2026-08-02 11:16:51 +08:00
parent 6a23e79f4c
commit 50ba6e9c56
11 changed files with 1618 additions and 5 deletions
+1
View File
@@ -13,6 +13,7 @@
"prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.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:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
+489
View File
@@ -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 { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminDashboardService } from './admin-dashboard.service';
import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
@Controller('admin/dashboard')
@UseGuards(HqAuthGuard)
@@ -13,6 +14,11 @@ export class AdminDashboardController {
return this.dashboardService.getStats();
}
@Get('analytics')
analytics(@Query() query: AdminDashboardAnalyticsQueryDto) {
return this.dashboardService.getAnalytics(query);
}
@Get('version')
@UseGuards(SuperAdminGuard)
version() {
@@ -1,5 +1,44 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
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()
export class AdminDashboardService {
@@ -59,7 +98,6 @@ export class AdminDashboardService {
redeemToday,
deliveriesTotal,
pendingPayouts,
/** 合伙人已确认、待总部审核打款 */
pendingBills,
pendingPartnerDraftBills,
openTickets,
@@ -85,4 +123,426 @@ export class AdminDashboardService {
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;
}
/** 概览页用户/订单 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 {
@IsOptional()
@IsString()