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:
2026-08-12 22:48:02 +08:00
parent 478291e2b3
commit 88053353ab
13 changed files with 356 additions and 59 deletions
@@ -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,
},
});
}