@@ -22,6 +22,14 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
|
||||
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
||||
|
||||
const MIN_ENV_PHOTOS = 3;
|
||||
const MAX_ENV_PHOTOS = 20;
|
||||
|
||||
/** Store 表标量字段(不含媒体) */
|
||||
const STORE_SCALAR_CHANGE_FIELDS = STORE_INFO_CHANGEABLE_FIELDS.filter(
|
||||
(f) => f !== 'coverUrl' && f !== 'envPhotoUrls',
|
||||
) as ChangeableField[];
|
||||
|
||||
function normalizeOptionalTextField(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const s = String(value).trim();
|
||||
@@ -45,6 +53,27 @@ function coerceNumberOrNull(value: unknown): number | null {
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeEnvPhotoUrls(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new BadRequestException('环境照片须为 URL 数组');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const item of raw) {
|
||||
const u = String(item ?? '').trim();
|
||||
if (!u || seen.has(u)) continue;
|
||||
seen.add(u);
|
||||
urls.push(u);
|
||||
}
|
||||
if (urls.length < MIN_ENV_PHOTOS) {
|
||||
throw new BadRequestException(`请上传至少 ${MIN_ENV_PHOTOS} 张环境照片`);
|
||||
}
|
||||
if (urls.length > MAX_ENV_PHOTOS) {
|
||||
throw new BadRequestException(`环境照片最多 ${MAX_ENV_PHOTOS} 张`);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
|
||||
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -67,6 +96,15 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
|
||||
case 'benefitUsageRule':
|
||||
out[field] = normalizeOptionalTextField(raw);
|
||||
break;
|
||||
case 'coverUrl': {
|
||||
const url = String(raw ?? '').trim();
|
||||
if (!url) throw new BadRequestException('请上传门头照');
|
||||
out[field] = url;
|
||||
break;
|
||||
}
|
||||
case 'envPhotoUrls':
|
||||
out[field] = normalizeEnvPhotoUrls(raw);
|
||||
break;
|
||||
default:
|
||||
out[field] = raw == null ? null : String(raw);
|
||||
}
|
||||
@@ -74,18 +112,13 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
|
||||
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;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
const aa = Array.isArray(a) ? a.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
|
||||
const bb = Array.isArray(b) ? b.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
|
||||
return aa.length === bb.length && aa.every((x, i) => x === bb[i]);
|
||||
}
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
@@ -111,45 +144,150 @@ export class StoreInfoChangeService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
/** 合伙人 / 门店端 提交基础信息变更 */
|
||||
private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) {
|
||||
let coverUrl: string | null = null;
|
||||
if (coverResourceId) {
|
||||
const cover = await this.prisma.commonResource.findUnique({
|
||||
where: { id: coverResourceId },
|
||||
select: { url: true, status: true },
|
||||
});
|
||||
if (cover?.status === 'ACTIVE' && cover.url) coverUrl = cover.url;
|
||||
}
|
||||
const envs = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'ENV',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { url: true },
|
||||
});
|
||||
return {
|
||||
coverUrl,
|
||||
envPhotoUrls: envs.map((e) => e.url).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private pickLiveScalarFields(store: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
|
||||
const v = store[field];
|
||||
out[field] = v == null ? null : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 审核通过后写入门头照 / 环境图 */
|
||||
private async applyApprovedMedia(storeId: bigint, proposed: Record<string, unknown>) {
|
||||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { coverResourceId: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
|
||||
if ('coverUrl' in proposed) {
|
||||
const coverUrl = String(proposed.coverUrl ?? '').trim();
|
||||
if (!coverUrl) throw new BadRequestException('门头照不能为空');
|
||||
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' in proposed) {
|
||||
const urls = normalizeEnvPhotoUrls(proposed.envPhotoUrls);
|
||||
await this.prisma.commonResource.updateMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'ENV',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'ENV',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket,
|
||||
ossKey: urls[i],
|
||||
url: urls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 合伙人 / 门店端 提交基础信息变更(含门头照/环境图) */
|
||||
async submitChange(input: {
|
||||
submitterType: StoreInfoChangeSubmitterType;
|
||||
submitterId: bigint;
|
||||
storeId: bigint;
|
||||
fields: Record<string, unknown>;
|
||||
}): Promise<StoreInfoChangeRequestDto> {
|
||||
// 1) 校验归属
|
||||
let store: Record<string, unknown>;
|
||||
let store: Record<string, unknown> & { coverResourceId?: bigint | null };
|
||||
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>;
|
||||
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
|
||||
} 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>;
|
||||
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
|
||||
}
|
||||
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可提交变更');
|
||||
}
|
||||
if (String(store.auditStatus || '') === 'PENDING') {
|
||||
throw new BadRequestException('门店审核中,暂不可提交变更');
|
||||
}
|
||||
|
||||
// 2) 规整 proposed + diff
|
||||
const proposed = buildProposedSnapshot(input.fields);
|
||||
const live = pickLiveFields(store);
|
||||
const media = await this.loadLiveMediaFields(
|
||||
input.storeId,
|
||||
store.coverResourceId != null ? BigInt(store.coverResourceId as never) : null,
|
||||
);
|
||||
const live: Record<string, unknown> = {
|
||||
...this.pickLiveScalarFields(store),
|
||||
coverUrl: media.coverUrl,
|
||||
envPhotoUrls: media.envPhotoUrls,
|
||||
};
|
||||
const changedFields = computeChangedFields(live, proposed);
|
||||
if (changedFields.length === 0) {
|
||||
throw new BadRequestException('没有检测到需要变更的字段');
|
||||
}
|
||||
|
||||
// 3) 基础校验
|
||||
if (proposed.name != null && !String(proposed.name).trim()) {
|
||||
throw new BadRequestException('请填写门店名称');
|
||||
}
|
||||
@@ -172,7 +310,6 @@ export class StoreInfoChangeService {
|
||||
throw new BadRequestException('经纬度须同时提供');
|
||||
}
|
||||
|
||||
// 4) 同门店已有 PENDING 则替换(最新优先)
|
||||
await this.prisma.storeInfoChangeRequest.deleteMany({
|
||||
where: { storeId: input.storeId, status: 'PENDING' },
|
||||
});
|
||||
@@ -195,7 +332,6 @@ export class StoreInfoChangeService {
|
||||
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** 合伙人端查看本门店历史变更 */
|
||||
async listPartnerRequests(
|
||||
storeId: bigint,
|
||||
partnerAccountId: bigint,
|
||||
@@ -214,7 +350,6 @@ export class StoreInfoChangeService {
|
||||
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
|
||||
}
|
||||
|
||||
/** 总部列表 */
|
||||
async adminList(opts: {
|
||||
status?: StoreInfoChangeStatus;
|
||||
page?: number;
|
||||
@@ -241,7 +376,6 @@ export class StoreInfoChangeService {
|
||||
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' } }),
|
||||
@@ -250,7 +384,6 @@ export class StoreInfoChangeService {
|
||||
return { pendingCount: infoPending, packagePendingCount: packagePending };
|
||||
}
|
||||
|
||||
/** 总部详情(含字段级 diff) */
|
||||
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
||||
where: { id },
|
||||
@@ -272,7 +405,6 @@ export class StoreInfoChangeService {
|
||||
return { ...dto, diffs };
|
||||
}
|
||||
|
||||
/** 总部审核通过/驳回 */
|
||||
async audit(input: {
|
||||
id: bigint;
|
||||
action: 'APPROVE' | 'REJECT';
|
||||
@@ -299,15 +431,19 @@ export class StoreInfoChangeService {
|
||||
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) {
|
||||
for (const field of STORE_SCALAR_CHANGE_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 });
|
||||
if (Object.keys(data).length) {
|
||||
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
|
||||
}
|
||||
if ('coverUrl' in proposed || 'envPhotoUrls' in proposed) {
|
||||
await this.applyApprovedMedia(row.storeId, proposed);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.storeInfoChangeRequest.update({
|
||||
where: { id: input.id },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -13,14 +14,18 @@ import {
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreService } from './store.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
|
||||
type PackageInput = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class StorePackageService {
|
||||
private readonly logger = new Logger(StorePackageService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storeService: StoreService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
) {}
|
||||
|
||||
normalizePackages(raw: unknown): StorePackageItemDto[] {
|
||||
@@ -181,6 +186,34 @@ export class StorePackageService {
|
||||
submitterId,
|
||||
},
|
||||
});
|
||||
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { name: true, cityName: true },
|
||||
});
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
|
||||
void this.wecomPush
|
||||
.dispatchMarkdown(
|
||||
'store.package_audit_pending',
|
||||
[
|
||||
'**套餐变更待审核**',
|
||||
`门店:${store?.name ?? storeId}`,
|
||||
store?.cityName ? `城市:${store.cityName}` : null,
|
||||
`提交端:${submitterLabel}`,
|
||||
`套餐条数:${packages.length}`,
|
||||
`时间:${now}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
{ applyMention: false },
|
||||
)
|
||||
.catch((e) =>
|
||||
this.logger.warn(
|
||||
`store.package_audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
|
||||
return serializeBigInt({
|
||||
id: req.id.toString(),
|
||||
status: req.status,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
TestWhitelistService,
|
||||
normalizeTestPhone,
|
||||
} from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
|
||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
@@ -61,6 +63,7 @@ function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly config = loadAppConfig();
|
||||
private readonly logger = new Logger(StoreService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -70,8 +73,33 @@ export class StoreService {
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly tencentLbs: TencentLbsProvider,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
) {}
|
||||
|
||||
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
|
||||
private notifyStoreAuditPending(opts: {
|
||||
storeName: string;
|
||||
cityName?: string | null;
|
||||
partnerLabel?: string | null;
|
||||
submitType: '新建' | '重提';
|
||||
}) {
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
const lines = [
|
||||
`**门店审核待处理 · ${opts.submitType}**`,
|
||||
`门店:${opts.storeName}`,
|
||||
opts.cityName ? `城市:${opts.cityName}` : null,
|
||||
opts.partnerLabel ? `合伙人:${opts.partnerLabel}` : null,
|
||||
`时间:${now}`,
|
||||
].filter(Boolean);
|
||||
void this.wecomPush
|
||||
.dispatchMarkdown('store.audit_pending', lines.join('\n'), { applyMention: false })
|
||||
.catch((e) =>
|
||||
this.logger.warn(
|
||||
`store.audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async whitelistPhoneSet(): Promise<Set<string>> {
|
||||
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
||||
select: { phone: true },
|
||||
@@ -559,6 +587,19 @@ export class StoreService {
|
||||
extraJson: { storeName: store.name, phone: normalizedPhone },
|
||||
});
|
||||
|
||||
if (!this.config.autoApproveStore) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: primaryId },
|
||||
select: { name: true, phone: true },
|
||||
});
|
||||
this.notifyStoreAuditPending({
|
||||
storeName: store.name,
|
||||
cityName: store.cityName,
|
||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
||||
submitType: '新建',
|
||||
});
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
store: mapStoreCompat({
|
||||
...store,
|
||||
@@ -716,6 +757,16 @@ export class StoreService {
|
||||
remark: '合伙人修改资料后重新提交审核',
|
||||
},
|
||||
});
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: primaryId },
|
||||
select: { name: true, phone: true },
|
||||
});
|
||||
this.notifyStoreAuditPending({
|
||||
storeName: updated.name,
|
||||
cityName: updated.cityName,
|
||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
||||
submitType: '重提',
|
||||
});
|
||||
}
|
||||
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
@@ -739,6 +790,11 @@ export class StoreService {
|
||||
if (store.auditStatus === 'PENDING') {
|
||||
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||||
}
|
||||
if (store.auditStatus === 'APPROVED') {
|
||||
throw new BadRequestException(
|
||||
'已营业门店修改门头照/环境图须通过「提交变更」由总部审核通过后生效',
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
|
||||
const hasEnv = body.envPhotoUrls !== undefined;
|
||||
@@ -825,6 +881,16 @@ export class StoreService {
|
||||
remark: '合伙人重新上传资料后重新提交审核',
|
||||
},
|
||||
});
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: primaryId },
|
||||
select: { name: true, phone: true },
|
||||
});
|
||||
this.notifyStoreAuditPending({
|
||||
storeName: store.name,
|
||||
cityName: store.cityName,
|
||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
||||
submitType: '重提',
|
||||
});
|
||||
}
|
||||
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
|
||||
Reference in New Issue
Block a user