Files
dukang/server/dukang-api/src/modules/ops/admin-dashboard.service.ts
T
jacy 68a4b7f984
CI / verify (pull_request) Waiting to run
v4.0.14概览和日报
2026-09-02 21:11:58 +08:00

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,
};
}
}