4372018c09
Refund rollback, winery T+3, multi withdraw, mini-user store detail, dev plan batch edit and WeCom dispatch, support ticket edit/attachments/batch status, package imageUrl. Co-authored-by: Cursor <cursoragent@cursor.com>
1371 lines
48 KiB
TypeScript
1371 lines
48 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||
import { validateBusinessHours } from '@dukang/domain';
|
||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||
import { parseBigIntParam } from '../../common/parse-bigint';
|
||
import { AnalyticsService } from '../analytics/analytics.service';
|
||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||
import { AuthService } from '../iam/auth.service';
|
||
import { StoreCategoryService } from './store-category.service';
|
||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||
|
||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||
const R = 6371000;
|
||
const dLat = toRad(lat2 - lat1);
|
||
const dLng = toRad(lng2 - lng1);
|
||
const a =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||
}
|
||
|
||
/** 选填文案:空 / null / "null" 存库为 null,避免 String(null)==="null" */
|
||
function normalizeOptionalTextField(value: unknown): string | null {
|
||
if (value == null) return null;
|
||
const s = String(value).trim();
|
||
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
|
||
return s;
|
||
}
|
||
|
||
export type StoreViewer = {
|
||
/** C 端用户手机号;无则无法看到白名单门店 */
|
||
phone?: string | null;
|
||
/** 运营/代下单等场景跳过白名单 */
|
||
bypassWhitelist?: boolean;
|
||
};
|
||
|
||
function normalizePhone(phone: string | null | undefined): string {
|
||
return (phone || '').replace(/\D/g, '').trim();
|
||
}
|
||
|
||
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
|
||
if (value == null || value === '') return null;
|
||
const n = typeof value === 'number' ? value : Number(value);
|
||
if (!Number.isFinite(n)) return null;
|
||
if (kind === 'lat' && (n < -90 || n > 90)) return null;
|
||
if (kind === 'lng' && (n < -180 || n > 180)) return null;
|
||
return n;
|
||
}
|
||
|
||
@Injectable()
|
||
export class StoreService {
|
||
private readonly config = loadAppConfig();
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly analyticsService: AnalyticsService,
|
||
private readonly partnerCityService: PartnerCityService,
|
||
private readonly authService: AuthService,
|
||
private readonly storeCategoryService: StoreCategoryService,
|
||
private readonly tencentLbs: TencentLbsProvider,
|
||
) {}
|
||
|
||
private storeAddressText(store: {
|
||
province?: string | null;
|
||
cityName?: string | null;
|
||
district?: string | null;
|
||
address?: string | null;
|
||
}) {
|
||
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
|
||
}
|
||
|
||
/** 缺坐标时用地址正向地理编码并回写 */
|
||
private async ensureStoreCoordinates(store: {
|
||
id: bigint;
|
||
latitude?: unknown;
|
||
longitude?: unknown;
|
||
province?: string | null;
|
||
cityName?: string | null;
|
||
district?: string | null;
|
||
address?: string | null;
|
||
}): Promise<{ latitude: number; longitude: number } | null> {
|
||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||
const lng = store.longitude != null ? Number(store.longitude) : NaN;
|
||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||
return { latitude: lat, longitude: lng };
|
||
}
|
||
const address = this.storeAddressText(store);
|
||
if (!address) return null;
|
||
const geo = await this.tencentLbs.geocodeAddress(address, {
|
||
refType: 'STORE',
|
||
refId: store.id,
|
||
});
|
||
if (!geo) return null;
|
||
await this.prisma.store.update({
|
||
where: { id: store.id },
|
||
data: { latitude: geo.latitude, longitude: geo.longitude },
|
||
});
|
||
return { latitude: geo.latitude, longitude: geo.longitude };
|
||
}
|
||
|
||
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
||
const user = await this.prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { phone: true },
|
||
});
|
||
return user?.phone ?? null;
|
||
}
|
||
|
||
isVisibleToViewer(
|
||
store: {
|
||
visibilityWhitelistEnabled: boolean;
|
||
visibilityPhones: Array<{ phone: string }>;
|
||
},
|
||
viewer?: StoreViewer,
|
||
): boolean {
|
||
if (viewer?.bypassWhitelist) return true;
|
||
if (!store.visibilityWhitelistEnabled) return true;
|
||
const phone = normalizePhone(viewer?.phone);
|
||
if (!phone) return false;
|
||
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
|
||
}
|
||
|
||
async listOpenStores(
|
||
cityCode?: string,
|
||
userLat?: number,
|
||
userLng?: number,
|
||
viewer?: StoreViewer,
|
||
) {
|
||
const where: Record<string, unknown> = { status: 'OPEN' };
|
||
if (cityCode) {
|
||
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
|
||
if (city) where.cityId = city.id;
|
||
}
|
||
const stores = await this.prisma.store.findMany({
|
||
where: where as never,
|
||
include: {
|
||
category: true,
|
||
coverResource: true,
|
||
visibilityPhones: { select: { phone: true } },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
|
||
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
|
||
|
||
const hasUser =
|
||
userLat != null &&
|
||
userLng != null &&
|
||
Number.isFinite(userLat) &&
|
||
Number.isFinite(userLng);
|
||
|
||
type StoreListItem = ReturnType<typeof mapStoreCompat> & {
|
||
distanceMeters: number | null;
|
||
latitude?: unknown;
|
||
longitude?: unknown;
|
||
};
|
||
|
||
const items: StoreListItem[] = [];
|
||
for (const store of visible) {
|
||
const coords = await this.ensureStoreCoordinates(store);
|
||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||
const mapped = mapStoreCompat({
|
||
...rest,
|
||
latitude: coords?.latitude ?? store.latitude,
|
||
longitude: coords?.longitude ?? store.longitude,
|
||
});
|
||
const distanceMeters =
|
||
hasUser && coords
|
||
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
||
: null;
|
||
items.push({ ...mapped, distanceMeters });
|
||
}
|
||
|
||
if (hasUser) {
|
||
items.sort((a, b) => {
|
||
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||
return da - db;
|
||
});
|
||
}
|
||
|
||
return serializeBigInt(items);
|
||
}
|
||
|
||
async getStore(id: bigint, viewer?: StoreViewer) {
|
||
const store = await this.prisma.store.findFirst({
|
||
where: { id, status: 'OPEN' },
|
||
include: {
|
||
category: true,
|
||
coverResource: true,
|
||
visibilityPhones: { select: { phone: true } },
|
||
},
|
||
});
|
||
if (!store || !this.isVisibleToViewer(store, viewer)) {
|
||
throw new NotFoundException('门店不存在');
|
||
}
|
||
const coords = await this.ensureStoreCoordinates(store);
|
||
const media = await this.prisma.commonResource.findMany({
|
||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||
orderBy: { sortOrder: 'asc' },
|
||
});
|
||
const packageRows = await this.prisma.storePackage.findMany({
|
||
where: { storeId: id },
|
||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||
});
|
||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||
return serializeBigInt(
|
||
mapStoreCompat({
|
||
...rest,
|
||
latitude: coords?.latitude ?? store.latitude,
|
||
longitude: coords?.longitude ?? store.longitude,
|
||
media,
|
||
packages: packageRows.map((p) => ({
|
||
name: p.name,
|
||
price: p.price.toFixed(2),
|
||
dishes: p.dishes,
|
||
usableTime: p.usableTime,
|
||
otherNotes: p.otherNotes,
|
||
imageUrl: p.imageUrl,
|
||
sortOrder: p.sortOrder,
|
||
})),
|
||
}),
|
||
);
|
||
}
|
||
|
||
private async resolvePartnerScope(actorAccountId: bigint) {
|
||
const account = await this.getPartnerAccount(actorAccountId);
|
||
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
|
||
return { account, primaryId };
|
||
}
|
||
|
||
async partnerListStores(partnerAccountId: bigint) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId };
|
||
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,
|
||
include: { category: true, coverResource: true },
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
return serializeBigInt(stores.map(mapStoreCompat));
|
||
}
|
||
|
||
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
const store = await this.prisma.store.findFirst({
|
||
where: { id: storeId, partnerAccountId: primaryId },
|
||
include: { category: true, coverResource: true },
|
||
});
|
||
if (!store) throw new NotFoundException('门店不存在');
|
||
if (this.isSubAccount(account)) {
|
||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||
}
|
||
|
||
const media = await this.loadPartnerStoreMedia(storeId);
|
||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||
}
|
||
|
||
async partnerListCities(partnerAccountId: bigint) {
|
||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
const cityWhere = await this.partnerCityService.buildPartnerCityWhere(primaryId);
|
||
const cities = await this.prisma.commonCity.findMany({
|
||
where: cityWhere,
|
||
select: { id: true, name: true, code: true, province: true },
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
return serializeBigInt(cities);
|
||
}
|
||
|
||
async partnerCheckStorePhone(phone: string) {
|
||
const normalizedPhone = phone.trim();
|
||
if (!normalizedPhone) {
|
||
return { available: false, message: '请填写联系电话' };
|
||
}
|
||
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||
return { available: false, message: '联系电话须为11位手机号' };
|
||
}
|
||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||
where: { phone: normalizedPhone },
|
||
include: { _count: { select: { bindings: true } } },
|
||
});
|
||
if (!existingAccount) {
|
||
return { available: true, existingStoreCount: 0, needConfirm: false };
|
||
}
|
||
if (existingAccount.isPrimary !== 1) {
|
||
return { available: false, message: '该手机号已是门店子账号,不可作为负责人' };
|
||
}
|
||
if (existingAccount.status !== 'ACTIVE') {
|
||
return { available: false, message: '该手机号对应门店账号已停用' };
|
||
}
|
||
const existingStoreCount = existingAccount._count.bindings;
|
||
return {
|
||
available: true,
|
||
existingStoreCount,
|
||
needConfirm: existingStoreCount > 0,
|
||
message:
|
||
existingStoreCount > 0
|
||
? `该手机号已是门店主账号(已绑 ${existingStoreCount} 家店),确认后将追加绑定新店`
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
async sendPartnerStorePhoneSms(phone: string) {
|
||
const check = await this.partnerCheckStorePhone(phone);
|
||
if (!check.available) {
|
||
throw new BadRequestException(check.message ?? '该手机号不可用于门店账号');
|
||
}
|
||
return this.authService.sendSms(phone.trim(), SmsScene.PARTNER_STORE_OPEN, {
|
||
clientApp: ClientApp.PARTNER_H5,
|
||
}).then(() => {
|
||
const normalizedPhone = phone.trim();
|
||
const masked =
|
||
normalizedPhone.length >= 7
|
||
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
|
||
: normalizedPhone;
|
||
return { ok: true, maskedPhone: masked };
|
||
});
|
||
}
|
||
|
||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
const normalizedPhone = String(body.phone).trim();
|
||
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
|
||
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
|
||
|
||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||
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 bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
|
||
|
||
if (!body.categoryId) {
|
||
throw new BadRequestException('请选择店铺类型');
|
||
}
|
||
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
|
||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||
|
||
const openTime = body.openTime ? String(body.openTime).trim() : '10:00';
|
||
const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00';
|
||
const openTime2 = body.openTime2 ? String(body.openTime2).trim() : '';
|
||
const closeTime2 = body.closeTime2 ? String(body.closeTime2).trim() : '';
|
||
const hoursCheck = validateBusinessHours([
|
||
{ open: openTime, close: closeTime },
|
||
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||
]);
|
||
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||
|
||
const avgPriceRaw = body.avgPrice != null && body.avgPrice !== '' ? Number(body.avgPrice) : null;
|
||
if (avgPriceRaw != null && (Number.isNaN(avgPriceRaw) || avgPriceRaw < 0)) {
|
||
throw new BadRequestException('人均费用须为非负数字');
|
||
}
|
||
|
||
const introRaw = body.intro != null ? String(body.intro).trim() : '';
|
||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||
throw new BadRequestException('门店简介须为 2~500 字');
|
||
}
|
||
const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule);
|
||
if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) {
|
||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||
}
|
||
|
||
const latitude = parseOptionalCoord(body.latitude, 'lat');
|
||
const longitude = parseOptionalCoord(body.longitude, 'lng');
|
||
if ((latitude == null) !== (longitude == null)) {
|
||
throw new BadRequestException('经纬度须同时提供');
|
||
}
|
||
|
||
const store = await this.prisma.store.create({
|
||
data: {
|
||
cityId: city.id,
|
||
partnerAccountId: primaryId,
|
||
categoryId,
|
||
name: String(body.name),
|
||
phone: normalizedPhone,
|
||
province: String(body.province ?? city.province ?? '河南省'),
|
||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||
district: String(body.district ?? ''),
|
||
address: String(body.address),
|
||
intro: introRaw || null,
|
||
benefitUsageRule: benefitUsageRuleRaw,
|
||
avgPrice: avgPriceRaw,
|
||
openTime,
|
||
closeTime,
|
||
openTime2: openTime2 || null,
|
||
closeTime2: closeTime2 || null,
|
||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||
auditedAt: this.config.autoApproveStore ? new Date() : null,
|
||
rejectReason: null,
|
||
},
|
||
});
|
||
|
||
if (latitude == null || longitude == null) {
|
||
await this.ensureStoreCoordinates(store);
|
||
}
|
||
|
||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||
|
||
if (coverUrl) {
|
||
const cover = await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'COVER',
|
||
mediaType: 'IMAGE',
|
||
ossBucket,
|
||
ossKey: coverUrl,
|
||
url: coverUrl,
|
||
},
|
||
});
|
||
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||
}
|
||
|
||
for (let i = 0; i < envPhotoUrls.length; i++) {
|
||
await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'ENV',
|
||
mediaType: 'IMAGE',
|
||
ossBucket,
|
||
ossKey: envPhotoUrls[i],
|
||
url: envPhotoUrls[i],
|
||
sortOrder: i,
|
||
},
|
||
});
|
||
}
|
||
|
||
if (contractUrl) {
|
||
await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'CONTRACT',
|
||
mediaType: 'FILE',
|
||
ossBucket,
|
||
ossKey: contractUrl,
|
||
url: contractUrl,
|
||
},
|
||
});
|
||
}
|
||
|
||
const audit = await this.prisma.commonEvent.create({
|
||
data: {
|
||
eventType: 'STORE_AUDIT',
|
||
refType: 'STORE',
|
||
refId: store.id,
|
||
actorType: 'PARTNER',
|
||
actorId: partnerAccountId,
|
||
status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||
param1: 'NEW',
|
||
param1Desc: 'audit_type',
|
||
extraJson: body as never,
|
||
},
|
||
});
|
||
void audit;
|
||
|
||
const existingPrimary = await this.prisma.storeAccount.findUnique({
|
||
where: { phone: normalizedPhone },
|
||
});
|
||
if (existingPrimary && existingPrimary.isPrimary === 1) {
|
||
await this.prisma.storeAccountStore.create({
|
||
data: { storeAccountId: existingPrimary.id, storeId: store.id },
|
||
});
|
||
if (bankAccountName || bankAccountNo || bankBranch) {
|
||
await this.prisma.storeAccount.update({
|
||
where: { id: existingPrimary.id },
|
||
data: {
|
||
...(bankAccountName != null ? { bankAccountName } : {}),
|
||
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||
...(bankBranch != null ? { bankBranch } : {}),
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
await this.prisma.storeAccount.create({
|
||
data: {
|
||
phone: normalizedPhone,
|
||
name: String(body.name),
|
||
isPrimary: 1,
|
||
bankAccountName,
|
||
bankAccountNo,
|
||
bankBranch,
|
||
bindings: { create: [{ storeId: store.id }] },
|
||
},
|
||
});
|
||
}
|
||
|
||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||
partnerAccountId: primaryId,
|
||
eventName: 'partner_store_create',
|
||
refType: 'STORE',
|
||
refId: store.id,
|
||
extraJson: { storeName: store.name, phone: normalizedPhone },
|
||
});
|
||
|
||
return serializeBigInt({
|
||
store: mapStoreCompat({
|
||
...store,
|
||
coverResource: coverUrl ? { url: coverUrl } : null,
|
||
}),
|
||
});
|
||
}
|
||
|
||
async partnerUpdateStoreStatus(
|
||
partnerAccountId: bigint,
|
||
storeId: bigint,
|
||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||
) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||
const store = await this.prisma.store.findFirst({
|
||
where: { id: storeId, partnerAccountId: primaryId },
|
||
});
|
||
if (!store) throw new NotFoundException('门店不存在');
|
||
if (store.status === 'CLOSED') {
|
||
throw new BadRequestException('门店已关闭,不可变更状态');
|
||
}
|
||
if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
|
||
throw new BadRequestException('无效的门店状态');
|
||
}
|
||
if (status === 'OPEN') {
|
||
if (store.auditStatus === 'PENDING') {
|
||
throw new BadRequestException('门店尚在总部审核中,通过后方可开门');
|
||
}
|
||
if (store.auditStatus === 'REJECTED') {
|
||
throw new BadRequestException(
|
||
store.rejectReason
|
||
? `门店审核未通过:${store.rejectReason}`
|
||
: '门店审核未通过,请查看驳回原因并重新提交',
|
||
);
|
||
}
|
||
}
|
||
|
||
const updated = await this.prisma.store.update({
|
||
where: { id: storeId },
|
||
data: { status },
|
||
include: { coverResource: true },
|
||
});
|
||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||
partnerAccountId: primaryId,
|
||
eventName: 'partner_store_status_change',
|
||
refType: 'STORE',
|
||
refId: storeId,
|
||
extraJson: {
|
||
status,
|
||
previousStatus: store.status,
|
||
},
|
||
});
|
||
return serializeBigInt(mapStoreCompat(updated));
|
||
}
|
||
|
||
async partnerUpdateStoreBasic(
|
||
partnerAccountId: bigint,
|
||
storeId: bigint,
|
||
body: Record<string, unknown>,
|
||
) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||
const store = await this.prisma.store.findFirst({
|
||
where: { id: storeId, partnerAccountId: primaryId },
|
||
});
|
||
if (!store) throw new NotFoundException('门店不存在');
|
||
if (store.status === 'CLOSED') {
|
||
throw new BadRequestException('门店已关闭,不可编辑');
|
||
}
|
||
if (store.auditStatus === 'PENDING') {
|
||
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||
}
|
||
|
||
const name = body.name !== undefined ? String(body.name).trim() : undefined;
|
||
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
|
||
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
||
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
||
const benefitUsageRuleRaw =
|
||
body.benefitUsageRule !== undefined
|
||
? normalizeOptionalTextField(body.benefitUsageRule)
|
||
: undefined;
|
||
const latitude =
|
||
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
|
||
const longitude =
|
||
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
|
||
|
||
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
|
||
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
|
||
throw new BadRequestException('联系电话须为11位手机号');
|
||
}
|
||
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||
throw new BadRequestException('门店简介须为 2~500 字');
|
||
}
|
||
if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) {
|
||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||
}
|
||
if (
|
||
(latitude !== undefined || longitude !== undefined) &&
|
||
(latitude == null || longitude == null)
|
||
) {
|
||
throw new BadRequestException('经纬度须同时提供');
|
||
}
|
||
|
||
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||
const hasCoordsUpdate = latitude != null && longitude != null;
|
||
const updated = await this.prisma.store.update({
|
||
where: { id: storeId },
|
||
data: {
|
||
...(name !== undefined ? { name } : {}),
|
||
...(phone !== undefined ? { phone } : {}),
|
||
...(address !== undefined
|
||
? hasCoordsUpdate
|
||
? { address }
|
||
: { address, latitude: null, longitude: null }
|
||
: {}),
|
||
...(hasCoordsUpdate ? { latitude, longitude } : {}),
|
||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||
...(benefitUsageRuleRaw !== undefined
|
||
? { benefitUsageRule: benefitUsageRuleRaw }
|
||
: {}),
|
||
...(resubmitAudit
|
||
? {
|
||
auditStatus: 'PENDING' as const,
|
||
rejectReason: null,
|
||
auditedAt: null,
|
||
status: store.status === 'OPEN' ? ('PAUSED' as const) : store.status,
|
||
}
|
||
: {}),
|
||
},
|
||
});
|
||
|
||
if (address !== undefined && !hasCoordsUpdate) {
|
||
await this.ensureStoreCoordinates(updated);
|
||
}
|
||
|
||
if (resubmitAudit) {
|
||
await this.prisma.commonEvent.create({
|
||
data: {
|
||
eventType: 'STORE_AUDIT',
|
||
refType: 'STORE',
|
||
refId: storeId,
|
||
actorType: 'PARTNER',
|
||
actorId: partnerAccountId,
|
||
status: 'PENDING',
|
||
param1: 'RESUBMIT',
|
||
param1Desc: 'audit_type',
|
||
remark: '合伙人修改资料后重新提交审核',
|
||
},
|
||
});
|
||
}
|
||
|
||
return this.partnerGetStore(partnerAccountId, storeId);
|
||
}
|
||
|
||
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(至少 3 张,不设上限) */
|
||
async partnerUpdateStoreMedia(
|
||
partnerAccountId: bigint,
|
||
storeId: bigint,
|
||
body: Record<string, unknown>,
|
||
) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||
const store = await this.prisma.store.findFirst({
|
||
where: { id: storeId, partnerAccountId: primaryId },
|
||
});
|
||
if (!store) throw new NotFoundException('门店不存在');
|
||
if (store.status === 'CLOSED') {
|
||
throw new BadRequestException('门店已关闭,不可编辑');
|
||
}
|
||
if (store.auditStatus === 'PENDING') {
|
||
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||
}
|
||
|
||
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
|
||
const hasEnv = body.envPhotoUrls !== undefined;
|
||
const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined;
|
||
if (coverUrl !== undefined && !coverUrl) {
|
||
throw new BadRequestException('请上传门头照');
|
||
}
|
||
if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) {
|
||
throw new BadRequestException('请上传至少 3 张环境照片');
|
||
}
|
||
if (coverUrl === undefined && envPhotoUrls === undefined) {
|
||
throw new BadRequestException('请至少更新门头照或环境照片');
|
||
}
|
||
|
||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||
|
||
if (coverUrl !== undefined) {
|
||
if (store.coverResourceId) {
|
||
await this.prisma.commonResource.update({
|
||
where: { id: store.coverResourceId },
|
||
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
|
||
});
|
||
} else {
|
||
const cover = await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: storeId,
|
||
bizType: 'COVER',
|
||
mediaType: 'IMAGE',
|
||
ossBucket,
|
||
ossKey: coverUrl,
|
||
url: coverUrl,
|
||
},
|
||
});
|
||
await this.prisma.store.update({
|
||
where: { id: storeId },
|
||
data: { coverResourceId: cover.id },
|
||
});
|
||
}
|
||
}
|
||
|
||
if (envPhotoUrls !== undefined) {
|
||
await this.prisma.commonResource.updateMany({
|
||
where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
for (let i = 0; i < envPhotoUrls.length; i++) {
|
||
await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: storeId,
|
||
bizType: 'ENV',
|
||
mediaType: 'IMAGE',
|
||
ossBucket,
|
||
ossKey: envPhotoUrls[i],
|
||
url: envPhotoUrls[i],
|
||
sortOrder: i,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||
if (resubmitAudit) {
|
||
await this.prisma.store.update({
|
||
where: { id: storeId },
|
||
data: {
|
||
auditStatus: 'PENDING',
|
||
rejectReason: null,
|
||
auditedAt: null,
|
||
status: store.status === 'OPEN' ? 'PAUSED' : store.status,
|
||
},
|
||
});
|
||
await this.prisma.commonEvent.create({
|
||
data: {
|
||
eventType: 'STORE_AUDIT',
|
||
refType: 'STORE',
|
||
refId: storeId,
|
||
actorType: 'PARTNER',
|
||
actorId: partnerAccountId,
|
||
status: 'PENDING',
|
||
param1: 'RESUBMIT',
|
||
param1Desc: 'audit_type',
|
||
remark: '合伙人重新上传资料后重新提交审核',
|
||
},
|
||
});
|
||
}
|
||
|
||
return this.partnerGetStore(partnerAccountId, storeId);
|
||
}
|
||
|
||
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||
include: { store: { include: { category: true, coverResource: true } } },
|
||
});
|
||
return serializeBigInt(mapStoreCompat(binding.store));
|
||
}
|
||
|
||
async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') {
|
||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||
include: { store: true },
|
||
});
|
||
if (binding.store.status === 'CLOSED') {
|
||
throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态');
|
||
}
|
||
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
|
||
throw new BadRequestException(
|
||
binding.store.auditStatus === 'REJECTED'
|
||
? `门店审核未通过${binding.store.rejectReason ? `:${binding.store.rejectReason}` : ''}`
|
||
: '门店尚在总部审核中,通过后方可营业',
|
||
);
|
||
}
|
||
const previousStatus = binding.store.status;
|
||
const store = await this.prisma.store.update({
|
||
where: { id: storeId },
|
||
data: { status },
|
||
});
|
||
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||
storeId,
|
||
eventName: 'store_status_change',
|
||
extraJson: {
|
||
status,
|
||
previousStatus,
|
||
actor: 'STORE',
|
||
},
|
||
});
|
||
return serializeBigInt(store);
|
||
}
|
||
|
||
async partnerDashboard(partnerAccountId: bigint) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
const primaryAccount = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||
where: { id: primaryId },
|
||
});
|
||
const orderCount = await this.prisma.order.count({
|
||
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
|
||
});
|
||
|
||
let storeWhere: { partnerAccountId: bigint; id?: { in: bigint[] } } = {
|
||
partnerAccountId: primaryId,
|
||
};
|
||
if (this.isSubAccount(account)) {
|
||
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
|
||
if (storeIds.length === 0) {
|
||
return {
|
||
storeCount: 0,
|
||
orderCount,
|
||
companyName: primaryAccount.companyName ?? '',
|
||
recentStores: [],
|
||
pendingAuditCount: 0,
|
||
notifications: [],
|
||
};
|
||
}
|
||
storeWhere = { partnerAccountId: primaryId, id: { in: storeIds } };
|
||
}
|
||
|
||
const storeIdsForNotices = (
|
||
await this.prisma.store.findMany({
|
||
where: storeWhere,
|
||
select: { id: true },
|
||
})
|
||
).map((s) => s.id);
|
||
|
||
const [storeCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([
|
||
this.prisma.store.count({ where: storeWhere }),
|
||
this.prisma.store.findMany({
|
||
where: storeWhere,
|
||
select: { id: true, name: true, status: true, auditStatus: true, createdAt: true },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 10,
|
||
}),
|
||
this.prisma.store.count({
|
||
where: { ...storeWhere, auditStatus: 'PENDING' },
|
||
}),
|
||
storeIdsForNotices.length === 0
|
||
? Promise.resolve([])
|
||
: this.prisma.commonEvent.findMany({
|
||
where: {
|
||
eventType: 'STORE_AUDIT',
|
||
refType: 'STORE',
|
||
refId: { in: storeIdsForNotices },
|
||
status: { in: ['APPROVED', 'REJECTED'] },
|
||
actorType: 'HQ',
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 20,
|
||
}),
|
||
]);
|
||
|
||
const storeNameMap = new Map(
|
||
(
|
||
await this.prisma.store.findMany({
|
||
where: { id: { in: recentNotices.map((n) => n.refId) } },
|
||
select: { id: true, name: true },
|
||
})
|
||
).map((s) => [s.id.toString(), s.name]),
|
||
);
|
||
|
||
return {
|
||
storeCount,
|
||
orderCount,
|
||
companyName: primaryAccount.companyName ?? '',
|
||
recentStores: serializeBigInt(recentStores),
|
||
pendingAuditCount,
|
||
notifications: serializeBigInt(
|
||
recentNotices.map((n) => ({
|
||
id: n.id,
|
||
storeId: n.refId,
|
||
storeName: storeNameMap.get(n.refId.toString()) ?? '门店',
|
||
status: n.status,
|
||
remark: n.remark,
|
||
createdAt: n.createdAt,
|
||
title:
|
||
n.status === 'APPROVED'
|
||
? '门店审核已通过'
|
||
: n.status === 'REJECTED'
|
||
? '门店审核已驳回'
|
||
: '门店审核更新',
|
||
content:
|
||
n.status === 'APPROVED'
|
||
? `${storeNameMap.get(n.refId.toString()) ?? '门店'} 已通过总部审核,可开门营业`
|
||
: `${storeNameMap.get(n.refId.toString()) ?? '门店'} 未通过审核${n.remark ? `:${n.remark}` : ''}`,
|
||
})),
|
||
),
|
||
};
|
||
}
|
||
|
||
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
|
||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||
|
||
const accounts = await this.prisma.partnerAccount.findMany({
|
||
where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] },
|
||
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(actorAccountId: bigint, startDate?: string) {
|
||
const { account, primaryId } = await this.resolvePartnerScope(actorAccountId);
|
||
this.assertPrimaryAccount(account);
|
||
|
||
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.weeklyStoreTarget ??
|
||
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
|
||
|
||
const orderWhere = {
|
||
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
|
||
payStatus: 'PAID' as const,
|
||
paidAt: { gte: periodStart, lt: periodEnd },
|
||
};
|
||
const prevOrderWhere = {
|
||
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
|
||
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: { partnerAccountId: primaryId } }),
|
||
this.prisma.store.count({
|
||
where: { partnerAccountId: primaryId, 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: { partnerAccountId: primaryId },
|
||
},
|
||
_sum: { amount: true },
|
||
orderBy: { _sum: { amount: 'desc' } },
|
||
take: 10,
|
||
}),
|
||
this.prisma.redeemRecord.findMany({
|
||
where: {
|
||
createdAt: { gte: periodStart, lt: periodEnd },
|
||
store: { partnerAccountId: primaryId },
|
||
},
|
||
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 getPartnerPrimaryId(actorAccountId: bigint) {
|
||
const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId);
|
||
return primary.id;
|
||
}
|
||
|
||
private async getPartnerAccount(partnerAccountId: bigint) {
|
||
return this.prisma.partnerAccount.findUniqueOrThrow({
|
||
where: { id: partnerAccountId },
|
||
});
|
||
}
|
||
|
||
private async resolvePartnerCity(actorAccountId: bigint, cityId: unknown) {
|
||
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
|
||
if (cityId) {
|
||
const id = parseBigIntParam(cityId, '城市ID');
|
||
await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id);
|
||
const city = await this.prisma.commonCity.findUnique({ where: { id } });
|
||
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
|
||
return city;
|
||
}
|
||
const cityIds = await this.partnerCityService.listCityIdsForPartnerAccount(primaryId);
|
||
if (!cityIds.length) throw new BadRequestException('合伙人未绑定开城');
|
||
const city = await this.prisma.commonCity.findFirst({ where: { id: { in: cityIds } } });
|
||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||
return city;
|
||
}
|
||
|
||
private isSubAccount(account: { isPrimary: number }) {
|
||
return account.isPrimary !== 1;
|
||
}
|
||
|
||
private partnerPermissionList(account: { permissions?: unknown }): string[] {
|
||
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
||
}
|
||
|
||
/** 主账号,或门店类子账号(含历史空权限)可改门店 */
|
||
private async assertCanMutateStore(
|
||
account: { isPrimary: number; permissions?: unknown },
|
||
partnerAccountId: bigint,
|
||
storeId: bigint,
|
||
) {
|
||
if (!this.isSubAccount(account)) return;
|
||
const perms = this.partnerPermissionList(account);
|
||
const canManage = perms.includes('store:manage');
|
||
const canCreate = perms.includes('store:create');
|
||
const legacyStoreStaff = perms.length === 0;
|
||
const warehouseOnly =
|
||
!canManage &&
|
||
!canCreate &&
|
||
!legacyStoreStaff &&
|
||
(perms.includes('warehouse:manage') ||
|
||
(perms.includes('order:view') && !perms.includes('store:create') && !perms.includes('store:manage')));
|
||
if (warehouseOnly || (!canManage && !canCreate && !legacyStoreStaff)) {
|
||
throw new ForbiddenException('子账号无门店管理权限');
|
||
}
|
||
// store:manage 可管团队门店;仅 store:create / 历史空权限只能改自己录入的店
|
||
if (canManage) return;
|
||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||
}
|
||
|
||
private normalizeEnvPhotoUrls(raw: unknown): string[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
const seen = new Set<string>();
|
||
const urls: string[] = [];
|
||
for (const item of raw) {
|
||
const url = String(item ?? '').trim();
|
||
if (!url || seen.has(url)) continue;
|
||
seen.add(url);
|
||
urls.push(url);
|
||
}
|
||
return urls;
|
||
}
|
||
|
||
/** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */
|
||
private async loadPartnerStoreMedia(storeId: bigint) {
|
||
const media = await this.prisma.commonResource.findMany({
|
||
where: {
|
||
ownerType: 'STORE',
|
||
ownerId: storeId,
|
||
status: 'ACTIVE',
|
||
bizType: { in: ['ENV', 'CONTRACT'] },
|
||
},
|
||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||
});
|
||
|
||
const seenEnv = new Set<string>();
|
||
const duplicateEnvIds: bigint[] = [];
|
||
const kept: typeof media = [];
|
||
for (const row of media) {
|
||
if (row.bizType !== 'ENV') {
|
||
kept.push(row);
|
||
continue;
|
||
}
|
||
const key = row.url.trim();
|
||
if (seenEnv.has(key)) {
|
||
duplicateEnvIds.push(row.id);
|
||
continue;
|
||
}
|
||
seenEnv.add(key);
|
||
kept.push(row);
|
||
}
|
||
|
||
if (duplicateEnvIds.length > 0) {
|
||
await this.prisma.commonResource.updateMany({
|
||
where: { id: { in: duplicateEnvIds } },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
}
|
||
|
||
return kept;
|
||
}
|
||
|
||
private assertPrimaryAccount(account: { isPrimary: number }) {
|
||
if (this.isSubAccount(account)) {
|
||
throw new ForbiddenException('子账号无权执行此操作');
|
||
}
|
||
}
|
||
|
||
private async assertStorePhoneAvailable(phone: string, confirmBindExisting = false) {
|
||
const result = await this.partnerCheckStorePhone(phone);
|
||
if (!result.available) {
|
||
throw new BadRequestException(result.message ?? '该手机号不可用');
|
||
}
|
||
if (result.needConfirm && !confirmBindExisting) {
|
||
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('无权查看该门店');
|
||
}
|
||
}
|