feat(ops,store,partner): 用户权益列 + 门店合同多图上传
- admin 用户列表新增剩余/已用/累计好客权益金额三列(benefitCoupon groupBy 聚合,排除 VOID) - admin/partner 门店入驻合同支持多张照片与 PDF(CommonResource 多记录,复用 ENV 多图逻辑) - 新增 contract-urls.util 归一化工具,向后兼容旧 contractUrl 字段并标记 deprecated 需求1/2/3
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 门店签约合同(CommonResource bizType='CONTRACT')多附件工具。
|
||||
*
|
||||
* 历史上合同只存单条记录(字段 contractUrl:string),现改为支持多张照片 / PDF。
|
||||
* 入参同时兼容新的 contractUrls:string[] 与旧的 contractUrl:string。
|
||||
*/
|
||||
|
||||
export const MAX_CONTRACT_FILES = 20;
|
||||
|
||||
/**
|
||||
* 归一化合同附件地址:去空白、去重、限制数量。
|
||||
* @param urls 新字段 contractUrls
|
||||
* @param legacy 旧字段 contractUrl(仅在 urls 未提供时生效)
|
||||
*/
|
||||
export function normalizeContractUrls(
|
||||
urls?: unknown,
|
||||
legacy?: string | null,
|
||||
): string[] {
|
||||
const raw: unknown[] = Array.isArray(urls)
|
||||
? urls
|
||||
: legacy != null
|
||||
? [legacy]
|
||||
: [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
const url = String(item ?? '').trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
if (out.length >= MAX_CONTRACT_FILES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** PDF 存 FILE,其余(合同照片)存 IMAGE,便于前端按图片预览 */
|
||||
export function contractMediaType(url: string): 'FILE' | 'IMAGE' {
|
||||
return /\.pdf(\?|$)/i.test(url) ? 'FILE' : 'IMAGE';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { 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';
|
||||
@@ -446,24 +447,23 @@ export class AdminStoresService {
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.contractUrl !== undefined) {
|
||||
const contractUrl = dto.contractUrl?.trim() || '';
|
||||
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' },
|
||||
});
|
||||
if (contractUrl) {
|
||||
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
|
||||
for (let i = 0; i < contractUrls.length; i++) {
|
||||
await tx.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: isPdf ? 'FILE' : 'IMAGE',
|
||||
mediaType: contractMediaType(contractUrls[i]),
|
||||
ossBucket: 'legacy',
|
||||
ossKey: contractUrl,
|
||||
url: contractUrl,
|
||||
sortOrder: 0,
|
||||
ossKey: contractUrls[i],
|
||||
url: contractUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -642,16 +642,18 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.contractUrl) {
|
||||
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: 'FILE',
|
||||
mediaType: contractMediaType(contractUrls[i]),
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.contractUrl,
|
||||
url: dto.contractUrl,
|
||||
ossKey: contractUrls[i],
|
||||
url: contractUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,28 @@ import type { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
|
||||
|
||||
/** 权益统计口径:VOID(退款作废)不计入,与 C 端 listCoupons 一致 */
|
||||
const BENEFIT_STAT_STATUSES = ['ACTIVE', 'USED_UP'] as const;
|
||||
|
||||
export type UserBenefitStat = {
|
||||
/** 累计获得(含已使用) */
|
||||
benefitTotalAmount: number;
|
||||
/** 已使用(已核销) */
|
||||
benefitUsedAmount: number;
|
||||
/** 剩余未使用(可核销余额) */
|
||||
benefitBalance: number;
|
||||
};
|
||||
|
||||
const EMPTY_BENEFIT_STAT: UserBenefitStat = {
|
||||
benefitTotalAmount: 0,
|
||||
benefitUsedAmount: 0,
|
||||
benefitBalance: 0,
|
||||
};
|
||||
|
||||
function toAmount(v: Prisma.Decimal | number | null | undefined) {
|
||||
return v == null ? 0 : Number(v);
|
||||
}
|
||||
|
||||
function mapAdminUserRow(u: {
|
||||
id: bigint;
|
||||
userNo: string;
|
||||
@@ -23,7 +45,7 @@ function mapAdminUserRow(u: {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { orders: number };
|
||||
}) {
|
||||
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
|
||||
return {
|
||||
id: u.id,
|
||||
userNo: u.userNo,
|
||||
@@ -42,6 +64,7 @@ function mapAdminUserRow(u: {
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
orderCount: u._count.orders,
|
||||
...benefit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,14 +113,37 @@ export class AdminUsersService {
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
const benefitMap = await this.loadBenefitStats(items.map((u) => u.id));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((u) => mapAdminUserRow(u)),
|
||||
items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量聚合当前页用户的好客权益金额(累计获得 / 已使用 / 剩余) */
|
||||
private async loadBenefitStats(userIds: bigint[]): Promise<Map<string, UserBenefitStat>> {
|
||||
const map = new Map<string, UserBenefitStat>();
|
||||
if (!userIds.length) return map;
|
||||
|
||||
const grouped = await this.prisma.benefitCoupon.groupBy({
|
||||
by: ['userId'],
|
||||
where: { userId: { in: userIds }, status: { in: [...BENEFIT_STAT_STATUSES] } },
|
||||
_sum: { totalAmount: true, usedAmount: true, balance: true },
|
||||
});
|
||||
|
||||
for (const row of grouped) {
|
||||
map.set(row.userId.toString(), {
|
||||
benefitTotalAmount: toAmount(row._sum.totalAmount),
|
||||
benefitUsedAmount: toAmount(row._sum.usedAmount),
|
||||
benefitBalance: toAmount(row._sum.balance),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
@@ -128,8 +174,11 @@ export class AdminUsersService {
|
||||
});
|
||||
}
|
||||
|
||||
const benefitMap = await this.loadBenefitStats([user.id]);
|
||||
|
||||
return serializeBigInt({
|
||||
...user,
|
||||
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
|
||||
wechatVerified: !!user.wxOpenId,
|
||||
mergedFromCount: user._count.mergedFrom,
|
||||
orderCount: user._count.orders,
|
||||
|
||||
@@ -105,10 +105,18 @@ export class CreateStoreDto {
|
||||
@ArrayMaxSize(20)
|
||||
envPhotoUrls?: string[];
|
||||
|
||||
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractUrl?: string;
|
||||
|
||||
/** 签约合同(支持多张照片 / PDF) */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(20)
|
||||
contractUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -190,10 +198,18 @@ export class UpdateStoreDto {
|
||||
@ArrayMaxSize(20)
|
||||
envPhotoUrls?: string[];
|
||||
|
||||
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractUrl?: string | null;
|
||||
|
||||
/** 签约合同(支持多张照片 / PDF),传空数组即清空 */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(20)
|
||||
contractUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
@@ -377,11 +378,14 @@ export class StoreService {
|
||||
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() : '';
|
||||
const contractUrls = normalizeContractUrls(
|
||||
body.contractUrls,
|
||||
body.contractUrl != null ? String(body.contractUrl) : null,
|
||||
);
|
||||
|
||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
||||
if (!contractUrl) throw new BadRequestException('请上传签约合同');
|
||||
if (!contractUrls.length) throw new BadRequestException('请上传签约合同');
|
||||
|
||||
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||
@@ -486,16 +490,17 @@ export class StoreService {
|
||||
});
|
||||
}
|
||||
|
||||
if (contractUrl) {
|
||||
for (let i = 0; i < contractUrls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: 'FILE',
|
||||
mediaType: contractMediaType(contractUrls[i]),
|
||||
ossBucket,
|
||||
ossKey: contractUrl,
|
||||
url: contractUrl,
|
||||
ossKey: contractUrls[i],
|
||||
url: contractUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user