feat(ops): add HQ admin proxy order and mini-user store session fixes
Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,7 @@ export const HqOperationAction = {
|
||||
ORDER_SHIP: 'ORDER_SHIP',
|
||||
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
|
||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||
STORE_CREATE: 'STORE_CREATE',
|
||||
STORE_UPDATE: 'STORE_UPDATE',
|
||||
STORE_STATUS: 'STORE_STATUS',
|
||||
@@ -122,6 +123,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
|
||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import {
|
||||
HqProxyOrderCreateDto,
|
||||
HqProxyOrderPreviewDto,
|
||||
HqProxyOrderSendCustomerSmsDto,
|
||||
} from './dto/hq-proxy-order.dto';
|
||||
|
||||
@Controller('admin/proxy-orders')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('orders')
|
||||
export class AdminProxyOrdersController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get('options')
|
||||
options() {
|
||||
return this.tradeService.getHqProxyOrderOptions();
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
preview(@Body() dto: HqProxyOrderPreviewDto) {
|
||||
return this.tradeService.previewPartnerProxyOrder(dto);
|
||||
}
|
||||
|
||||
@Post('send-customer-sms')
|
||||
sendCustomerSms(@Body() dto: HqProxyOrderSendCustomerSmsDto) {
|
||||
return this.tradeService.sendHqProxyCustomerSms(dto.phone);
|
||||
}
|
||||
|
||||
@Post('send-operator-sms')
|
||||
sendOperatorSms(@CurrentUser() user: AuthUser) {
|
||||
return this.tradeService.sendHqProxyOperatorSms(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_PROXY_CREATE,
|
||||
refType: 'ORDER',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: HqProxyOrderCreateDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.tradeService.createHqProxyOrder(user.actorId, dto, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class HqProxyOrderPreviewDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverCity?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverDistrict?: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderSendCustomerSmsDto {
|
||||
@IsString()
|
||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderCreateDto {
|
||||
@IsString()
|
||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
customerSmsCode: string;
|
||||
|
||||
/** 发至当前 HQ 登录手机号的确认验证码 */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
operatorSmsCode: string;
|
||||
|
||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsBoolean()
|
||||
autoReceive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
receiverName?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
province?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
city?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
district?: string;
|
||||
|
||||
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
addressDetail?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminProxyOrdersController } from './admin-proxy-orders.controller';
|
||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||
import { AdminStoresService } from './admin-stores.service';
|
||||
@@ -76,6 +77,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminDeployController,
|
||||
AdminUsersController,
|
||||
AdminOrdersController,
|
||||
AdminProxyOrdersController,
|
||||
AdminStoresController,
|
||||
AdminStoreAccountsController,
|
||||
AdminStoreMediaController,
|
||||
|
||||
@@ -1383,4 +1383,255 @@ export class TradeService {
|
||||
|
||||
return this.getPartnerOrder(partnerAccountId, order.id);
|
||||
}
|
||||
|
||||
/** HQ 代下单:商品/推广码选项(不绑定合伙人门店) */
|
||||
async getHqProxyOrderOptions() {
|
||||
const [products, promoCodes] = await Promise.all([
|
||||
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
||||
this.promoCodeService.listActiveOptions(),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
products: products.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
spec: p.spec,
|
||||
price: Number(p.price),
|
||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
||||
})),
|
||||
promoCodes,
|
||||
stores: [],
|
||||
});
|
||||
}
|
||||
|
||||
async sendHqProxyCustomerSms(phone: string) {
|
||||
const normalizedPhone = phone.trim();
|
||||
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
|
||||
clientApp: ClientApp.HQ_WEB,
|
||||
});
|
||||
const masked =
|
||||
normalizedPhone.length >= 7
|
||||
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
|
||||
: normalizedPhone;
|
||||
return { ok: true, maskedPhone: masked };
|
||||
}
|
||||
|
||||
async sendHqProxyOperatorSms(hqAccountId: bigint) {
|
||||
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
||||
if (!hq || hq.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('总部账号无效');
|
||||
}
|
||||
const operatorPhone = hq.phone?.trim();
|
||||
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
|
||||
throw new BadRequestException('总部账号手机号无效,无法发送确认验证码');
|
||||
}
|
||||
await this.authService.sendSms(operatorPhone, SmsScene.PARTNER_PROXY_ORDER, {
|
||||
clientApp: ClientApp.HQ_WEB,
|
||||
});
|
||||
const masked =
|
||||
operatorPhone.length >= 7
|
||||
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
|
||||
: operatorPhone;
|
||||
return { ok: true, maskedPhone: masked };
|
||||
}
|
||||
|
||||
async createHqProxyOrder(
|
||||
hqAccountId: bigint,
|
||||
body: {
|
||||
phone: string;
|
||||
customerSmsCode: string;
|
||||
operatorSmsCode: string;
|
||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||
autoReceive?: boolean;
|
||||
receiverName?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
addressDetail?: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
},
|
||||
req: Request,
|
||||
) {
|
||||
const normalizedPhone = body.phone.trim();
|
||||
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
||||
if (!hq || hq.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('总部账号无效');
|
||||
}
|
||||
const operatorPhone = hq.phone?.trim();
|
||||
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
|
||||
throw new BadRequestException('总部账号手机号无效');
|
||||
}
|
||||
|
||||
await this.authService.verifySmsCode(
|
||||
normalizedPhone,
|
||||
body.customerSmsCode.trim(),
|
||||
SmsScene.PARTNER_PROXY_CUSTOMER,
|
||||
);
|
||||
await this.authService.verifySmsCode(
|
||||
operatorPhone,
|
||||
body.operatorSmsCode.trim(),
|
||||
SmsScene.PARTNER_PROXY_ORDER,
|
||||
);
|
||||
|
||||
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
|
||||
throw new BadRequestException('配送到址须勾选同意自动收货');
|
||||
}
|
||||
|
||||
const maskedOperatorPhone =
|
||||
operatorPhone.length >= 7
|
||||
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
|
||||
: operatorPhone;
|
||||
const proxyDisplayName = `总部·${hq.name}`;
|
||||
|
||||
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
|
||||
sourceType: 'PARTNER_PROXY',
|
||||
sourceRefId: hq.id,
|
||||
sourceLabel: `总部代下单·${maskedOperatorPhone}`,
|
||||
});
|
||||
|
||||
const preview = await this.previewPartnerProxyOrder({
|
||||
productId: body.productId,
|
||||
quantity: body.quantity,
|
||||
deliveryMode: body.deliveryMode,
|
||||
receiverCity: body.city,
|
||||
receiverDistrict: body.district,
|
||||
});
|
||||
|
||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||
|
||||
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||
let receiverProvince = body.province?.trim() || '';
|
||||
let receiverCity = body.city?.trim() || '';
|
||||
let receiverDistrict = body.district?.trim() || '';
|
||||
let receiverAddress = '';
|
||||
let commissionDistrict = receiverDistrict;
|
||||
|
||||
if (body.deliveryMode === 'ON_SITE_PICKUP') {
|
||||
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||
receiverProvince = '现场';
|
||||
receiverCity = '现场';
|
||||
receiverDistrict = '取货';
|
||||
receiverAddress = '现场提货';
|
||||
commissionDistrict = '';
|
||||
} else {
|
||||
if (!receiverProvince || !receiverCity || !receiverDistrict) {
|
||||
throw new BadRequestException('请选择省市区');
|
||||
}
|
||||
if (!body.addressDetail?.trim()) {
|
||||
throw new BadRequestException('请填写详细地址');
|
||||
}
|
||||
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
||||
}
|
||||
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||
|
||||
let promoCodeId: bigint | undefined;
|
||||
if (body.promoCodeId?.trim()) {
|
||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
||||
}
|
||||
|
||||
const orderNo = generateOrderNo();
|
||||
const now = new Date();
|
||||
const location = buildOrderClientLocationSnapshot(
|
||||
req,
|
||||
this.ipGeoService.resolve(extractClientIp(req)),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
orderType: 'PROXY',
|
||||
userId: user.id,
|
||||
cityId: city.id,
|
||||
status: 'COMPLETED',
|
||||
payStatus: 'PAID',
|
||||
deliveryType: preview.deliveryType,
|
||||
channelSource: 'OFFLINE_PROXY',
|
||||
promoCodeId,
|
||||
productId: product.id,
|
||||
barcode69: product.barcode69,
|
||||
productName: product.name,
|
||||
productSpec: product.spec,
|
||||
imageResourceId: product.coverResourceId,
|
||||
quantity: body.quantity,
|
||||
listUnitPrice: product.price,
|
||||
listAmount: preview.productAmount,
|
||||
productAmount: preview.productAmount,
|
||||
payAmount: preview.payAmount,
|
||||
benefitAmount: preview.benefitAmount,
|
||||
freightAmount: 0,
|
||||
freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null,
|
||||
receiverName,
|
||||
receiverPhone: normalizedPhone,
|
||||
receiverAddress,
|
||||
receiverProvince,
|
||||
receiverCity,
|
||||
receiverDistrict,
|
||||
clientIp: location.clientIp,
|
||||
ipProvince: location.ipProvince,
|
||||
ipCity: location.ipCity,
|
||||
ipDistrict: location.ipDistrict,
|
||||
paidAt: now,
|
||||
shippedAt: now,
|
||||
completedAt: now,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
proxyPartnerAccountId: null,
|
||||
proxyPartnerName: proxyDisplayName,
|
||||
proxyPartnerPhone: operatorPhone,
|
||||
remark: `总部代下单 hqAccountId=${hq.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
|
||||
},
|
||||
include: { product: true, imageResource: true },
|
||||
});
|
||||
|
||||
await tx.orderDelivery.create({
|
||||
data: {
|
||||
orderId: created.id,
|
||||
provider: 'MANUAL',
|
||||
outWarehouseAt: now,
|
||||
shippingAt: now,
|
||||
deliveredAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: created.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'COMPLETED',
|
||||
operator: 'HQ_PROXY',
|
||||
remark: `总部线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
await tx.commonPromoCode.update({
|
||||
where: { id: promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
|
||||
return serializeBigInt({
|
||||
id: order.id,
|
||||
orderNo: order.orderNo,
|
||||
status: order.status,
|
||||
payAmount: Number(order.payAmount),
|
||||
benefitAmount: Number(order.benefitAmount),
|
||||
proxyPartnerName: proxyDisplayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user