代下单功能

This commit is contained in:
2026-07-12 11:40:04 +08:00
parent d949301bc1
commit de01d36cdb
46 changed files with 2657 additions and 253 deletions
@@ -0,0 +1,71 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
export class PartnerProxyOrderPreviewDto {
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
receiverCity?: string;
@IsOptional()
@IsString()
receiverDistrict?: string;
}
export class PartnerProxyOrderSendSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
}
export class PartnerProxyOrderCreateDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@IsString()
@IsNotEmpty()
smsCode: string;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@IsString()
@MaxLength(32)
province: string;
@IsString()
@MaxLength(32)
city: string;
@IsString()
@MaxLength(32)
district: string;
@IsString()
@MaxLength(256)
addressDetail: string;
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
promoCodeId?: string;
}
@@ -5,6 +5,11 @@ import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto,
} from './dto/partner-proxy-order.dto';
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
@@ -106,3 +111,33 @@ export class PartnerReshipmentController {
return this.tradeService.listPartnerReshipments(user.actorId);
}
}
@Controller('partner/proxy-orders')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getPartnerProxyOrderOptions();
}
@Post('preview')
preview(@Body() dto: PartnerProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto);
}
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
}
@Post()
create(
@CurrentUser() user: AuthUser,
@Body() dto: PartnerProxyOrderCreateDto,
@Req() req: Request,
) {
return this.tradeService.createPartnerProxyOrder(user.actorId, dto, req);
}
}
@@ -6,12 +6,32 @@ import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { PromoModule } from '../promo/promo.module';
import {
TradeController,
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
} from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
imports: [
IntegrationsModule,
IamModule,
CatalogModule,
AnalyticsModule,
CityScopeModule,
PromoModule,
forwardRef(() => BenefitModule),
CommonModule,
],
controllers: [
TradeController,
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
],
providers: [TradeService],
exports: [TradeService],
})
@@ -11,13 +11,15 @@ import {
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { loadAppConfig, ClientApp, SmsScene, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -41,6 +43,8 @@ export class TradeService {
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly authService: AuthService,
private readonly promoCodeService: PromoCodeService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -584,4 +588,225 @@ export class TradeService {
});
});
}
async getPartnerProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
this.catalogService.listProducts(),
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,
})),
promoCodes,
});
}
async previewPartnerProxyOrder(body: {
productId: string;
quantity: number;
receiverCity?: string;
receiverDistrict?: string;
}) {
const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
const check = validateMinPurchase(
deliveryType,
body.quantity,
city.localMinQty,
city.crossMinQty,
);
if (!check.ok) throw new BadRequestException(check.message);
const unitPrice = Number(product.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
return {
productAmount,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
deliveryType,
unitPrice,
};
}
async sendPartnerProxyOrderSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
normalizedPhone.length >= 7
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
: normalizedPhone;
return { ok: true, maskedPhone: masked };
}
async createPartnerProxyOrder(
partnerAccountId: bigint,
body: {
phone: string;
smsCode: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
productId: string;
quantity: number;
promoCodeId?: string;
},
req: Request,
) {
const normalizedPhone = body.phone.trim();
await this.authService.verifySmsCode(
normalizedPhone,
body.smsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone);
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
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' } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district);
let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) {
promoCodeId = BigInt(body.promoCodeId.trim());
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
}
const receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
const receiverAddress = `${body.province}${body.city}${body.district}${body.addressDetail}`;
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: body.province,
receiverCity: body.city,
receiverDistrict: body.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
paidAt: now,
shippedAt: now,
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
remark: `合伙人代下单 partnerAccountId=${primary.id}`,
},
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: 'PARTNER_PROXY',
remark: '合伙人线下代下单',
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
await this.benefitService.grantOnOrderPaid(order.id);
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_proxy_order_create',
refType: 'ORDER',
refId: order.id,
extraJson: {
orderId: order.id.toString(),
userId: user.id.toString(),
productId: body.productId,
quantity: body.quantity,
promoCodeId: promoCodeId?.toString() ?? null,
},
});
return this.getPartnerOrder(partnerAccountId, order.id);
}
}