932 lines
34 KiB
TypeScript
932 lines
34 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||
import { Prisma } from '@prisma/client';
|
||
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, 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 { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||
import { StoreCategoryService } from '../store/store-category.service';
|
||
import { AnalyticsService } from '../analytics/analytics.service';
|
||
import type {
|
||
CreateStoreAccountDto,
|
||
CreateStoreDto,
|
||
CreateStoreMediaDto,
|
||
UpdateStoreAccountDto,
|
||
UpdateStoreDto,
|
||
UpdateStoreMediaDto,
|
||
UpdateStoreStatusDto,
|
||
} from './dto/admin-mutate.dto';
|
||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||
|
||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||
function normalizeStoreOptionalText(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;
|
||
}
|
||
|
||
function normalizeVisibilityPhones(phones?: string[]): string[] {
|
||
if (!phones?.length) return [];
|
||
const out: string[] = [];
|
||
const seen = new Set<string>();
|
||
for (const raw of phones) {
|
||
const phone = String(raw || '')
|
||
.replace(/\D/g, '')
|
||
.trim();
|
||
if (!phone || seen.has(phone)) continue;
|
||
if (!/^1\d{10}$/.test(phone)) {
|
||
throw new BadRequestException(`手机号格式无效:${raw}`);
|
||
}
|
||
seen.add(phone);
|
||
out.push(phone);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
@Injectable()
|
||
export class AdminStoresService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly partnerCityService: PartnerCityService,
|
||
private readonly storeCategoryService: StoreCategoryService,
|
||
private readonly analyticsService: AnalyticsService,
|
||
private readonly testWhitelist: TestWhitelistService,
|
||
) {}
|
||
|
||
async listStores(query: AdminStoresQueryDto) {
|
||
const page = query.page ?? 1;
|
||
const pageSize = query.pageSize ?? 20;
|
||
const where: Prisma.StoreWhereInput = {};
|
||
if (query.name) where.name = { contains: query.name };
|
||
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
|
||
if (query.auditStatus) {
|
||
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
|
||
}
|
||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||
if (query.phone) where.phone = { contains: query.phone };
|
||
if (query.excludeTest) where.isTest = false;
|
||
|
||
const [items, total] = await Promise.all([
|
||
this.prisma.store.findMany({
|
||
where,
|
||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
include: {
|
||
cityRef: { select: { id: true, name: true, code: true } },
|
||
partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } },
|
||
category: { select: { id: true, name: true, parentId: true } },
|
||
bindings: {
|
||
where: { storeAccount: { isPrimary: 1 } },
|
||
take: 1,
|
||
include: {
|
||
storeAccount: { select: { id: true, phone: true, name: true, status: true } },
|
||
},
|
||
},
|
||
coverResource: { select: { id: true, url: true } },
|
||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||
},
|
||
}),
|
||
this.prisma.store.count({ where }),
|
||
]);
|
||
|
||
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
|
||
const pendingByStore = new Map<string, string>();
|
||
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
|
||
const pendingInfoByStore = new Map<string, string>();
|
||
if (items.length) {
|
||
const storeIds = items.map((s) => s.id);
|
||
const [pendingReqs, pendingInfoReqs] = await Promise.all([
|
||
this.prisma.storePackageChangeRequest.findMany({
|
||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||
select: { id: true, storeId: true },
|
||
}),
|
||
this.prisma.storeInfoChangeRequest.findMany({
|
||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||
select: { id: true, storeId: true },
|
||
}),
|
||
]);
|
||
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
|
||
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
|
||
}
|
||
|
||
return serializeBigInt({
|
||
items: items.map((s) => {
|
||
const { visibilityPhones, ...rest } = s;
|
||
return mapStoreCompat({
|
||
...rest,
|
||
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
|
||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
||
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
||
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
|
||
partner: s.partnerAccount,
|
||
account: s.bindings[0]?.storeAccount ?? null,
|
||
bindings: undefined,
|
||
});
|
||
}),
|
||
total,
|
||
page,
|
||
pageSize,
|
||
});
|
||
}
|
||
|
||
async detailStore(id: bigint) {
|
||
const store = await this.prisma.store.findUnique({
|
||
where: { id },
|
||
include: {
|
||
cityRef: true,
|
||
partnerAccount: true,
|
||
category: true,
|
||
bindings: {
|
||
where: { storeAccount: { isPrimary: 1 } },
|
||
take: 1,
|
||
include: { storeAccount: true },
|
||
},
|
||
coverResource: true,
|
||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||
_count: { select: { redeemRecords: true, ratings: true } },
|
||
},
|
||
});
|
||
if (!store) throw new NotFoundException('门店不存在');
|
||
const [media, audits] = await Promise.all([
|
||
this.prisma.commonResource.findMany({
|
||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' },
|
||
orderBy: { sortOrder: 'asc' },
|
||
}),
|
||
this.prisma.commonEvent.findMany({
|
||
where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 5,
|
||
}),
|
||
]);
|
||
const { visibilityPhones, ...rest } = store;
|
||
return serializeBigInt(mapStoreCompat({
|
||
...rest,
|
||
visibilityWhitelistEnabled: store.visibilityWhitelistEnabled,
|
||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||
partner: store.partnerAccount,
|
||
account: store.bindings[0]?.storeAccount ?? null,
|
||
/** 门店端登录手机号(store_account.phone);与 store.phone 对齐 */
|
||
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
|
||
/** 对外联系电话;空则回退登录号 */
|
||
contactPhone: store.contactPhone?.trim() || store.phone,
|
||
bindings: undefined,
|
||
media,
|
||
audits,
|
||
redeemCount: store._count.redeemRecords,
|
||
ratingCount: store._count.ratings,
|
||
_count: undefined,
|
||
}));
|
||
}
|
||
|
||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
|
||
const store = await this.prisma.store.update({
|
||
where: { id },
|
||
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||
});
|
||
return serializeBigInt(store);
|
||
}
|
||
|
||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||
if (!store) throw new NotFoundException('门店不存在');
|
||
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
|
||
// 允许对已通过门店再次驳回/通过(总部纠错);PENDING/REJECTED/APPROVED 均可审核
|
||
}
|
||
if (!dto.approved) {
|
||
const reason = dto.remark?.trim();
|
||
if (!reason) throw new BadRequestException('驳回时必须填写原因');
|
||
}
|
||
|
||
const now = new Date();
|
||
const updated = await this.prisma.store.update({
|
||
where: { id },
|
||
data: dto.approved
|
||
? {
|
||
// 审核通过后保持闭店,由合伙人自行开门
|
||
status: store.status === 'CLOSED' ? 'CLOSED' : 'PAUSED',
|
||
auditStatus: 'APPROVED',
|
||
rejectReason: null,
|
||
auditedAt: now,
|
||
}
|
||
: {
|
||
status: 'PAUSED',
|
||
auditStatus: 'REJECTED',
|
||
rejectReason: dto.remark!.trim(),
|
||
auditedAt: now,
|
||
},
|
||
});
|
||
|
||
await this.prisma.commonEvent.create({
|
||
data: {
|
||
eventType: 'STORE_AUDIT',
|
||
refType: 'STORE',
|
||
refId: id,
|
||
actorType: 'HQ',
|
||
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
||
remark: dto.approved
|
||
? (dto.remark?.trim() || '审核通过,可开门营业')
|
||
: dto.remark!.trim(),
|
||
param1: dto.approved ? 'APPROVE' : 'REJECT',
|
||
param1Desc: 'audit_action',
|
||
},
|
||
});
|
||
|
||
if (updated.partnerAccountId) {
|
||
this.analyticsService.trackPartnerOneSafe(undefined, 'HQ_WEB', {
|
||
partnerAccountId: updated.partnerAccountId,
|
||
eventName: dto.approved ? 'partner_store_audit_approved' : 'partner_store_audit_rejected',
|
||
refType: 'STORE',
|
||
refId: id,
|
||
extraJson: {
|
||
storeId: id.toString(),
|
||
remark: dto.remark?.trim() || null,
|
||
},
|
||
});
|
||
}
|
||
|
||
return serializeBigInt({
|
||
...updated,
|
||
notifyHint: dto.approved
|
||
? '已通过审核,合伙人可在端内开门营业'
|
||
: '已驳回,驳回原因已同步至合伙人端',
|
||
});
|
||
}
|
||
|
||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||
const current = await this.prisma.store.findUnique({ where: { id } });
|
||
if (!current) throw new NotFoundException('门店不存在');
|
||
|
||
const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || '';
|
||
const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || '';
|
||
const openTime2 =
|
||
dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || '';
|
||
const closeTime2 =
|
||
dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || '';
|
||
if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) {
|
||
const hoursCheck = validateBusinessHours([
|
||
{ open: openTime || '10:00', close: closeTime || '22:00' },
|
||
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||
]);
|
||
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||
}
|
||
|
||
const latitude = dto.latitude !== undefined ? (dto.latitude == null ? null : Number(dto.latitude)) : undefined;
|
||
const longitude = dto.longitude !== undefined ? (dto.longitude == null ? null : Number(dto.longitude)) : undefined;
|
||
if (latitude !== undefined || longitude !== undefined) {
|
||
if (latitude == null || longitude == null) {
|
||
throw new BadRequestException('经纬度须同时提供');
|
||
}
|
||
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||
throw new BadRequestException('纬度无效');
|
||
}
|
||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
||
throw new BadRequestException('经度无效');
|
||
}
|
||
}
|
||
|
||
let categoryId: bigint | undefined;
|
||
if (dto.categoryId !== undefined) {
|
||
if (!dto.categoryId?.trim()) {
|
||
throw new BadRequestException('请选择门店分类');
|
||
}
|
||
categoryId = BigInt(dto.categoryId);
|
||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||
}
|
||
|
||
if (dto.phone !== undefined) {
|
||
const normalizedPhone = dto.phone.trim();
|
||
if (!isMobilePhone(normalizedPhone)) {
|
||
throw new BadRequestException('请输入正确的登录手机号');
|
||
}
|
||
}
|
||
if (dto.contactPhone !== undefined) {
|
||
const contact = dto.contactPhone.trim();
|
||
if (contact && !isStoreContactPhone(contact)) {
|
||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||
}
|
||
}
|
||
|
||
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
|
||
const normalizedContactPhone =
|
||
dto.contactPhone !== undefined ? dto.contactPhone.trim() || null : undefined;
|
||
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
|
||
const nextEnabled =
|
||
dto.visibilityWhitelistEnabled !== undefined
|
||
? !!dto.visibilityWhitelistEnabled
|
||
: current.visibilityWhitelistEnabled;
|
||
if (nextEnabled) {
|
||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||
}
|
||
}
|
||
const bankTouched =
|
||
dto.bankAccountName !== undefined ||
|
||
dto.bankAccountNo !== undefined ||
|
||
dto.bankBranch !== undefined;
|
||
const needAccountSync =
|
||
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
|
||
|
||
// 登录凭证在 store_account.phone;与 store.phone(老板登录号)同步,不与对外联系电话绑定
|
||
let primaryBinding: {
|
||
storeAccount: { id: bigint; phone: string; name: string } | null;
|
||
} | null = null;
|
||
if (needAccountSync) {
|
||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||
where: { storeId: id, storeAccount: { isPrimary: 1 } },
|
||
include: {
|
||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||
},
|
||
});
|
||
if (!primaryBinding) {
|
||
primaryBinding = await this.prisma.storeAccountStore.findFirst({
|
||
where: { storeId: id },
|
||
include: {
|
||
storeAccount: { select: { id: true, phone: true, name: true } },
|
||
},
|
||
orderBy: { storeAccountId: 'asc' },
|
||
});
|
||
}
|
||
if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) {
|
||
throw new BadRequestException('门店未绑定登录账号,无法修改手机号');
|
||
}
|
||
const primaryAccount = primaryBinding?.storeAccount;
|
||
if (
|
||
normalizedPhone !== undefined &&
|
||
primaryAccount &&
|
||
normalizedPhone !== primaryAccount.phone
|
||
) {
|
||
const occupied = await this.prisma.storeAccount.findUnique({
|
||
where: { phone: normalizedPhone },
|
||
});
|
||
if (occupied && occupied.id !== primaryAccount.id) {
|
||
throw new BadRequestException('该手机号已被其他门店账号使用');
|
||
}
|
||
}
|
||
if (dto.bankAccountNo !== undefined) {
|
||
const no = dto.bankAccountNo?.trim() || null;
|
||
if (no && !/^\d{16,19}$/.test(no)) {
|
||
throw new BadRequestException('银行卡号须为 16–19 位数字');
|
||
}
|
||
}
|
||
}
|
||
|
||
await this.prisma.$transaction(async (tx) => {
|
||
await tx.store.update({
|
||
where: { id },
|
||
data: {
|
||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
||
...(normalizedContactPhone !== undefined
|
||
? { contactPhone: normalizedContactPhone }
|
||
: {}),
|
||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||
...(dto.benefitUsageRule !== undefined
|
||
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
|
||
: {}),
|
||
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
|
||
...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}),
|
||
...(dto.district !== undefined ? { district: dto.district.trim() } : {}),
|
||
...(categoryId !== undefined ? { categoryId } : {}),
|
||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
|
||
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
|
||
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
|
||
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
|
||
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
|
||
...(dto.visibilityWhitelistEnabled !== undefined
|
||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||
: {}),
|
||
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
|
||
...(dto.sortOrder !== undefined ? { sortOrder: Math.floor(Number(dto.sortOrder)) || 0 } : {}),
|
||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||
},
|
||
});
|
||
|
||
if (dto.visibilityPhones !== undefined) {
|
||
// 分实体手机号已废弃,忽略写入
|
||
}
|
||
|
||
if (dto.coverUrl !== undefined) {
|
||
const coverUrl = dto.coverUrl?.trim() || '';
|
||
if (!coverUrl) {
|
||
if (current.coverResourceId) {
|
||
await tx.commonResource.update({
|
||
where: { id: current.coverResourceId },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
|
||
}
|
||
await tx.commonResource.updateMany({
|
||
where: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', status: 'ACTIVE' },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
} else if (current.coverResourceId) {
|
||
await tx.commonResource.update({
|
||
where: { id: current.coverResourceId },
|
||
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
|
||
});
|
||
} else {
|
||
const cover = await tx.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: id,
|
||
bizType: 'COVER',
|
||
mediaType: 'IMAGE',
|
||
ossBucket: 'legacy',
|
||
ossKey: coverUrl,
|
||
url: coverUrl,
|
||
},
|
||
});
|
||
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
||
}
|
||
}
|
||
|
||
if (dto.envPhotoUrls !== undefined) {
|
||
const envUrls = [...new Set(
|
||
(dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean),
|
||
)].slice(0, 20);
|
||
await tx.commonResource.updateMany({
|
||
where: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', status: 'ACTIVE' },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
for (let i = 0; i < envUrls.length; i++) {
|
||
await tx.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: id,
|
||
bizType: 'ENV',
|
||
mediaType: 'IMAGE',
|
||
ossBucket: 'legacy',
|
||
ossKey: envUrls[i],
|
||
url: envUrls[i],
|
||
sortOrder: i,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) {
|
||
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||
await tx.commonResource.updateMany({
|
||
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
for (let i = 0; i < contractUrls.length; i++) {
|
||
await tx.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: id,
|
||
bizType: 'CONTRACT',
|
||
mediaType: contractMediaType(contractUrls[i]),
|
||
ossBucket: 'legacy',
|
||
ossKey: contractUrls[i],
|
||
url: contractUrls[i],
|
||
sortOrder: i,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (primaryBinding?.storeAccount) {
|
||
const account = primaryBinding.storeAccount;
|
||
const accountData: {
|
||
name?: string;
|
||
phone?: string;
|
||
bankAccountName?: string | null;
|
||
bankAccountNo?: string | null;
|
||
bankBranch?: string | null;
|
||
} = {};
|
||
if (dto.name !== undefined) accountData.name = dto.name.trim();
|
||
if (normalizedPhone !== undefined && normalizedPhone !== account.phone) {
|
||
accountData.phone = normalizedPhone;
|
||
}
|
||
if (dto.bankAccountName !== undefined) {
|
||
accountData.bankAccountName = dto.bankAccountName?.trim() || null;
|
||
}
|
||
if (dto.bankAccountNo !== undefined) {
|
||
accountData.bankAccountNo = dto.bankAccountNo?.trim() || null;
|
||
}
|
||
if (dto.bankBranch !== undefined) {
|
||
accountData.bankBranch = dto.bankBranch?.trim() || null;
|
||
}
|
||
if (Object.keys(accountData).length) {
|
||
await tx.storeAccount.update({
|
||
where: { id: account.id },
|
||
data: accountData,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
return this.detailStore(id);
|
||
}
|
||
|
||
async createStore(dto: CreateStoreDto) {
|
||
const normalizedPhone = dto.phone.trim();
|
||
if (!isMobilePhone(normalizedPhone)) {
|
||
throw new BadRequestException('请输入正确的手机号码');
|
||
}
|
||
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||
where: { phone: normalizedPhone },
|
||
});
|
||
if (existingAccount && existingAccount.isPrimary !== 1) {
|
||
throw new BadRequestException('该手机号已是门店子账号');
|
||
}
|
||
if (existingAccount && existingAccount.status !== 'ACTIVE') {
|
||
throw new BadRequestException('该手机号对应门店账号已停用');
|
||
}
|
||
|
||
const partnerAccountId = BigInt(dto.partnerAccountId);
|
||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||
where: { id: partnerAccountId },
|
||
});
|
||
if (!partnerAccount || partnerAccount.isPrimary !== 1) {
|
||
throw new BadRequestException('开城合伙人不存在');
|
||
}
|
||
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
|
||
if (!city) throw new BadRequestException('开城城市不存在');
|
||
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
||
|
||
if (!dto.categoryId?.trim()) {
|
||
throw new BadRequestException('请选择门店分类');
|
||
}
|
||
const categoryId = BigInt(dto.categoryId);
|
||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||
|
||
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
|
||
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
|
||
if ((latitude == null) !== (longitude == null)) {
|
||
throw new BadRequestException('经纬度须同时提供');
|
||
}
|
||
if (
|
||
latitude != null &&
|
||
(!Number.isFinite(latitude) || !Number.isFinite(longitude!) || latitude < -90 || latitude > 90)
|
||
) {
|
||
throw new BadRequestException('经纬度无效');
|
||
}
|
||
|
||
const openTime = dto.openTime?.trim() || '10:00';
|
||
const closeTime = dto.closeTime?.trim() || '22:00';
|
||
const openTime2 = dto.openTime2?.trim() || '';
|
||
const closeTime2 = dto.closeTime2?.trim() || '';
|
||
const hoursCheck = validateBusinessHours([
|
||
{ open: openTime, close: closeTime },
|
||
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||
]);
|
||
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||
|
||
const intro = dto.intro?.trim() || null;
|
||
if (intro && (intro.length < 2 || intro.length > 500)) {
|
||
throw new BadRequestException('门店简介须为 2~500 字');
|
||
}
|
||
const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
|
||
if (benefitUsageRule && benefitUsageRule.length > 1000) {
|
||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||
}
|
||
|
||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||
if (whitelistEnabled) {
|
||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||
}
|
||
const isTest =
|
||
dto.isTest !== undefined
|
||
? !!dto.isTest
|
||
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||
|
||
const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
|
||
if (!isStoreContactPhone(contactPhoneRaw)) {
|
||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||
}
|
||
|
||
const store = await this.prisma.store.create({
|
||
data: {
|
||
cityId: city.id,
|
||
partnerAccountId,
|
||
settlementRate: dto.settlementRate ?? 0.6,
|
||
categoryId,
|
||
name: dto.name,
|
||
phone: normalizedPhone,
|
||
contactPhone: contactPhoneRaw,
|
||
province: dto.province ?? city.province,
|
||
cityName: dto.city ?? city.name,
|
||
district: dto.district ?? '',
|
||
address: dto.address,
|
||
intro,
|
||
benefitUsageRule,
|
||
avgPrice: dto.avgPrice ?? null,
|
||
openTime,
|
||
closeTime,
|
||
openTime2: openTime2 || null,
|
||
closeTime2: closeTime2 || null,
|
||
visibilityWhitelistEnabled: whitelistEnabled,
|
||
isTest,
|
||
sortOrder: dto.sortOrder != null ? Math.floor(Number(dto.sortOrder)) || 0 : 0,
|
||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||
status: 'OPEN',
|
||
auditStatus: 'APPROVED',
|
||
auditedAt: new Date(),
|
||
rejectReason: null,
|
||
},
|
||
});
|
||
|
||
if (dto.coverUrl) {
|
||
const cover = await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'COVER',
|
||
mediaType: 'IMAGE',
|
||
ossBucket: 'legacy',
|
||
ossKey: dto.coverUrl,
|
||
url: dto.coverUrl,
|
||
},
|
||
});
|
||
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||
}
|
||
|
||
const envUrls = [...new Set((dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean))].slice(0, 20);
|
||
for (let i = 0; i < envUrls.length; i++) {
|
||
await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'ENV',
|
||
mediaType: 'IMAGE',
|
||
ossBucket: 'legacy',
|
||
ossKey: envUrls[i],
|
||
url: envUrls[i],
|
||
sortOrder: i,
|
||
},
|
||
});
|
||
}
|
||
|
||
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||
for (let i = 0; i < contractUrls.length; i++) {
|
||
await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'CONTRACT',
|
||
mediaType: contractMediaType(contractUrls[i]),
|
||
ossBucket: 'legacy',
|
||
ossKey: contractUrls[i],
|
||
url: contractUrls[i],
|
||
sortOrder: i,
|
||
},
|
||
});
|
||
}
|
||
|
||
await this.prisma.commonEvent.create({
|
||
data: {
|
||
eventType: 'STORE_AUDIT',
|
||
refType: 'STORE',
|
||
refId: store.id,
|
||
actorType: 'HQ',
|
||
status: 'APPROVED',
|
||
param1: 'NEW',
|
||
param1Desc: 'audit_type',
|
||
remark: 'HQ 后台新建',
|
||
},
|
||
});
|
||
|
||
const bankAccountName = dto.bankAccountName ?? null;
|
||
const bankAccountNo = dto.bankAccountNo ?? null;
|
||
const bankBranch = dto.bankBranch ?? null;
|
||
|
||
if (existingAccount) {
|
||
await this.prisma.storeAccountStore.create({
|
||
data: { storeAccountId: existingAccount.id, storeId: store.id },
|
||
});
|
||
if (bankAccountName || bankAccountNo || bankBranch) {
|
||
await this.prisma.storeAccount.update({
|
||
where: { id: existingAccount.id },
|
||
data: {
|
||
...(bankAccountName != null ? { bankAccountName } : {}),
|
||
...(bankAccountNo != null ? { bankAccountNo } : {}),
|
||
...(bankBranch != null ? { bankBranch } : {}),
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
await this.prisma.storeAccount.create({
|
||
data: {
|
||
phone: normalizedPhone,
|
||
name: dto.accountName ?? dto.name,
|
||
isPrimary: 1,
|
||
bankAccountName,
|
||
bankAccountNo,
|
||
bankBranch,
|
||
bindings: { create: [{ storeId: store.id }] },
|
||
},
|
||
});
|
||
}
|
||
|
||
return this.detailStore(store.id);
|
||
}
|
||
|
||
async createStoreAccount(dto: CreateStoreAccountDto) {
|
||
const store = await this.prisma.store.findUnique({
|
||
where: { id: BigInt(dto.storeId) },
|
||
include: { bindings: true },
|
||
});
|
||
if (!store) throw new BadRequestException('门店不存在');
|
||
const primaryBound = store.bindings.length > 0
|
||
? await this.prisma.storeAccount.findFirst({
|
||
where: {
|
||
isPrimary: 1,
|
||
bindings: { some: { storeId: store.id } },
|
||
},
|
||
})
|
||
: null;
|
||
if (primaryBound) throw new BadRequestException('门店已有主账号绑定');
|
||
|
||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } });
|
||
if (existing) {
|
||
if (existing.isPrimary !== 1) {
|
||
throw new BadRequestException('该手机号已是门店子账号');
|
||
}
|
||
await this.prisma.storeAccountStore.create({
|
||
data: { storeAccountId: existing.id, storeId: store.id },
|
||
});
|
||
return serializeBigInt(existing);
|
||
}
|
||
|
||
const account = await this.prisma.storeAccount.create({
|
||
data: {
|
||
phone: dto.phone,
|
||
name: dto.name,
|
||
isPrimary: 1,
|
||
bindings: { create: [{ storeId: store.id }] },
|
||
},
|
||
});
|
||
return serializeBigInt(account);
|
||
}
|
||
|
||
async listStoreMedia(query: AdminStoreMediaQueryDto) {
|
||
const page = query.page ?? 1;
|
||
const pageSize = query.pageSize ?? 20;
|
||
const where: Prisma.CommonResourceWhereInput = {
|
||
ownerType: 'STORE',
|
||
status: 'ACTIVE',
|
||
};
|
||
if (query.storeId) where.ownerId = BigInt(query.storeId);
|
||
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
|
||
|
||
const [items, total] = await Promise.all([
|
||
this.prisma.commonResource.findMany({
|
||
where,
|
||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
}),
|
||
this.prisma.commonResource.count({ where }),
|
||
]);
|
||
return serializeBigInt({ items, total, page, pageSize });
|
||
}
|
||
|
||
async createStoreMedia(dto: CreateStoreMediaDto) {
|
||
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
|
||
if (!store) throw new BadRequestException('门店不存在');
|
||
const media = await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'STORE',
|
||
ownerId: store.id,
|
||
bizType: 'ENV',
|
||
mediaType: dto.mediaType as 'IMAGE' | 'VIDEO',
|
||
ossBucket: 'legacy',
|
||
ossKey: dto.url,
|
||
url: dto.url,
|
||
sortOrder: dto.sortOrder ?? 0,
|
||
},
|
||
});
|
||
return serializeBigInt(media);
|
||
}
|
||
|
||
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
|
||
const media = await this.prisma.commonResource.update({
|
||
where: { id },
|
||
data: {
|
||
...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
|
||
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' } : {}),
|
||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||
},
|
||
});
|
||
return serializeBigInt(media);
|
||
}
|
||
|
||
async deleteStoreMedia(id: bigint) {
|
||
await this.prisma.commonResource.update({
|
||
where: { id },
|
||
data: { status: 'DELETED' },
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
||
const page = query.page ?? 1;
|
||
const pageSize = query.pageSize ?? 20;
|
||
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
|
||
if (query.phone) where.phone = { contains: query.phone };
|
||
if (query.storeId) {
|
||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||
}
|
||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||
if (query.excludeTest) where.isTest = false;
|
||
|
||
const [items, total] = await Promise.all([
|
||
this.prisma.storeAccount.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
include: {
|
||
bindings: {
|
||
include: {
|
||
store: { select: { id: true, name: true, status: true, cityName: true } },
|
||
},
|
||
},
|
||
_count: { select: { childAccounts: true, bindings: true } },
|
||
},
|
||
}),
|
||
this.prisma.storeAccount.count({ where }),
|
||
]);
|
||
const mapped = items.map((row) => ({
|
||
...row,
|
||
storeCount: row._count.bindings,
|
||
staffCount: row._count.childAccounts,
|
||
stores: row.bindings.map((b) => b.store),
|
||
store: row.bindings[0]?.store ?? null,
|
||
}));
|
||
return serializeBigInt({ items: mapped, total, page, pageSize });
|
||
}
|
||
|
||
async detailStoreAccount(id: bigint) {
|
||
const account = await this.prisma.storeAccount.findUnique({
|
||
where: { id },
|
||
include: {
|
||
bindings: {
|
||
include: {
|
||
store: { include: { cityRef: true, partnerAccount: true } },
|
||
},
|
||
},
|
||
childAccounts: {
|
||
include: {
|
||
bindings: {
|
||
include: {
|
||
store: { select: { id: true, name: true, status: true } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
if (!account) throw new NotFoundException('门店账号不存在');
|
||
return serializeBigInt({
|
||
...account,
|
||
stores: account.bindings.map((b) => b.store),
|
||
store: account.bindings[0]?.store ?? null,
|
||
staff: account.childAccounts,
|
||
});
|
||
}
|
||
|
||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
|
||
const account = await this.prisma.storeAccount.update({
|
||
where: { id },
|
||
data: {
|
||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
|
||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
||
},
|
||
});
|
||
return serializeBigInt(account);
|
||
}
|
||
|
||
/** HQ 删除门店子账号(非主账号) */
|
||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
|
||
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
|
||
if (!parent || parent.isPrimary !== 1) {
|
||
throw new BadRequestException('主账号不存在');
|
||
}
|
||
const staff = await this.prisma.storeAccount.findFirst({
|
||
where: { id: staffId, parentAccountId, isPrimary: 0 },
|
||
});
|
||
if (!staff) throw new NotFoundException('子账号不存在');
|
||
|
||
const pending = await this.prisma.redeemPendingRecord.count({
|
||
where: { storeAccountId: staffId },
|
||
});
|
||
if (pending > 0) {
|
||
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
|
||
}
|
||
|
||
await this.prisma.storeAccount.delete({ where: { id: staffId } });
|
||
return { ok: true };
|
||
}
|
||
}
|