|
|
|
@@ -0,0 +1,341 @@
|
|
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
Logger,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import {
|
|
|
|
|
isStoreContactPhone,
|
|
|
|
|
STORE_CONTACT_PHONE_HINT,
|
|
|
|
|
} from '@dukang/domain';
|
|
|
|
|
import {
|
|
|
|
|
STORE_INFO_CHANGEABLE_FIELDS,
|
|
|
|
|
type StoreInfoChangeFieldDiff,
|
|
|
|
|
type StoreInfoChangeRequestDto,
|
|
|
|
|
type StoreInfoChangeStatus,
|
|
|
|
|
type StoreInfoChangeSubmitterType,
|
|
|
|
|
} from '@dukang/shared-types';
|
|
|
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
|
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
|
|
|
import { StoreService } from './store.service';
|
|
|
|
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
|
|
|
|
|
|
|
|
|
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
|
|
|
|
|
|
|
|
|
function normalizeOptionalTextField(value: unknown): string | null {
|
|
|
|
|
if (value == null) return null;
|
|
|
|
|
const s = String(value).trim();
|
|
|
|
|
if (!s || /^null$/i.test(s)) return null;
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeBusinessHour(value: unknown): string | null {
|
|
|
|
|
if (value == null || String(value).trim() === '') return null;
|
|
|
|
|
const s = String(value).trim();
|
|
|
|
|
if (!/^\d{1,2}:\d{2}$/.test(s)) {
|
|
|
|
|
throw new BadRequestException('营业时间格式应为 HH:MM,如 09:00');
|
|
|
|
|
}
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function coerceNumberOrNull(value: unknown): number | null {
|
|
|
|
|
if (value == null || String(value).trim() === '') return null;
|
|
|
|
|
const n = Number(value);
|
|
|
|
|
if (!Number.isFinite(n)) throw new BadRequestException('数值字段格式不正确');
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
|
|
|
|
|
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
const out: Record<string, unknown> = {};
|
|
|
|
|
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
|
|
|
|
if (!(field in fields)) continue;
|
|
|
|
|
const raw = fields[field];
|
|
|
|
|
switch (field) {
|
|
|
|
|
case 'latitude':
|
|
|
|
|
case 'longitude':
|
|
|
|
|
case 'avgPrice':
|
|
|
|
|
out[field] = coerceNumberOrNull(raw);
|
|
|
|
|
break;
|
|
|
|
|
case 'openTime':
|
|
|
|
|
case 'closeTime':
|
|
|
|
|
case 'openTime2':
|
|
|
|
|
case 'closeTime2':
|
|
|
|
|
out[field] = normalizeBusinessHour(raw);
|
|
|
|
|
break;
|
|
|
|
|
case 'intro':
|
|
|
|
|
case 'benefitUsageRule':
|
|
|
|
|
out[field] = normalizeOptionalTextField(raw);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
out[field] = raw == null ? null : String(raw);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 取 live store 上白名单字段的当前值(用于快照与 diff) */
|
|
|
|
|
function pickLiveFields(store: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
const out: Record<string, unknown> = {};
|
|
|
|
|
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
|
|
|
|
const v = store[field];
|
|
|
|
|
out[field] = v == null ? null : v;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function looseEqual(a: unknown, b: unknown): boolean {
|
|
|
|
|
if (a == null && b == null) return true;
|
|
|
|
|
return String(a) === String(b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function computeChangedFields(
|
|
|
|
|
live: Record<string, unknown>,
|
|
|
|
|
proposed: Record<string, unknown>,
|
|
|
|
|
): ChangeableField[] {
|
|
|
|
|
const changed: ChangeableField[] = [];
|
|
|
|
|
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
|
|
|
|
if (!(field in proposed)) continue;
|
|
|
|
|
if (!looseEqual(live[field], proposed[field])) changed.push(field as ChangeableField);
|
|
|
|
|
}
|
|
|
|
|
return changed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class StoreInfoChangeService {
|
|
|
|
|
private readonly logger = new Logger(StoreInfoChangeService.name);
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly storeService: StoreService,
|
|
|
|
|
private readonly partnerCityService: PartnerCityService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/** 合伙人 / 门店端 提交基础信息变更 */
|
|
|
|
|
async submitChange(input: {
|
|
|
|
|
submitterType: StoreInfoChangeSubmitterType;
|
|
|
|
|
submitterId: bigint;
|
|
|
|
|
storeId: bigint;
|
|
|
|
|
fields: Record<string, unknown>;
|
|
|
|
|
}): Promise<StoreInfoChangeRequestDto> {
|
|
|
|
|
// 1) 校验归属
|
|
|
|
|
let store: Record<string, unknown>;
|
|
|
|
|
if (input.submitterType === 'PARTNER') {
|
|
|
|
|
const primary = await this.partnerCityService.resolvePrimaryAccount(input.submitterId);
|
|
|
|
|
const found = await this.prisma.store.findFirst({
|
|
|
|
|
where: { id: input.storeId, partnerAccountId: primary.id },
|
|
|
|
|
});
|
|
|
|
|
if (!found) throw new NotFoundException('门店不存在或无权操作');
|
|
|
|
|
store = found as unknown as Record<string, unknown>;
|
|
|
|
|
} else {
|
|
|
|
|
// SHOP / HQ_DIRECT_ADMIN:先校验门店绑定/存在
|
|
|
|
|
if (input.submitterType === 'SHOP') {
|
|
|
|
|
await this.storeService.getShopStore(input.submitterId, input.storeId);
|
|
|
|
|
}
|
|
|
|
|
const found = await this.prisma.store.findUnique({ where: { id: input.storeId } });
|
|
|
|
|
if (!found) throw new NotFoundException('门店不存在');
|
|
|
|
|
store = found as unknown as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (store.status === 'CLOSED') {
|
|
|
|
|
throw new BadRequestException('门店已关闭,不可提交变更');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2) 规整 proposed + diff
|
|
|
|
|
const proposed = buildProposedSnapshot(input.fields);
|
|
|
|
|
const live = pickLiveFields(store);
|
|
|
|
|
const changedFields = computeChangedFields(live, proposed);
|
|
|
|
|
if (changedFields.length === 0) {
|
|
|
|
|
throw new BadRequestException('没有检测到需要变更的字段');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3) 基础校验
|
|
|
|
|
if (proposed.name != null && !String(proposed.name).trim()) {
|
|
|
|
|
throw new BadRequestException('请填写门店名称');
|
|
|
|
|
}
|
|
|
|
|
if (proposed.contactPhone != null && !isStoreContactPhone(String(proposed.contactPhone))) {
|
|
|
|
|
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
|
|
|
|
}
|
|
|
|
|
if (proposed.intro != null && (String(proposed.intro).length < 2 || String(proposed.intro).length > 500)) {
|
|
|
|
|
throw new BadRequestException('门店简介须为 2~500 字');
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
proposed.benefitUsageRule != null &&
|
|
|
|
|
String(proposed.benefitUsageRule).length > 1000
|
|
|
|
|
) {
|
|
|
|
|
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
(proposed.latitude != null || proposed.longitude != null) &&
|
|
|
|
|
(proposed.latitude == null || proposed.longitude == null)
|
|
|
|
|
) {
|
|
|
|
|
throw new BadRequestException('经纬度须同时提供');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4) 同门店已有 PENDING 则替换(最新优先)
|
|
|
|
|
await this.prisma.storeInfoChangeRequest.deleteMany({
|
|
|
|
|
where: { storeId: input.storeId, status: 'PENDING' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const created = await this.prisma.storeInfoChangeRequest.create({
|
|
|
|
|
data: {
|
|
|
|
|
storeId: input.storeId,
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
liveSnapshot: live as object,
|
|
|
|
|
proposedSnapshot: proposed as object,
|
|
|
|
|
changedFields: changedFields as unknown as never,
|
|
|
|
|
submitterType: input.submitterType,
|
|
|
|
|
submitterId: input.submitterId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.logger.log(
|
|
|
|
|
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
|
|
|
|
|
);
|
|
|
|
|
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 合伙人端查看本门店历史变更 */
|
|
|
|
|
async listPartnerRequests(
|
|
|
|
|
storeId: bigint,
|
|
|
|
|
partnerAccountId: bigint,
|
|
|
|
|
): Promise<StoreInfoChangeRequestDto[]> {
|
|
|
|
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
|
|
|
|
const owned = await this.prisma.store.findFirst({
|
|
|
|
|
where: { id: storeId, partnerAccountId: primary.id },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (!owned) throw new NotFoundException('门店不存在或无权操作');
|
|
|
|
|
const rows = await this.prisma.storeInfoChangeRequest.findMany({
|
|
|
|
|
where: { storeId },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
take: 20,
|
|
|
|
|
});
|
|
|
|
|
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 总部列表 */
|
|
|
|
|
async adminList(opts: {
|
|
|
|
|
status?: StoreInfoChangeStatus;
|
|
|
|
|
page?: number;
|
|
|
|
|
pageSize?: number;
|
|
|
|
|
}): Promise<{ items: StoreInfoChangeRequestDto[]; total: number; page: number; pageSize: number }> {
|
|
|
|
|
const page = Math.max(1, opts.page || 1);
|
|
|
|
|
const pageSize = Math.min(Math.max(opts.pageSize || 20, 1), 100);
|
|
|
|
|
const where = opts.status ? { status: opts.status } : {};
|
|
|
|
|
const [rows, total] = await Promise.all([
|
|
|
|
|
this.prisma.storeInfoChangeRequest.findMany({
|
|
|
|
|
where,
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
skip: (page - 1) * pageSize,
|
|
|
|
|
take: pageSize,
|
|
|
|
|
include: { store: { select: { name: true } } },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.storeInfoChangeRequest.count({ where }),
|
|
|
|
|
]);
|
|
|
|
|
const items = rows.map((r) =>
|
|
|
|
|
serializeBigInt(
|
|
|
|
|
this.toDto(r as unknown as Record<string, unknown>, (r as { store?: { name?: string } }).store?.name),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return { items, total, page, pageSize };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 总部待审总数(与套餐审核汇总,用于统一 badge) */
|
|
|
|
|
async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> {
|
|
|
|
|
const [infoPending, packagePending] = await Promise.all([
|
|
|
|
|
this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }),
|
|
|
|
|
this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' } }),
|
|
|
|
|
]);
|
|
|
|
|
return { pendingCount: infoPending, packagePendingCount: packagePending };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 总部详情(含字段级 diff) */
|
|
|
|
|
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
|
|
|
|
|
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
|
|
|
|
where: { id },
|
|
|
|
|
include: { store: { select: { name: true } } },
|
|
|
|
|
});
|
|
|
|
|
if (!row) throw new NotFoundException('变更请求不存在');
|
|
|
|
|
const dto = this.toDto(
|
|
|
|
|
row as unknown as Record<string, unknown>,
|
|
|
|
|
(row as { store?: { name?: string } }).store?.name,
|
|
|
|
|
);
|
|
|
|
|
const live = (row as { liveSnapshot?: Record<string, unknown> }).liveSnapshot || {};
|
|
|
|
|
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
|
|
|
|
|
const changed = ((row as { changedFields?: ChangeableField[] }).changedFields as ChangeableField[]) || [];
|
|
|
|
|
const diffs: StoreInfoChangeFieldDiff[] = changed.map((field) => ({
|
|
|
|
|
field,
|
|
|
|
|
live: live[field] ?? null,
|
|
|
|
|
proposed: proposed[field] ?? null,
|
|
|
|
|
}));
|
|
|
|
|
return { ...dto, diffs };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 总部审核通过/驳回 */
|
|
|
|
|
async audit(input: {
|
|
|
|
|
id: bigint;
|
|
|
|
|
action: 'APPROVE' | 'REJECT';
|
|
|
|
|
rejectReason?: string;
|
|
|
|
|
reviewerId: bigint;
|
|
|
|
|
}): Promise<StoreInfoChangeRequestDto> {
|
|
|
|
|
const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id: input.id } });
|
|
|
|
|
if (!row) throw new NotFoundException('变更请求不存在');
|
|
|
|
|
if (row.status !== 'PENDING') {
|
|
|
|
|
throw new BadRequestException('该变更请求已处理');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.action === 'REJECT') {
|
|
|
|
|
const updated = await this.prisma.storeInfoChangeRequest.update({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'REJECTED',
|
|
|
|
|
rejectReason: normalizeOptionalTextField(input.rejectReason) || '总部驳回',
|
|
|
|
|
reviewerId: input.reviewerId,
|
|
|
|
|
reviewedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
this.logger.log(`Store info change rejected id=${input.id}`);
|
|
|
|
|
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APPROVE:将 proposedSnapshot 写入 Store(白名单内)
|
|
|
|
|
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
|
|
|
|
|
const data: Record<string, unknown> = {};
|
|
|
|
|
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
|
|
|
|
if (!(field in proposed)) continue;
|
|
|
|
|
const v = proposed[field];
|
|
|
|
|
data[field] = v == null ? null : v;
|
|
|
|
|
}
|
|
|
|
|
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
|
|
|
|
|
|
|
|
|
|
const updated = await this.prisma.storeInfoChangeRequest.update({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'APPROVED',
|
|
|
|
|
reviewerId: input.reviewerId,
|
|
|
|
|
reviewedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
this.logger.log(`Store info change approved id=${input.id} storeId=${row.storeId}`);
|
|
|
|
|
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toDto(
|
|
|
|
|
row: Record<string, unknown>,
|
|
|
|
|
storeName?: string,
|
|
|
|
|
): StoreInfoChangeRequestDto {
|
|
|
|
|
return {
|
|
|
|
|
id: String(row.id),
|
|
|
|
|
storeId: String(row.storeId),
|
|
|
|
|
storeName,
|
|
|
|
|
status: row.status as StoreInfoChangeStatus,
|
|
|
|
|
changedFields: ((row.changedFields as ChangeableField[]) || []).map(String) as never,
|
|
|
|
|
submitterType: row.submitterType as StoreInfoChangeSubmitterType,
|
|
|
|
|
submitterId: String(row.submitterId),
|
|
|
|
|
rejectReason: (row.rejectReason as string | null) ?? null,
|
|
|
|
|
reviewedAt: row.reviewedAt ? (row.reviewedAt as Date).toISOString() : null,
|
|
|
|
|
createdAt: (row.createdAt as Date).toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|