@@ -1,6 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { HqPermissionKey } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import {
|
||||
HqPermissionsResolver,
|
||||
assertHqCityInScope,
|
||||
hqStoreCityWhere,
|
||||
type HqCityScope,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
function startOfDay(d: Date) {
|
||||
@@ -55,14 +62,63 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
||||
return now.getTime() > deadline.getTime();
|
||||
}
|
||||
|
||||
function cityIdFilter(scope: HqCityScope): Prisma.BigIntFilter | undefined {
|
||||
if (scope === null) return undefined;
|
||||
if (!scope.length) return { equals: BigInt(0) };
|
||||
return { in: scope };
|
||||
}
|
||||
|
||||
function emptyRows<T>(): Promise<T[]> {
|
||||
return Promise.resolve([] as T[]);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
async getStats(actorId: bigint) {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const [{ keys, isSuperAdmin }, scope] = await Promise.all([
|
||||
this.hqPermissions.resolveAccess(actorId),
|
||||
this.hqPermissions.resolveCityScope(actorId),
|
||||
]);
|
||||
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
|
||||
const storeWhere = hqStoreCityWhere(scope) ?? {};
|
||||
const cityFilter = cityIdFilter(scope);
|
||||
const orderWhere: Prisma.OrderWhereInput = cityFilter ? { cityId: cityFilter } : {};
|
||||
const partnerWhere: Prisma.PartnerAccountWhereInput = {
|
||||
isPrimary: 1,
|
||||
...(cityFilter ? { cityId: cityFilter } : {}),
|
||||
};
|
||||
|
||||
let userWhere: Prisma.UserWhereInput = { status: 1, mergedIntoUserId: null };
|
||||
if (scope !== null) {
|
||||
if (!scope.length) {
|
||||
userWhere = { id: { equals: BigInt(0) } };
|
||||
} else {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { id: { in: scope } },
|
||||
select: { code: true },
|
||||
});
|
||||
const codes = cities.map((c) => c.code);
|
||||
userWhere = {
|
||||
status: 1,
|
||||
mergedIntoUserId: null,
|
||||
cityPreference: { selectedCityCode: { in: codes.length ? codes : [''] } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const redeemTodayWhere: Prisma.RedeemRecordWhereInput = {
|
||||
createdAt: { gte: todayStart },
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
};
|
||||
|
||||
const [
|
||||
usersTotal,
|
||||
guestUsers,
|
||||
@@ -79,32 +135,112 @@ export class AdminDashboardService {
|
||||
pendingPartnerDraftBills,
|
||||
openTickets,
|
||||
pendingWithdrawRows,
|
||||
pendingStoreOnboard,
|
||||
pendingStorePackageAudits,
|
||||
pendingStoreInfoChanges,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||
this.prisma.user.count({
|
||||
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null },
|
||||
}),
|
||||
this.prisma.user.count({
|
||||
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } },
|
||||
}),
|
||||
this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }),
|
||||
this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
}),
|
||||
this.prisma.store.count(),
|
||||
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.orderDelivery.count(),
|
||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||
this.prisma.storeWithdrawRequest.findMany({
|
||||
where: { status: 'PENDING_REVIEW' },
|
||||
select: { appliedAt: true },
|
||||
}),
|
||||
can('users')
|
||||
? this.prisma.user.count({ where: userWhere })
|
||||
: Promise.resolve(0),
|
||||
can('users')
|
||||
? this.prisma.user.count({
|
||||
where: { ...userWhere, phoneVerifiedAt: null },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('users')
|
||||
? this.prisma.user.count({
|
||||
where: { ...userWhere, phoneVerifiedAt: { not: null } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('users')
|
||||
? this.prisma.user.count({
|
||||
where:
|
||||
scope === null
|
||||
? { mergedIntoUserId: { not: null } }
|
||||
: { id: { equals: BigInt(0) } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('orders')
|
||||
? this.prisma.order.count({
|
||||
where: { ...orderWhere, createdAt: { gte: todayStart } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('orders')
|
||||
? this.prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
where: orderWhere,
|
||||
_count: { status: true },
|
||||
})
|
||||
: emptyRows<{ status: string; _count: { status: number } }>(),
|
||||
can('stores') ? this.prisma.store.count({ where: storeWhere }) : Promise.resolve(0),
|
||||
can('partners')
|
||||
? this.prisma.partnerAccount.count({ where: partnerWhere })
|
||||
: Promise.resolve(0),
|
||||
can('benefit')
|
||||
? this.prisma.redeemRecord.count({ where: redeemTodayWhere })
|
||||
: Promise.resolve(0),
|
||||
can('deliveries')
|
||||
? this.prisma.orderDelivery.count({
|
||||
where: cityFilter ? { order: { cityId: cityFilter } } : undefined,
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.storePayout.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.partnerBill.count({
|
||||
where: {
|
||||
status: 'UNPAID',
|
||||
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.partnerBill.count({
|
||||
where: {
|
||||
status: 'PENDING_REVIEW',
|
||||
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('tickets') && scope === null
|
||||
? this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } })
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.storeWithdrawRequest.findMany({
|
||||
where: {
|
||||
status: 'PENDING_REVIEW',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
select: { appliedAt: true },
|
||||
})
|
||||
: emptyRows<{ appliedAt: Date }>(),
|
||||
can('stores')
|
||||
? this.prisma.store.count({
|
||||
where: { auditStatus: 'PENDING', ...storeWhere },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('store_audits')
|
||||
? this.prisma.storePackageChangeRequest.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('store_audits')
|
||||
? this.prisma.storeInfoChangeRequest.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const now = new Date();
|
||||
@@ -129,6 +265,9 @@ export class AdminDashboardService {
|
||||
openTickets,
|
||||
pendingStoreWithdrawals,
|
||||
overdueStoreWithdrawals,
|
||||
pendingStoreOnboard,
|
||||
pendingStorePackageAudits,
|
||||
pendingStoreInfoChanges,
|
||||
ordersByStatus: ordersByStatus.map((row) => ({
|
||||
status: row.status,
|
||||
count: row._count.status,
|
||||
@@ -152,7 +291,7 @@ export class AdminDashboardService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAnalytics(query: AdminDashboardAnalyticsQueryDto) {
|
||||
async getAnalytics(actorId: bigint, query: AdminDashboardAnalyticsQueryDto) {
|
||||
const today = startOfDay(new Date());
|
||||
const defaultFrom = new Date(today);
|
||||
defaultFrom.setDate(defaultFrom.getDate() - 29);
|
||||
@@ -164,9 +303,18 @@ export class AdminDashboardService {
|
||||
const rangeStart = startOfDay(from <= to ? from : to);
|
||||
const rangeEnd = endOfDay(from <= to ? to : from);
|
||||
|
||||
let filterCityCode: string | null | undefined;
|
||||
let filterCityId: bigint | null | undefined;
|
||||
const [{ keys, isSuperAdmin }, scope] = await Promise.all([
|
||||
this.hqPermissions.resolveAccess(actorId),
|
||||
this.hqPermissions.resolveCityScope(actorId),
|
||||
]);
|
||||
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
|
||||
|
||||
let filterCityCode: string | string[] | null | undefined;
|
||||
let filterCityId: bigint | bigint[] | null | undefined;
|
||||
if (query.cityId === 'none') {
|
||||
if (scope !== null) {
|
||||
throw new ForbiddenException('无权按未选城筛选');
|
||||
}
|
||||
filterCityCode = null;
|
||||
filterCityId = null;
|
||||
} else if (query.cityId) {
|
||||
@@ -174,20 +322,47 @@ export class AdminDashboardService {
|
||||
where: { id: BigInt(query.cityId) },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
if (city) {
|
||||
filterCityCode = city.code;
|
||||
filterCityId = city.id;
|
||||
if (!city) {
|
||||
throw new ForbiddenException('无权访问该城市的门店');
|
||||
}
|
||||
assertHqCityInScope(scope, city.id);
|
||||
filterCityCode = city.code;
|
||||
filterCityId = city.id;
|
||||
} else if (scope !== null) {
|
||||
if (!scope.length) {
|
||||
filterCityId = [];
|
||||
filterCityCode = [];
|
||||
} else {
|
||||
const scopedCities = await this.prisma.commonCity.findMany({
|
||||
where: { id: { in: scope } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
filterCityId = scope;
|
||||
filterCityCode = scopedCities.map((c) => c.code);
|
||||
}
|
||||
}
|
||||
|
||||
const filterPromoNone = query.promoCodeId === 'none';
|
||||
const filterPromoId =
|
||||
query.promoCodeId && query.promoCodeId !== 'none'
|
||||
can('promo_codes') && query.promoCodeId && query.promoCodeId !== 'none'
|
||||
? BigInt(query.promoCodeId)
|
||||
: undefined;
|
||||
const filterPartnerId = query.partnerAccountId
|
||||
? BigInt(query.partnerAccountId)
|
||||
: undefined;
|
||||
const filterPartnerId =
|
||||
can('partners') && query.partnerAccountId
|
||||
? BigInt(query.partnerAccountId)
|
||||
: undefined;
|
||||
|
||||
if (filterPartnerId !== undefined && scope !== null) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: filterPartnerId },
|
||||
select: { cityId: true },
|
||||
});
|
||||
if (partner?.cityId) {
|
||||
assertHqCityInScope(scope, partner.cityId);
|
||||
} else if (scope !== null) {
|
||||
throw new ForbiddenException('无权访问该城市的门店');
|
||||
}
|
||||
}
|
||||
|
||||
const userWhere: Prisma.UserWhereInput = {
|
||||
status: 1,
|
||||
@@ -199,10 +374,14 @@ export class AdminDashboardService {
|
||||
{ cityPreference: null },
|
||||
{ cityPreference: { selectedCityCode: null } },
|
||||
];
|
||||
} else if (Array.isArray(filterCityCode)) {
|
||||
userWhere.cityPreference = {
|
||||
selectedCityCode: { in: filterCityCode.length ? filterCityCode : [''] },
|
||||
};
|
||||
} else if (filterCityCode) {
|
||||
userWhere.cityPreference = { selectedCityCode: filterCityCode };
|
||||
}
|
||||
if (filterPromoNone) {
|
||||
if (can('promo_codes') && filterPromoNone) {
|
||||
userWhere.promoTouch = { is: null };
|
||||
} else if (filterPromoId !== undefined) {
|
||||
userWhere.promoTouch = { promoCodeId: filterPromoId };
|
||||
@@ -211,10 +390,14 @@ export class AdminDashboardService {
|
||||
const orderWhere: Prisma.OrderWhereInput = {
|
||||
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||
};
|
||||
if (filterCityId !== undefined && filterCityId !== null) {
|
||||
if (filterCityId === null) {
|
||||
orderWhere.id = { in: [] };
|
||||
} else if (Array.isArray(filterCityId)) {
|
||||
orderWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
orderWhere.cityId = filterCityId;
|
||||
}
|
||||
if (filterPromoNone) {
|
||||
if (can('promo_codes') && filterPromoNone) {
|
||||
orderWhere.promoCodeId = null;
|
||||
} else if (filterPromoId !== undefined) {
|
||||
orderWhere.promoCodeId = filterPromoId;
|
||||
@@ -226,6 +409,8 @@ export class AdminDashboardService {
|
||||
};
|
||||
if (filterCityId === null) {
|
||||
partnerWhere.cityId = null;
|
||||
} else if (Array.isArray(filterCityId)) {
|
||||
partnerWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
partnerWhere.cityId = filterCityId;
|
||||
}
|
||||
@@ -237,8 +422,9 @@ export class AdminDashboardService {
|
||||
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||
};
|
||||
if (filterCityId === null) {
|
||||
// 门店必有 cityId
|
||||
storeWhere.id = { in: [] };
|
||||
} else if (Array.isArray(filterCityId)) {
|
||||
storeWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
storeWhere.cityId = filterCityId;
|
||||
}
|
||||
@@ -253,28 +439,56 @@ export class AdminDashboardService {
|
||||
redeemWhere.id = { in: [] };
|
||||
} else {
|
||||
const storeFilter: Prisma.StoreWhereInput = {};
|
||||
if (filterCityId !== undefined) storeFilter.cityId = filterCityId;
|
||||
if (Array.isArray(filterCityId)) {
|
||||
storeFilter.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else 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 skipOrders = filterCityId === null || !can('orders');
|
||||
const cityListWhere: Prisma.CommonCityWhereInput =
|
||||
scope === null ? {} : { id: { in: scope.length ? scope : [BigInt(0)] } };
|
||||
|
||||
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 } },
|
||||
},
|
||||
}),
|
||||
can('users')
|
||||
? this.prisma.user.findMany({
|
||||
where: userWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityPreference: { select: { selectedCityCode: true } },
|
||||
promoTouch: { select: { promoCodeId: true } },
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.UserGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
createdAt: true;
|
||||
cityPreference: { select: { selectedCityCode: true } };
|
||||
promoTouch: { select: { promoCodeId: true } };
|
||||
};
|
||||
}>
|
||||
>(),
|
||||
skipOrders
|
||||
? Promise.resolve([])
|
||||
? emptyRows<
|
||||
Prisma.OrderGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
userId: true;
|
||||
createdAt: true;
|
||||
cityId: true;
|
||||
promoCodeId: true;
|
||||
payStatus: true;
|
||||
};
|
||||
}>
|
||||
>()
|
||||
: this.prisma.order.findMany({
|
||||
where: orderWhere,
|
||||
select: {
|
||||
@@ -286,45 +500,81 @@ export class AdminDashboardService {
|
||||
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 } },
|
||||
},
|
||||
}),
|
||||
can('partners')
|
||||
? this.prisma.partnerAccount.findMany({
|
||||
where: partnerWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityId: true,
|
||||
companyName: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.PartnerAccountGetPayload<{
|
||||
select: { id: true; createdAt: true; cityId: true; companyName: true; name: true };
|
||||
}>
|
||||
>(),
|
||||
can('stores')
|
||||
? this.prisma.store.findMany({
|
||||
where: storeWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityId: true,
|
||||
partnerAccountId: true,
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.StoreGetPayload<{
|
||||
select: { id: true; createdAt: true; cityId: true; partnerAccountId: true };
|
||||
}>
|
||||
>(),
|
||||
can('benefit')
|
||||
? this.prisma.redeemRecord.findMany({
|
||||
where: redeemWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
amount: true,
|
||||
settleAmount: true,
|
||||
store: { select: { cityId: true, partnerAccountId: true } },
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.RedeemRecordGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
createdAt: true;
|
||||
amount: true;
|
||||
settleAmount: true;
|
||||
store: { select: { cityId: true; partnerAccountId: true } };
|
||||
};
|
||||
}>
|
||||
>(),
|
||||
this.prisma.commonCity.findMany({
|
||||
where: cityListWhere,
|
||||
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 },
|
||||
}),
|
||||
can('promo_codes')
|
||||
? this.prisma.commonPromoCode.findMany({
|
||||
select: { id: true, code: true, name: true },
|
||||
})
|
||||
: emptyRows<Prisma.CommonPromoCodeGetPayload<{ select: { id: true; code: true; name: true } }>>(),
|
||||
can('partners') || can('stores') || can('benefit')
|
||||
? this.prisma.partnerAccount.findMany({
|
||||
where: {
|
||||
isPrimary: 1,
|
||||
...(cityIdFilter(scope) ? { cityId: cityIdFilter(scope) } : {}),
|
||||
},
|
||||
select: { id: true, companyName: true, name: true },
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.PartnerAccountGetPayload<{
|
||||
select: { id: true; companyName: true; name: true };
|
||||
}>
|
||||
>(),
|
||||
]);
|
||||
|
||||
const cityByCode = new Map(cities.map((c) => [c.code, c]));
|
||||
|
||||
Reference in New Issue
Block a user