339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { ForbiddenException, Injectable } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import type {
|
|
DashboardAnalytics,
|
|
DashboardGranularity,
|
|
HqPermissionKey,
|
|
} from '@dukang/shared-types';
|
|
import {
|
|
addShanghaiDays,
|
|
defaultShanghaiRangeYmds,
|
|
eachShanghaiBuckets,
|
|
parseShanghaiYmd,
|
|
shanghaiYmd,
|
|
} from '@dukang/domain';
|
|
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';
|
|
import { type CityFilter } from './admin-dashboard-analytics';
|
|
import { loadDashboardLineCharts } from './admin-dashboard-lines';
|
|
|
|
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
|
const day = appliedAt.getDay();
|
|
if (day === 0 || day === 6) return false;
|
|
const deadline = new Date(
|
|
appliedAt.getFullYear(),
|
|
appliedAt.getMonth(),
|
|
appliedAt.getDate(),
|
|
18,
|
|
0,
|
|
0,
|
|
0,
|
|
);
|
|
return now.getTime() > deadline.getTime();
|
|
}
|
|
|
|
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,
|
|
private readonly hqPermissions: HqPermissionsResolver,
|
|
) {}
|
|
|
|
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,
|
|
verifiedUsers,
|
|
mergedUsers,
|
|
ordersToday,
|
|
ordersByStatus,
|
|
storesTotal,
|
|
partnersTotal,
|
|
redeemToday,
|
|
deliveriesTotal,
|
|
pendingPayouts,
|
|
pendingBills,
|
|
pendingPartnerDraftBills,
|
|
openTickets,
|
|
pendingWithdrawRows,
|
|
pendingStoreOnboard,
|
|
pendingStorePackageAudits,
|
|
pendingStoreInfoChanges,
|
|
] = await Promise.all([
|
|
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: {
|
|
order: {
|
|
deliveryType: { not: 'ON_SITE_PICKUP' },
|
|
...(cityFilter ? { cityId: cityFilter } : {}),
|
|
},
|
|
},
|
|
})
|
|
: 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();
|
|
const pendingStoreWithdrawals = pendingWithdrawRows.length;
|
|
const overdueStoreWithdrawals = pendingWithdrawRows.filter((r) =>
|
|
isWithdrawOverdue(r.appliedAt, now),
|
|
).length;
|
|
|
|
return {
|
|
usersTotal,
|
|
guestUsers,
|
|
verifiedUsers,
|
|
mergedUsers,
|
|
ordersToday,
|
|
storesTotal,
|
|
partnersTotal,
|
|
redeemToday,
|
|
deliveriesTotal,
|
|
pendingPayouts,
|
|
pendingBills,
|
|
pendingPartnerDraftBills,
|
|
openTickets,
|
|
pendingStoreWithdrawals,
|
|
overdueStoreWithdrawals,
|
|
pendingStoreOnboard,
|
|
pendingStorePackageAudits,
|
|
pendingStoreInfoChanges,
|
|
ordersByStatus: ordersByStatus.map((row) => ({
|
|
status: row.status,
|
|
count: row._count.status,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async getLatestVersion() {
|
|
const row = await this.prisma.systemVersion.findFirst({
|
|
orderBy: { deployedAt: 'desc' },
|
|
});
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id.toString(),
|
|
gitTag: row.gitTag,
|
|
commitId: row.commitId,
|
|
commitMessage: row.commitMessage,
|
|
branch: row.branch,
|
|
deployedBy: row.deployedBy,
|
|
deployedAt: row.deployedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async getAnalytics(
|
|
actorId: bigint,
|
|
query: AdminDashboardAnalyticsQueryDto,
|
|
): Promise<DashboardAnalytics> {
|
|
const grain: DashboardGranularity = query.granularity ?? 'day';
|
|
const defaults = defaultShanghaiRangeYmds(grain);
|
|
const fromYmd = /^\d{4}-\d{2}-\d{2}$/.test(query.dateFrom ?? '')
|
|
? query.dateFrom!
|
|
: defaults.from;
|
|
const toYmd = /^\d{4}-\d{2}-\d{2}$/.test(query.dateTo ?? '') ? query.dateTo! : defaults.to;
|
|
const fromDay = parseShanghaiYmd(fromYmd <= toYmd ? fromYmd : toYmd);
|
|
const toDay = parseShanghaiYmd(fromYmd <= toYmd ? toYmd : fromYmd);
|
|
const rangeStart = fromDay;
|
|
const rangeEndExclusive = addShanghaiDays(toDay, 1);
|
|
|
|
const [{ keys, isSuperAdmin }, scope] = await Promise.all([
|
|
this.hqPermissions.resolveAccess(actorId),
|
|
this.hqPermissions.resolveCityScope(actorId),
|
|
]);
|
|
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
|
|
|
|
let city: CityFilter = { kind: 'all' };
|
|
if (query.cityId === 'none') {
|
|
if (scope !== null) throw new ForbiddenException('无权按未选城筛选');
|
|
city = { kind: 'none' };
|
|
} else if (query.cityId) {
|
|
const row = await this.prisma.commonCity.findUnique({
|
|
where: { id: BigInt(query.cityId) },
|
|
select: { id: true, code: true },
|
|
});
|
|
if (!row) throw new ForbiddenException('无权访问该城市的门店');
|
|
assertHqCityInScope(scope, row.id);
|
|
city = { kind: 'ids', ids: [row.id], codes: [row.code] };
|
|
} else if (scope !== null) {
|
|
if (!scope.length) {
|
|
city = { kind: 'empty' };
|
|
} else {
|
|
const scopedCities = await this.prisma.commonCity.findMany({
|
|
where: { id: { in: scope } },
|
|
select: { id: true, code: true },
|
|
});
|
|
city = { kind: 'ids', ids: scope, codes: scopedCities.map((c) => c.code) };
|
|
}
|
|
}
|
|
|
|
const buckets = eachShanghaiBuckets(rangeStart, toDay, grain);
|
|
const charts = await loadDashboardLineCharts({
|
|
prisma: this.prisma,
|
|
grain,
|
|
city,
|
|
rangeStart,
|
|
rangeEndExclusive,
|
|
periodKeys: buckets.map((b) => b.key),
|
|
can,
|
|
});
|
|
|
|
return {
|
|
granularity: grain,
|
|
range: { from: shanghaiYmd(fromDay), to: shanghaiYmd(toDay) },
|
|
periods: buckets.map((b) => ({ key: b.key, label: b.label })),
|
|
charts,
|
|
};
|
|
}
|
|
}
|