389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
|
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
|
import type {
|
|
CreateStoreAccountDto,
|
|
CreateStoreDto,
|
|
CreateStoreMediaDto,
|
|
UpdateStoreAccountDto,
|
|
UpdateStoreDto,
|
|
UpdateStoreMediaDto,
|
|
UpdateStoreStatusDto,
|
|
} from './dto/admin-mutate.dto';
|
|
|
|
@Injectable()
|
|
export class AdminStoresService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly partnerCityService: PartnerCityService,
|
|
) {}
|
|
|
|
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.cityId) where.cityId = BigInt(query.cityId);
|
|
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
|
if (query.phone) where.phone = { contains: query.phone };
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.store.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: {
|
|
cityRef: { select: { id: true, name: true, code: true } },
|
|
partnerAccount: { select: { id: true, companyName: true } },
|
|
account: { select: { id: true, phone: true, name: true, status: true } },
|
|
coverResource: { select: { id: true, url: true } },
|
|
},
|
|
}),
|
|
this.prisma.store.count({ where }),
|
|
]);
|
|
return serializeBigInt({
|
|
items: items.map((s) => mapStoreCompat(s)),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
});
|
|
}
|
|
|
|
async detailStore(id: bigint) {
|
|
const store = await this.prisma.store.findUnique({
|
|
where: { id },
|
|
include: {
|
|
cityRef: true,
|
|
partnerAccount: true,
|
|
category: true,
|
|
account: true,
|
|
coverResource: true,
|
|
_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,
|
|
}),
|
|
]);
|
|
return serializeBigInt(mapStoreCompat({
|
|
...store,
|
|
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('门店不存在');
|
|
const status = dto.approved ? 'OPEN' : 'PAUSED';
|
|
const updated = await this.prisma.store.update({
|
|
where: { id },
|
|
data: { status },
|
|
});
|
|
await this.prisma.commonEvent.create({
|
|
data: {
|
|
eventType: 'STORE_AUDIT',
|
|
refType: 'STORE',
|
|
refId: id,
|
|
actorType: 'HQ',
|
|
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
|
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
|
|
},
|
|
});
|
|
return serializeBigInt(updated);
|
|
}
|
|
|
|
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
|
const store = await this.prisma.store.update({
|
|
where: { id },
|
|
data: {
|
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
|
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
|
|
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
|
...(dto.address !== undefined ? { address: dto.address } : {}),
|
|
...(dto.district !== undefined ? { district: dto.district } : {}),
|
|
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
|
},
|
|
});
|
|
|
|
if (dto.coverUrl) {
|
|
const current = await this.prisma.store.findUniqueOrThrow({ where: { id } });
|
|
if (current.coverResourceId) {
|
|
await this.prisma.commonResource.update({
|
|
where: { id: current.coverResourceId },
|
|
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
|
|
});
|
|
} else {
|
|
const cover = await this.prisma.commonResource.create({
|
|
data: {
|
|
ownerType: 'STORE',
|
|
ownerId: id,
|
|
bizType: 'COVER',
|
|
mediaType: 'IMAGE',
|
|
ossBucket: 'legacy',
|
|
ossKey: dto.coverUrl,
|
|
url: dto.coverUrl,
|
|
},
|
|
});
|
|
await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } });
|
|
}
|
|
}
|
|
|
|
return this.detailStore(id);
|
|
}
|
|
|
|
async createStore(dto: CreateStoreDto) {
|
|
const normalizedPhone = dto.phone.trim();
|
|
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
|
throw new BadRequestException('请输入正确的手机号码');
|
|
}
|
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
|
where: { phone: normalizedPhone },
|
|
});
|
|
if (existingAccount) 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);
|
|
|
|
const store = await this.prisma.store.create({
|
|
data: {
|
|
cityId: city.id,
|
|
partnerAccountId,
|
|
settlementRate: dto.settlementRate ?? 0.6,
|
|
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
|
name: dto.name,
|
|
phone: normalizedPhone,
|
|
province: dto.province ?? city.province,
|
|
cityName: dto.city ?? city.name,
|
|
district: dto.district ?? '',
|
|
address: dto.address,
|
|
intro: dto.intro ?? null,
|
|
bankAccountName: dto.bankAccountName ?? null,
|
|
bankAccountNo: dto.bankAccountNo ?? null,
|
|
bankBranch: dto.bankBranch ?? null,
|
|
openTime: '10:00',
|
|
closeTime: '22:00',
|
|
status: 'OPEN',
|
|
},
|
|
});
|
|
|
|
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 = (dto.envPhotoUrls ?? []).filter(Boolean);
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (dto.contractUrl) {
|
|
await this.prisma.commonResource.create({
|
|
data: {
|
|
ownerType: 'STORE',
|
|
ownerId: store.id,
|
|
bizType: 'CONTRACT',
|
|
mediaType: 'FILE',
|
|
ossBucket: 'legacy',
|
|
ossKey: dto.contractUrl,
|
|
url: dto.contractUrl,
|
|
},
|
|
});
|
|
}
|
|
|
|
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 后台新建',
|
|
},
|
|
});
|
|
|
|
await this.prisma.storeAccount.create({
|
|
data: {
|
|
storeId: store.id,
|
|
phone: normalizedPhone,
|
|
name: dto.accountName ?? dto.name,
|
|
},
|
|
});
|
|
|
|
return this.detailStore(store.id);
|
|
}
|
|
|
|
async createStoreAccount(dto: CreateStoreAccountDto) {
|
|
const store = await this.prisma.store.findUnique({
|
|
where: { id: BigInt(dto.storeId) },
|
|
include: { account: true },
|
|
});
|
|
if (!store) throw new BadRequestException('门店不存在');
|
|
if (store.account) throw new BadRequestException('门店已有账户');
|
|
const account = await this.prisma.storeAccount.create({
|
|
data: { storeId: store.id, phone: dto.phone, name: dto.name },
|
|
});
|
|
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 = {};
|
|
if (query.phone) where.phone = { contains: query.phone };
|
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
|
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.storeAccount.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: {
|
|
store: { select: { id: true, name: true, status: true, cityName: true } },
|
|
},
|
|
}),
|
|
this.prisma.storeAccount.count({ where }),
|
|
]);
|
|
return serializeBigInt({ items, total, page, pageSize });
|
|
}
|
|
|
|
async detailStoreAccount(id: bigint) {
|
|
const account = await this.prisma.storeAccount.findUnique({
|
|
where: { id },
|
|
include: { store: { include: { cityRef: true, partnerAccount: true } } },
|
|
});
|
|
if (!account) throw new NotFoundException('门店账号不存在');
|
|
return serializeBigInt(account);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|