fix:提交修复6个问题

This commit is contained in:
ljy
2026-07-08 23:01:18 +08:00
parent 99be8e0237
commit ab9654564e
43 changed files with 3671 additions and 277 deletions
@@ -1,9 +1,11 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -47,8 +49,14 @@ export class StoreService {
async partnerListStores(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId };
if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) return [];
where.id = { in: storeIds };
}
const stores = await this.prisma.store.findMany({
where: { partnerId: account.partnerId },
where,
include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
});
@@ -62,6 +70,9 @@ export class StoreService {
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
if (this.isSubAccount(account)) {
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
const media = await this.prisma.commonResource.findMany({
where: {
@@ -85,16 +96,27 @@ export class StoreService {
return serializeBigInt(cities);
}
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const account = await this.getPartnerAccount(partnerAccountId);
const normalizedPhone = String(body.phone).trim();
async partnerCheckStorePhone(phone: string) {
const normalizedPhone = phone.trim();
if (!normalizedPhone) {
return { available: false, message: '请填写联系电话' };
}
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
throw new BadRequestException('联系电话须为11位手机号');
return { available: false, message: '联系电话须为11位手机号' };
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
if (existingAccount) {
return { available: false, message: '该手机号已绑定门店' };
}
return { available: true };
}
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const account = await this.getPartnerAccount(partnerAccountId);
const normalizedPhone = String(body.phone).trim();
await this.assertStorePhoneAvailable(normalizedPhone);
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
@@ -103,6 +125,10 @@ export class StoreService {
: [];
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
if (!coverUrl) throw new BadRequestException('请上传门头照');
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
if (!contractUrl) throw new BadRequestException('请上传签约合同');
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -209,6 +235,7 @@ export class StoreService {
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const account = await this.getPartnerAccount(partnerAccountId);
this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
});
@@ -244,6 +271,7 @@ export class StoreService {
body: Record<string, unknown>,
) {
const account = await this.getPartnerAccount(partnerAccountId);
this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
});
@@ -310,7 +338,13 @@ export class StoreService {
async partnerDashboard(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const [storeCount, orderCount, recentStores] = await Promise.all([
this.assertPrimaryAccount(account);
const partnerStoreIds = await this.prisma.store.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const storeIds = partnerStoreIds.map((s) => s.id);
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
this.prisma.order.count({
where: { city: { partnerId: account.partnerId } },
@@ -321,15 +355,322 @@ export class StoreService {
orderBy: { createdAt: 'desc' },
take: 10,
}),
storeIds.length === 0
? Promise.resolve(0)
: this.prisma.commonEvent.count({
where: {
eventType: 'STORE_AUDIT',
status: 'PENDING',
refType: 'STORE',
refId: { in: storeIds },
},
}),
]);
return {
storeCount,
orderCount,
companyName: account.partner.companyName,
recentStores: serializeBigInt(recentStores),
pendingAuditCount,
};
}
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
const account = await this.getPartnerAccount(partnerAccountId);
this.assertPrimaryAccount(account);
const accounts = await this.prisma.partnerAccount.findMany({
where: { partnerId: account.partnerId },
orderBy: { id: 'asc' },
});
const { periodStart, periodEnd } = this.resolveLeaderboardPeriodRange(period);
const entries = await Promise.all(
accounts.map(async (row) => {
const totalStores = await this.prisma.commonEvent.count({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: row.id,
refType: 'STORE',
},
});
const periodStores =
period === 'total'
? totalStores
: await this.prisma.commonEvent.count({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: row.id,
refType: 'STORE',
createdAt: { gte: periodStart, lt: periodEnd },
},
});
const staffRole = row.staffRole as PartnerStaffRole | null;
const roleLabel =
staffRole != null
? PARTNER_STAFF_ROLE_LABELS[staffRole]
: row.isPrimary === 1
? PARTNER_STAFF_ROLE_LABELS.PARTNER
: PARTNER_STAFF_ROLE_LABELS.INTERNAL;
return {
accountId: row.id.toString(),
name: row.name,
staffRole: staffRole ?? undefined,
roleLabel,
totalStores,
periodStores,
isSelf: row.id === partnerAccountId,
};
}),
);
entries.sort((a, b) => {
if (b.periodStores !== a.periodStores) return b.periodStores - a.periodStores;
if (b.totalStores !== a.totalStores) return b.totalStores - a.totalStores;
return a.accountId.localeCompare(b.accountId);
});
const list = entries.map((entry, index) => ({
...entry,
rank: index + 1,
}));
const selfEntry = list.find((entry) => entry.isSelf);
let self: (typeof list)[number] & { beatPercent?: number } | undefined;
if (selfEntry) {
const below = list.filter((entry) => entry.rank > selfEntry.rank).length;
const beatPercent =
list.length <= 1 ? 0 : Math.round((below / (list.length - 1)) * 100);
self = { ...selfEntry, beatPercent };
}
return { period, list, self };
}
async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) {
const account = await this.getPartnerAccount(partnerAccountId);
this.assertPrimaryAccount(account);
const partnerId = account.partnerId;
const currentWeekStart = this.startOfWeekMonday(new Date());
const periodStart =
startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate)
? this.parseLocalDate(startDate)
: currentWeekStart;
const periodEnd = this.addDays(periodStart, 7);
const prevPeriodStart = this.addDays(periodStart, -7);
const prevPeriodEnd = periodStart;
const newStoreTarget =
account.partner.weeklyStoreTarget ??
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
const orderWhere = {
city: { partnerId },
payStatus: 'PAID' as const,
paidAt: { gte: periodStart, lt: periodEnd },
};
const prevOrderWhere = {
city: { partnerId },
payStatus: 'PAID' as const,
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
};
const [
gmvAgg,
orderCount,
totalStoreCount,
newStoreCount,
prevGmvAgg,
redeemGroups,
activeRedeems,
paidOrders,
] = await Promise.all([
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
this.prisma.order.count({ where: orderWhere }),
this.prisma.store.count({ where: { partnerId } }),
this.prisma.store.count({
where: { partnerId, createdAt: { gte: periodStart, lt: periodEnd } },
}),
this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId },
},
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
take: 10,
}),
this.prisma.redeemRecord.findMany({
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId },
},
select: { storeId: true },
distinct: ['storeId'],
}),
this.prisma.order.findMany({
where: orderWhere,
select: { payAmount: true, paidAt: true },
}),
]);
const gmv = Number(gmvAgg._sum.payAmount ?? 0);
const prevGmv = Number(prevGmvAgg._sum.payAmount ?? 0);
const gmvGrowthPercent =
prevGmv === 0 ? 0 : Math.round(((gmv - prevGmv) / prevGmv) * 1000) / 10;
const newStoreProgressPercent =
newStoreTarget <= 0
? 0
: Math.min(100, Math.round((newStoreCount / newStoreTarget) * 100));
const dailyGmvMap = new Map<string, number>();
for (let i = 0; i < 7; i += 1) {
dailyGmvMap.set(this.formatDateKey(this.addDays(periodStart, i)), 0);
}
for (const order of paidOrders) {
if (!order.paidAt) continue;
const key = this.formatDateKey(order.paidAt);
if (dailyGmvMap.has(key)) {
dailyGmvMap.set(key, (dailyGmvMap.get(key) ?? 0) + Number(order.payAmount));
}
}
const dailyGmv = Array.from({ length: 7 }, (_, i) => {
const day = this.addDays(periodStart, i);
const date = this.formatDateKey(day);
return {
date,
weekdayLabel: this.weekdayLabel(i),
amount: dailyGmvMap.get(date) ?? 0,
};
});
const rankStoreIds = redeemGroups.map((group) => group.storeId);
const rankStores =
rankStoreIds.length > 0
? await this.prisma.store.findMany({
where: { id: { in: rankStoreIds } },
select: { id: true, name: true, intro: true, address: true },
})
: [];
const storeMap = new Map(rankStores.map((store) => [store.id.toString(), store]));
const storeRanking = redeemGroups.map((group, index) => {
const store = storeMap.get(group.storeId.toString());
const subtitleSource = store?.intro?.trim() || store?.address?.trim() || '';
const subtitle =
subtitleSource.length > 30 ? `${subtitleSource.slice(0, 30)}` : subtitleSource;
return {
rank: index + 1,
storeId: group.storeId.toString(),
name: store?.name ?? '未知门店',
subtitle: subtitle || undefined,
redeemAmount: Number(group._sum.amount ?? 0),
};
});
const topStore = storeRanking[0];
let insight: string;
if (topStore && topStore.redeemAmount > 0) {
if (gmvGrowthPercent > 0) {
insight = `本周 GMV 增长 ${gmvGrowthPercent}%,主要得益于「${topStore.name}」的核销表现。建议关注同类高潜力门店。`;
} else if (gmvGrowthPercent < 0) {
insight = `本周 GMV 较上期下降 ${Math.abs(gmvGrowthPercent)}%,「${topStore.name}」仍为核销领先门店。建议复盘低效门店并复制头部经验。`;
} else {
insight = `本周 GMV 与上期持平,「${topStore.name}」核销表现领先。建议持续推动门店活跃。`;
}
} else {
insight = '本周暂无核销数据,建议关注门店培训与权益推广,激活辖区门店。';
}
const availablePeriods = [0, 1, 2, 3].map((offset) => {
const start = this.addDays(currentWeekStart, -7 * offset);
const end = this.addDays(start, 7);
return {
startDate: this.formatDateKey(start),
endDate: this.formatDateKey(this.addDays(end, -1)),
label: this.formatPeriodLabel(start, end),
};
});
return {
period: {
startDate: this.formatDateKey(periodStart),
endDate: this.formatDateKey(this.addDays(periodEnd, -1)),
label: this.formatPeriodLabel(periodStart, periodEnd),
},
availablePeriods,
summary: {
gmv,
gmvGrowthPercent,
activeStoreCount: activeRedeems.length,
totalStoreCount,
orderCount,
newStoreCount,
newStoreTarget,
newStoreProgressPercent,
},
dailyGmv,
storeRanking,
insight,
};
}
private resolveLeaderboardPeriodRange(period: PartnerLeaderboardPeriod) {
const now = new Date();
if (period === 'month') {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
return { periodStart, periodEnd: now };
}
if (period === 'lastMonth') {
const periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth(), 1);
return { periodStart, periodEnd };
}
return { periodStart: new Date(0), periodEnd: now };
}
private startOfWeekMonday(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
return d;
}
private addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
private parseLocalDate(dateKey: string): Date {
const [year, month, day] = dateKey.split('-').map(Number);
return new Date(year, month - 1, day, 0, 0, 0, 0);
}
private formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private formatPeriodLabel(start: Date, endExclusive: Date): string {
const end = this.addDays(endExclusive, -1);
return `${start.getMonth() + 1}${start.getDate()}日 - ${end.getMonth() + 1}${end.getDate()}`;
}
private weekdayLabel(index: number): string {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
}
private async getPartnerAccount(partnerAccountId: bigint) {
return this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
@@ -349,4 +690,49 @@ export class StoreService {
if (!city) throw new BadRequestException('合伙人未绑定开城');
return city;
}
private isSubAccount(account: { isPrimary: number }) {
return account.isPrimary !== 1;
}
private assertPrimaryAccount(account: { isPrimary: number }) {
if (this.isSubAccount(account)) {
throw new ForbiddenException('子账号无权执行此操作');
}
}
private async assertStorePhoneAvailable(phone: string) {
const result = await this.partnerCheckStorePhone(phone);
if (!result.available) {
throw new BadRequestException(result.message ?? '该手机号已绑定门店');
}
}
private async getStoreIdsCreatedByAccount(partnerAccountId: bigint): Promise<bigint[]> {
const events = await this.prisma.commonEvent.findMany({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: partnerAccountId,
refType: 'STORE',
},
select: { refId: true },
});
return events.map((event) => event.refId).filter((id): id is bigint => id != null);
}
private async assertStoreOwnedByAccount(partnerAccountId: bigint, storeId: bigint) {
const event = await this.prisma.commonEvent.findFirst({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: partnerAccountId,
refType: 'STORE',
refId: storeId,
},
});
if (!event) throw new ForbiddenException('无权查看该门店');
}
}