feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
@@ -40,6 +40,7 @@ type OrderFilterInput = Pick<
| 'excludeTest'
| 'deliveryType'
| 'promoCodeId'
| 'assocPartnerAccountId'
>;
@Injectable()
@@ -217,6 +218,12 @@ export class AdminOrdersService {
if (promoCodeId && /^\d+$/.test(promoCodeId)) {
where.promoCodeId = BigInt(promoCodeId);
}
const assocPartnerAccountId = query.assocPartnerAccountId?.trim();
if (assocPartnerAccountId === 'none') {
where.partnerAccountIdAtPay = null;
} else if (assocPartnerAccountId && /^\d+$/.test(assocPartnerAccountId)) {
where.partnerAccountIdAtPay = BigInt(assocPartnerAccountId);
}
return where;
}
@@ -282,6 +289,9 @@ export class AdminOrdersService {
hqRemark: true,
deviceKey: true,
phoneVerifiedAt: true,
assocPartnerAccountId: true,
assocBoundAt: true,
assocPartner: { select: { id: true, name: true, companyName: true, phone: true } },
},
},
delivery: {
@@ -328,11 +338,54 @@ export class AdminOrdersService {
: { redeemSummary: null, redeemRecords: [] };
const withFee = this.withDeliveryLogisticsFee(order);
const { benefitCoupon: _coupon, ...orderRest } = withFee;
const { benefitCoupon: _coupon, user, ...orderRest } = withFee;
let assocPartnerAtPay: {
id: string;
name: string;
companyName: string | null;
phone: string | null;
orderCommissionRate: number | null;
} | null = null;
if (order.partnerAccountIdAtPay) {
const partner = await this.prisma.partnerAccount.findUnique({
where: { id: order.partnerAccountIdAtPay },
select: { id: true, name: true, companyName: true, phone: true },
});
if (partner) {
assocPartnerAtPay = {
id: partner.id.toString(),
name: partner.name,
companyName: partner.companyName,
phone: partner.phone,
orderCommissionRate:
order.orderCommissionRateAtPay != null ? Number(order.orderCommissionRateAtPay) : null,
};
}
}
const userAssoc = user?.assocPartner
? {
id: user.assocPartner.id.toString(),
name: user.assocPartner.name,
companyName: user.assocPartner.companyName,
phone: user.assocPartner.phone,
}
: null;
return serializeBigInt(
mapOrderCompat({
...orderRest,
user: user
? {
...user,
assocPartner: userAssoc,
assocBoundAt: user.assocBoundAt?.toISOString() ?? null,
}
: user,
assocPartnerAtPay,
orderCommissionRateAtPay:
order.orderCommissionRateAtPay != null ? Number(order.orderCommissionRateAtPay) : null,
statusLogs: mapStatusLogCompat(statusLogs),
benefitCoupons: coupon
? [
@@ -1,8 +1,13 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminPartnersService } from './admin-partners.service';
import { PartnerAssocService } from '../store/partner-assoc.service';
import { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import {
CreatePartnerAccountDto,
@@ -14,7 +19,10 @@ import {
@Controller('admin/partners')
@UseGuards(HqAuthGuard)
export class AdminPartnersController {
constructor(private readonly service: AdminPartnersService) {}
constructor(
private readonly service: AdminPartnersService,
private readonly assoc: PartnerAssocService,
) {}
@Get()
list(@Query() query: AdminPartnersQueryDto) {
@@ -47,6 +55,38 @@ export class AdminPartnersController {
update(@Param('id') id: string, @Body() dto: UpdatePartnerDto) {
return this.service.updatePartner(BigInt(id), dto);
}
@Get(':id/assoc')
assocSummary(@Param('id') id: string) {
return this.assoc.getSummary(BigInt(id));
}
@Get(':id/assoc/users')
assocUsers(
@Param('id') id: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
}
@Post(':id/assoc/qrcode')
regenQrcode(@Param('id') id: string) {
return this.assoc.ensureQrcode(BigInt(id), true);
}
@Post(':id/assoc/users/:userId/unbind')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_partner_assoc')
@HqOperation({
action: HqOperationAction.USER_ASSOC_UPDATE,
refType: 'USER',
refIdParam: 'userId',
includeBody: true,
})
unbindUser(@Param('userId') userId: string) {
return this.assoc.unbindUser(BigInt(userId));
}
}
@Controller('admin/partner-accounts')
@@ -4,6 +4,7 @@ import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { PartnerAssocService } from '../store/partner-assoc.service';
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import type {
CreatePartnerAccountDto,
@@ -26,11 +27,24 @@ function assertPartnerCommissionRates(
if (!check.ok) throw new BadRequestException(check.message);
}
function toCommissionDecimal(rate: number): Prisma.Decimal {
return new Prisma.Decimal(Number(rate).toFixed(4));
}
/** 选填字符串:undefined 不改;null / 空白写成 null */
function trimOptionalText(value: string | null | undefined): string | null | undefined {
if (value === undefined) return undefined;
if (value == null) return null;
const t = String(value).trim();
return t.length ? t : null;
}
@Injectable()
export class AdminPartnersService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
private readonly partnerAssocService: PartnerAssocService,
) {}
async listPartners(query: AdminPartnersQueryDto) {
@@ -64,7 +78,7 @@ export class AdminPartnersService {
},
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, children: true } },
_count: { select: { stores: true, children: true, assocUsers: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
@@ -88,6 +102,7 @@ export class AdminPartnersService {
managedWarehouseId: p.managedWarehouseId?.toString() ?? null,
managedWarehouseName: p.managedWarehouse?.name ?? null,
storeCount: p._count.stores,
assocUserCount: p._count.assocUsers,
accountCount: p._count.children + 1,
children: p.children.map((c) => ({
id: c.id.toString(),
@@ -125,7 +140,7 @@ export class AdminPartnersService {
orderBy: { createdAt: 'asc' },
},
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, children: true } },
_count: { select: { stores: true, children: true, assocUsers: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人不存在');
@@ -140,6 +155,8 @@ export class AdminPartnersService {
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
managedWarehouseName: account.managedWarehouse?.name ?? null,
storeCount: account._count.stores,
assocUserCount: account._count.assocUsers,
accountCount: account._count.children + 1,
maxPartnerCommissionRate:
account.city?.maxPartnerCommissionRate != null
@@ -189,8 +206,8 @@ export class AdminPartnersService {
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes:
dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull,
orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate,
orderCommissionRate: toCommissionDecimal(orderCommissionRate),
redeemCommissionRate: toCommissionDecimal(redeemCommissionRate),
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName?.trim() || null,
address: dto.address?.trim() || null,
@@ -204,7 +221,13 @@ export class AdminPartnersService {
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
const dtoOut = serializeBigInt(this.partnerCityService.toDto(account));
try {
await this.partnerAssocService.ensureQrcode(account.id);
} catch {
/* 补码失败不挡创建 */
}
return dtoOut;
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
@@ -221,17 +244,26 @@ export class AdminPartnersService {
? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes)
: null;
await this.partnerCityService.validatePrimaryBinding(
cityId,
{
partnerAccountId: id.toString(),
scopeType,
districtCodes: districtCodes ?? undefined,
},
id,
);
const existingDistricts = this.partnerCityService.parseDistrictCodes(existing.districtCodes);
const nextDistrictsSorted = [...(districtCodes ?? [])].sort();
const existingDistrictsSorted = [...(existingDistricts ?? [])].sort();
const scopeActuallyChanged =
(dto.scopeType !== undefined && dto.scopeType !== existing.scopeType) ||
(dto.districtCodes !== undefined &&
JSON.stringify(nextDistrictsSorted) !== JSON.stringify(existingDistrictsSorted));
if (scopeActuallyChanged) {
await this.partnerCityService.validatePrimaryBinding(
cityId,
{
partnerAccountId: id.toString(),
scopeType,
districtCodes: districtCodes ?? undefined,
},
id,
);
}
if (dto.phone !== undefined) {
if (dto.phone !== undefined && dto.phone != null) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
@@ -254,18 +286,22 @@ export class AdminPartnersService {
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
}
const phoneChanged =
dto.phone !== undefined && dto.phone.trim() !== existing.phone;
const nextPhone = dto.phone != null ? dto.phone.trim() : undefined;
const phoneChanged = nextPhone !== undefined && nextPhone !== existing.phone;
const nextName = dto.name != null ? dto.name.trim() : undefined;
const nextCompanyName = trimOptionalText(dto.companyName);
const nextAddress = trimOptionalText(dto.address);
const nextContactPhone = trimOptionalText(dto.contactPhone);
const account = await this.prisma.partnerAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
...(nextName !== undefined ? { name: nextName } : {}),
...(nextPhone !== undefined ? { phone: nextPhone } : {}),
...(phoneChanged ? { wxOpenId: null, wxUnionId: null } : {}),
...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}),
...(nextCompanyName !== undefined ? { companyName: nextCompanyName } : {}),
...(nextAddress !== undefined ? { address: nextAddress } : {}),
...(nextContactPhone !== undefined ? { contactPhone: nextContactPhone } : {}),
...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}),
...(dto.scopeType !== undefined || dto.districtCodes !== undefined
? {
@@ -275,8 +311,12 @@ export class AdminPartnersService {
: Prisma.JsonNull,
}
: {}),
...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}),
...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}),
...(dto.orderCommissionRate !== undefined
? { orderCommissionRate: toCommissionDecimal(dto.orderCommissionRate) }
: {}),
...(dto.redeemCommissionRate !== undefined
? { redeemCommissionRate: toCommissionDecimal(dto.redeemCommissionRate) }
: {}),
...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}),
...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}),
...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}),
@@ -54,6 +54,9 @@ export class AdminStoreRatingsService {
id: r.id,
serviceScore: r.serviceScore,
envScore: r.envScore,
comment: r.comment,
tags: r.tags,
imageUrls: r.imageUrls,
createdAt: r.createdAt,
store: r.store,
redeemRecordId: r.redeemRecordId,
@@ -8,7 +8,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminUsersService } from './admin-users.service';
import { AdminUsersQueryDto } from './dto/admin-query.dto';
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto, UpdateAdminUserDto } from './dto/admin-mutate.dto';
import {
BatchDeleteUsersConfirmDto,
BatchDeleteUsersDto,
UpdateAdminUserDto,
UpdateUserAssocDto,
} from './dto/admin-mutate.dto';
@Controller('admin/users')
@UseGuards(HqAuthGuard)
@@ -48,6 +53,19 @@ export class AdminUsersController {
return this.usersService.detail(BigInt(id));
}
@Put(':id/assoc')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('users_partner_assoc')
@HqOperation({
action: HqOperationAction.USER_ASSOC_UPDATE,
refType: 'USER',
refIdParam: 'id',
includeBody: true,
})
setAssoc(@Param('id') id: string, @Body() dto: UpdateUserAssocDto) {
return this.usersService.setAssoc(BigInt(id), dto.partnerAccountId);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.USER_UPDATE,
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerAssocService } from '../store/partner-assoc.service';
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
@@ -28,6 +29,21 @@ function toAmount(v: Prisma.Decimal | number | null | undefined) {
return v == null ? 0 : Number(v);
}
function mapAssocPartner(p?: {
id: bigint;
name: string;
companyName: string | null;
phone: string | null;
} | null) {
if (!p) return null;
return {
id: p.id.toString(),
name: p.name,
companyName: p.companyName,
phone: p.phone,
};
}
function mapAdminUserRow(u: {
id: bigint;
userNo: string;
@@ -46,6 +62,12 @@ function mapAdminUserRow(u: {
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
assocPartner?: {
id: bigint;
name: string;
companyName: string | null;
phone: string | null;
} | null;
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
return {
id: u.id,
@@ -66,13 +88,17 @@ function mapAdminUserRow(u: {
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
assocPartner: mapAssocPartner(u.assocPartner),
...benefit,
};
}
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerAssocService: PartnerAssocService,
) {}
async list(query: AdminUsersQueryDto) {
const page = query.page ?? 1;
@@ -88,6 +114,30 @@ export class AdminUsersService {
}
where.id = BigInt(userId);
}
const keyword = query.keyword?.trim();
if (keyword) {
const keywordOr: Prisma.UserWhereInput[] = [
{ userNo: { contains: keyword } },
{ nickname: { contains: keyword } },
{ hqRemark: { contains: keyword } },
{ phone: { contains: keyword } },
];
if (/^\d+$/.test(keyword)) {
keywordOr.push({ id: BigInt(keyword) });
}
where.OR = keywordOr;
}
const assocPartnerAccountId = query.assocPartnerAccountId?.trim();
if (assocPartnerAccountId === 'none') {
where.assocPartnerAccountId = null;
} else if (assocPartnerAccountId === 'any') {
where.assocPartnerAccountId = { not: null };
} else if (assocPartnerAccountId) {
if (!/^\d+$/.test(assocPartnerAccountId)) {
return serializeBigInt({ items: [], total: 0, page, pageSize });
}
where.assocPartnerAccountId = BigInt(assocPartnerAccountId);
}
if (query.deviceKey) where.deviceKey = query.deviceKey;
if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
@@ -117,6 +167,7 @@ export class AdminUsersService {
isTest: true,
createdAt: true,
updatedAt: true,
assocPartner: { select: { id: true, name: true, companyName: true, phone: true } },
_count: { select: { orders: true } },
},
}),
@@ -159,6 +210,7 @@ export class AdminUsersService {
where: { id },
include: {
cityPreference: true,
assocPartner: { select: { id: true, name: true, companyName: true, phone: true } },
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
orders: {
orderBy: { createdAt: 'desc' },
@@ -186,18 +238,33 @@ export class AdminUsersService {
const benefitMap = await this.loadBenefitStats([user.id]);
const { assocPartner, ...userRest } = user;
return serializeBigInt({
...user,
...userRest,
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
wechatVerified: !!user.wxOpenId,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
addressCount: user._count.addresses,
sourcePromo,
assocPartner: assocPartner
? {
id: assocPartner.id.toString(),
name: assocPartner.name,
companyName: assocPartner.companyName,
phone: assocPartner.phone,
}
: null,
assocBoundAt: user.assocBoundAt?.toISOString() ?? null,
_count: undefined,
});
}
async setAssoc(id: bigint, partnerAccountId?: string | null) {
const raw = partnerAccountId?.trim() || null;
return this.partnerAssocService.setUserAssoc(id, raw ? BigInt(raw) : null);
}
async updateUser(id: bigint, dto: { hqRemark: string }) {
const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
if (!user) throw new NotFoundException('用户不存在');
@@ -364,11 +364,13 @@ export class CreatePartnerDto {
districtCodes?: string[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@@ -412,16 +414,19 @@ export class UpdatePartnerDto {
phone?: string;
@IsOptional()
@ValidateIf((_, v) => v != null)
@IsString()
companyName?: string;
companyName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v != null)
@IsString()
address?: string;
address?: string | null;
@IsOptional()
@ValidateIf((_, v) => v != null)
@IsString()
contactPhone?: string;
contactPhone?: string | null;
@IsOptional()
@IsIn(['CITY_WIDE', 'DISTRICT'])
@@ -433,11 +438,13 @@ export class UpdatePartnerDto {
districtCodes?: string[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@@ -590,11 +597,13 @@ export class BindCityPartnerDto {
districtCodes?: string[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@@ -615,11 +624,13 @@ export class UpdateCityPartnerDto {
districtCodes?: string[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@@ -974,6 +985,13 @@ export class UpdateAdminUserDto {
hqRemark: string;
}
export class UpdateUserAssocDto {
@IsOptional()
@ValidateIf((_, v) => v != null && v !== '')
@IsString()
partnerAccountId?: string | null;
}
/** HQ 订单发货(目前仅小飞侠 XFX) */
export class AdminShipOrderDto {
@IsIn(['XFX'])
@@ -40,6 +40,16 @@ export class PaginationQueryDto {
}
export class AdminUsersQueryDto extends PaginationQueryDto {
/** 综合搜索:编号 / 昵称 / 备注 / 手机 / 数字 ID */
@IsOptional()
@IsString()
keyword?: string;
/** 关联合伙人:主账号 ID;`none` 未关联;`any` 已关联(全部) */
@IsOptional()
@IsString()
assocPartnerAccountId?: string;
@IsOptional()
@IsString()
phone?: string;
@@ -136,6 +146,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
promoCodeId?: string;
/** 本单佣金关联合伙人:主账号 ID,或 `none` 表示无快照 */
@IsOptional()
@IsString()
assocPartnerAccountId?: string;
}
/** HQ 订单导出(筛选 + 勾选范围) */
@@ -206,6 +221,10 @@ export class AdminOrdersExportDto {
@IsOptional()
@IsString()
promoCodeId?: string;
@IsOptional()
@IsString()
assocPartnerAccountId?: string;
}
/** 概览页用户/订单 ECharts 聚合筛选 */
@@ -92,6 +92,10 @@ export class HqProxyOrderCreateDto {
@IsOptional()
@IsString()
promoCodeId?: string;
@IsOptional()
@IsString()
assocPartnerAccountId?: string;
}
export class HqProxyOrderPayDto {