发布商品,商品图片使用oss服务器地址
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"lint": "echo ok",
|
||||
"lint": "eslint src",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../integrations/integrations.module';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { PrismaModule } from '../common/prisma/prisma.module';
|
||||
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||
import { WechatRefundCallbackController } from './wechat-refund.controller';
|
||||
import { DeliveryCallbackController } from './delivery-track.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, TradeModule],
|
||||
controllers: [WechatPayCallbackController],
|
||||
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
||||
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
|
||||
})
|
||||
export class CallbacksModule {}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { CourierService } from '../integrations/courier/courier.service';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
|
||||
@Controller('callbacks/delivery')
|
||||
export class DeliveryCallbackController {
|
||||
constructor(
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly courier: CourierService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Post('track')
|
||||
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
||||
if (!body.orderId && !body.orderNo) {
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
}
|
||||
const order = body.orderId
|
||||
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
|
||||
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
|
||||
if (!order) return this.courier.buildTrackCallbackResponse(false);
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
SHIPPED: 'SHIPPING',
|
||||
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
|
||||
DELIVERED: 'COMPLETED',
|
||||
COMPLETED: 'COMPLETED',
|
||||
};
|
||||
const target = statusMap[body.status ?? ''] ?? body.status;
|
||||
if (target && target !== order.status) {
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
|
||||
}
|
||||
return this.courier.buildTrackCallbackResponse(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
|
||||
@Controller('callbacks/wechat')
|
||||
export class WechatRefundCallbackController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Post('refund')
|
||||
async refundNotify(
|
||||
@Req() req: Request,
|
||||
@Headers() _headers: Record<string, string | string[] | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const body = typeof req.body === 'object' ? req.body : {};
|
||||
const outRefundNo = String((body as Record<string, unknown>).out_refund_no ?? '');
|
||||
const refundId = String((body as Record<string, unknown>).refund_id ?? outRefundNo);
|
||||
|
||||
const existing = await this.prisma.logThirdParty.findFirst({
|
||||
where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' },
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND_CALLBACK',
|
||||
refType: 'TICKET',
|
||||
refId: BigInt(0),
|
||||
externalNo: refundId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '处理失败';
|
||||
return res.status(500).json({ code: 'FAIL', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,8 @@ export class RedisService {
|
||||
async del(key: string) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
|
||||
|
||||
export type CouponAllocation = { couponId: string; amount: number };
|
||||
|
||||
@Injectable()
|
||||
export class BenefitService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -85,4 +88,73 @@ export class BenefitService {
|
||||
});
|
||||
return serializeBigInt({ coupon, ledgers });
|
||||
}
|
||||
|
||||
/** 核销扣减券余额(乐观锁),由 redeem 模块调用 */
|
||||
async deductCoupons(
|
||||
tx: Prisma.TransactionClient,
|
||||
allocations: CouponAllocation[],
|
||||
refType: 'STORE',
|
||||
refId: bigint,
|
||||
) {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new Error('BENEFIT_DEDUCT_CONFLICT');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType,
|
||||
refId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 退款作废权益 */
|
||||
async voidCouponsOnRefund(orderId: bigint) {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { orderId, status: { in: ['ACTIVE', 'USED_UP'] } },
|
||||
});
|
||||
for (const coupon of coupons) {
|
||||
const balance = Number(coupon.balance);
|
||||
if (balance <= 0 && coupon.status === 'USED_UP') continue;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.benefitCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'VOID', balance: 0 },
|
||||
});
|
||||
if (balance > 0) {
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REFUND_VOID',
|
||||
amount: -balance,
|
||||
balanceAfter: 0,
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
remark: '退款作废权益',
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class CatalogController {
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
products(@Query('aromaType') aromaType?: string) {
|
||||
return this.catalogService.listProducts(aromaType);
|
||||
products(@Query('aromaType') aromaType?: string, @Query('cityCode') cityCode?: string) {
|
||||
return this.catalogService.listProducts(aromaType, cityCode);
|
||||
}
|
||||
|
||||
@Get('products/:id')
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CommonProductItem, CommonResource } from '@prisma/client';
|
||||
|
||||
export type ProductMediaDto = {
|
||||
mainImageUrl: string | null;
|
||||
carouselUrls: string[];
|
||||
detailImageUrls: string[];
|
||||
};
|
||||
|
||||
type ProductWithCover = CommonProductItem & {
|
||||
coverResource?: { url: string } | null;
|
||||
};
|
||||
|
||||
function urlsFromResources(resources: CommonResource[], bizType: 'CAROUSEL' | 'DETAIL') {
|
||||
return resources
|
||||
.filter((r) => r.bizType === bizType && r.url)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((r) => r.url);
|
||||
}
|
||||
|
||||
export function mapProductMedia(
|
||||
product: ProductWithCover,
|
||||
extraResources: CommonResource[] = [],
|
||||
): ProductMediaDto {
|
||||
const mainImageUrl = product.coverResource?.url ?? null;
|
||||
const carouselFromDb = urlsFromResources(extraResources, 'CAROUSEL');
|
||||
const detailFromDb = urlsFromResources(extraResources, 'DETAIL');
|
||||
|
||||
const detailFromJson = parseDetailContentImages(product.detailContent);
|
||||
|
||||
const carouselUrls =
|
||||
carouselFromDb.length > 0
|
||||
? carouselFromDb
|
||||
: mainImageUrl
|
||||
? [mainImageUrl]
|
||||
: [];
|
||||
|
||||
const detailImageUrls =
|
||||
detailFromDb.length > 0
|
||||
? detailFromDb
|
||||
: detailFromJson;
|
||||
|
||||
return { mainImageUrl, carouselUrls, detailImageUrls };
|
||||
}
|
||||
|
||||
function parseDetailContentImages(detailContent: unknown): string[] {
|
||||
if (!detailContent || typeof detailContent !== 'object') return [];
|
||||
const record = detailContent as Record<string, unknown>;
|
||||
const images = record.images ?? record.detailImages ?? record.detailImageUrls;
|
||||
if (!Array.isArray(images)) return [];
|
||||
return images.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||
}
|
||||
|
||||
export function groupResourcesByProductId(resources: CommonResource[]) {
|
||||
const map = new Map<string, CommonResource[]>();
|
||||
for (const resource of resources) {
|
||||
const key = resource.ownerId.toString();
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(resource);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
@@ -10,24 +11,50 @@ export class CatalogService {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async listProducts(aromaType?: string) {
|
||||
async listProducts(aromaType?: string, cityCode?: string) {
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.commonCity.findFirst({
|
||||
where: { code: cityCode, status: 'ACTIVE' },
|
||||
});
|
||||
if (!city) throw new BadRequestException('该城市暂未开城');
|
||||
}
|
||||
|
||||
const products = await this.prisma.commonProductItem.findMany({
|
||||
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { coverResource: true },
|
||||
});
|
||||
|
||||
const productIds = products.map((p) => p.id);
|
||||
const resources = productIds.length
|
||||
? await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'PRODUCT',
|
||||
ownerId: { in: productIds },
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
: [];
|
||||
const resourceMap = groupResourcesByProductId(resources);
|
||||
|
||||
return serializeBigInt(
|
||||
products.map((p) => ({
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
mainImageUrl: p.coverResource?.url ?? null,
|
||||
})),
|
||||
products.map((p) => {
|
||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||
return {
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
...media,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,11 +64,23 @@ export class CatalogService {
|
||||
include: { coverResource: true },
|
||||
});
|
||||
if (!product) return null;
|
||||
|
||||
const resources = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'PRODUCT',
|
||||
ownerId: id,
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
const media = mapProductMedia(product, resources);
|
||||
return serializeBigInt({
|
||||
...product,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
mainImageUrl: product.coverResource?.url ?? null,
|
||||
...media,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ export class AuthService {
|
||||
? await this.wechatProvider.code2Session(code)
|
||||
: await this.wechatProvider.oauth2AccessToken(code);
|
||||
|
||||
let user = await this.prisma.user.findFirst({
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ export class AdminDashboardService {
|
||||
partnersTotal,
|
||||
redeemToday,
|
||||
deliveriesTotal,
|
||||
pendingPayouts,
|
||||
pendingBills,
|
||||
openTickets,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||
this.prisma.user.count({
|
||||
@@ -38,6 +41,9 @@ export class AdminDashboardService {
|
||||
this.prisma.partner.count(),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.orderDelivery.count(),
|
||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }),
|
||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -50,6 +56,9 @@ export class AdminDashboardService {
|
||||
partnersTotal,
|
||||
redeemToday,
|
||||
deliveriesTotal,
|
||||
pendingPayouts,
|
||||
pendingBills,
|
||||
openTickets,
|
||||
ordersByStatus: ordersByStatus.map((row) => ({
|
||||
status: row.status,
|
||||
count: row._count.status,
|
||||
|
||||
@@ -45,6 +45,11 @@ export class AdminStoresController {
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||
return this.service.updateStoreStatus(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
|
||||
return this.service.auditStore(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-accounts')
|
||||
|
||||
@@ -93,6 +93,27 @@ export class AdminStoresService {
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const status = dto.approved ? 'OPEN' : 'PAUSED';
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
refType: 'STORE',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
||||
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Controller('admin/tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminTicketsController {
|
||||
constructor(private readonly service: AdminTicketsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: TicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.approve(BigInt(id), body.remark);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.reject(BigInt(id), body.remark);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminTicketsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly benefitService: BenefitService,
|
||||
) {}
|
||||
|
||||
list(query: TicketListQueryDto) {
|
||||
return this.ticketService.list(query);
|
||||
}
|
||||
|
||||
detail(id: bigint) {
|
||||
return this.ticketService.detail(id);
|
||||
}
|
||||
|
||||
async approve(id: bigint, remark?: string) {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('工单不存在');
|
||||
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
|
||||
throw new BadRequestException('工单状态不可审批');
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
|
||||
const orderId = ticket.refId;
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
||||
});
|
||||
await this.benefitService.voidCouponsOnRefund(orderId);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
status: 'SUCCESS',
|
||||
amount: 0,
|
||||
},
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'ORDER_STATUS',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
actorType: 'HQ',
|
||||
status: 'REFUNDED',
|
||||
remark: remark ?? '退款工单审批通过',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
|
||||
await this.tradeService.applyStatusTransition(
|
||||
ticket.refId,
|
||||
'PENDING_SHIP',
|
||||
'PENDING_SHIP',
|
||||
'HQ',
|
||||
);
|
||||
}
|
||||
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'RESOLVED',
|
||||
remark: remark ?? '审批通过',
|
||||
});
|
||||
}
|
||||
|
||||
reject(id: bigint, remark?: string) {
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'REJECTED',
|
||||
remark: remark ?? '审批驳回',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const orderIds = orders.map((o) => o.id);
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
ticketType: 'RESHIPMENT',
|
||||
refType: 'ORDER',
|
||||
refId: { in: orderIds },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(tickets);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,14 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
|
||||
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { AdminTicketsController } from './admin-tickets.controller';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, TradeModule],
|
||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminUsersController,
|
||||
@@ -41,6 +45,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
AdminDeliveriesController,
|
||||
AdminHqAccountsController,
|
||||
AdminProductsController,
|
||||
AdminTicketsController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
@@ -54,6 +59,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
AdminDeliveriesService,
|
||||
AdminHqAccountsService,
|
||||
AdminProductsService,
|
||||
AdminTicketsService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -29,6 +29,11 @@ export class UserRedeemController {
|
||||
export class ShopRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('preview')
|
||||
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.previewRedeem(user.actorId, body.token);
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => SettlementModule)],
|
||||
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
|
||||
@@ -15,7 +15,15 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { SettlementService } from '../settlement/settlement.service';
|
||||
import { buildBenefitLedgerEvent } from '../../common/event/event.helpers';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
|
||||
type TokenPayload = {
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
storeId?: string | null;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RedeemService {
|
||||
@@ -23,6 +31,7 @@ export class RedeemService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly settlementService: SettlementService,
|
||||
private readonly benefitService: BenefitService,
|
||||
) {}
|
||||
|
||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||
@@ -42,6 +51,7 @@ export class RedeemService {
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
|
||||
const result = allocateBenefitCoupons(
|
||||
coupons.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
@@ -51,6 +61,8 @@ export class RedeemService {
|
||||
body.amount,
|
||||
);
|
||||
if (!result.ok) throw new BadRequestException(result.message);
|
||||
const check = validateRedeemAmount(totalBalance, body.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
allocations = result.allocations;
|
||||
}
|
||||
|
||||
@@ -70,7 +82,7 @@ export class RedeemService {
|
||||
REDEEM_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return { token, expireAt, amount: body.amount };
|
||||
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
|
||||
}
|
||||
|
||||
async getToken(token: string) {
|
||||
@@ -79,6 +91,40 @@ export class RedeemService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
async previewRedeem(storeAccountId: bigint, token: string) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: BigInt(cached.userId) },
|
||||
select: { id: true, userNo: true, phone: true, nickname: true },
|
||||
});
|
||||
|
||||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
||||
|
||||
return serializeBigInt({
|
||||
token,
|
||||
amount: cached.amount,
|
||||
user,
|
||||
boundStoreId: cached.storeId,
|
||||
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
|
||||
expireInSeconds: ttl > 0 ? ttl : 0,
|
||||
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
@@ -88,19 +134,16 @@ export class RedeemService {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<{
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
}>(`redeem:token:${body.token}`);
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||
}
|
||||
|
||||
const allocations =
|
||||
cached.allocations ??
|
||||
(cached.couponId
|
||||
? [{ couponId: cached.couponId, amount: cached.amount }]
|
||||
: []);
|
||||
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
|
||||
if (allocations.length === 0) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
@@ -126,50 +169,30 @@ export class RedeemService {
|
||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
|
||||
const record = await this.prisma.$transaction(async (tx) => {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
let record;
|
||||
try {
|
||||
record = await this.prisma.$transaction(async (tx) => {
|
||||
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
redeemNo: generateRedeemNo(),
|
||||
userId: BigInt(cached.userId),
|
||||
couponId: BigInt(allocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType: 'STORE',
|
||||
refId: account.storeId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
redeemNo: generateRedeemNo(),
|
||||
userId: BigInt(cached.userId),
|
||||
couponId: BigInt(allocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
},
|
||||
return redeemRecord;
|
||||
});
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
||||
throw new BadRequestException('核销失败,请重试');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
||||
await this.redis.del(`redeem:token:${body.token}`);
|
||||
@@ -187,6 +210,7 @@ export class RedeemService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { payout: true },
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Controller('partner/settlement')
|
||||
@@ -15,6 +16,101 @@ export class SettlementController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/payouts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopPayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-payouts')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStorePayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminStorePayouts({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminStorePayout(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm')
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
return this.settlementService.confirmStorePayout(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post('batch-confirm')
|
||||
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
|
||||
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
|
||||
}
|
||||
|
||||
@Post('scan-due')
|
||||
scanDue() {
|
||||
return this.settlementService.scanDueStorePayouts();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/partner-bills')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminPartnerBillController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Post('generate')
|
||||
generate(@Body() body: { partnerId: string; year: number; month: number }) {
|
||||
return this.settlementService.generatePartnerBill(body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminPartnerBills({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
partnerId: query.partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
export(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.exportPartnerBills({
|
||||
partnerId: query.partnerId,
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminPartnerBill(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm')
|
||||
confirm(@Param('id') id: string) {
|
||||
return this.settlementService.confirmPartnerBill(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/mark-paid')
|
||||
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { PartnerMeController, SettlementController } from './settlement.controller';
|
||||
import {
|
||||
AdminPartnerBillController,
|
||||
AdminStorePayoutController,
|
||||
PartnerMeController,
|
||||
SettlementController,
|
||||
ShopPayoutController,
|
||||
} from './settlement.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [SettlementController, PartnerMeController],
|
||||
controllers: [
|
||||
SettlementController,
|
||||
PartnerMeController,
|
||||
ShopPayoutController,
|
||||
AdminStorePayoutController,
|
||||
AdminPartnerBillController,
|
||||
],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
function generateBillNo() {
|
||||
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -29,6 +34,106 @@ export class SettlementService {
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const where = { storeId: account.storeId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { redeemRecord: { select: { redeemNo: true, amount: true } } },
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async listAdminStorePayouts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StorePayoutWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { expectedPayAt: 'asc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true } },
|
||||
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getAdminStorePayout(id: bigint) {
|
||||
const payout = await this.prisma.storePayout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
store: true,
|
||||
redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } },
|
||||
},
|
||||
});
|
||||
if (!payout) throw new NotFoundException('打款记录不存在');
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
const payout = await this.prisma.storePayout.findUnique({ where: { id } });
|
||||
if (!payout) throw new NotFoundException('打款记录不存在');
|
||||
if (payout.status === 'PAID') throw new BadRequestException('已打款');
|
||||
|
||||
const updated = await this.prisma.storePayout.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
batchNo: dto.batchNo ?? payout.batchNo,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'STORE_PAYOUT',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'PAID',
|
||||
param1: dto.paymentRef ?? '',
|
||||
remark: dto.remark ?? '门店 T+1 打款确认',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) {
|
||||
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo });
|
||||
results.push({ id, ok: true });
|
||||
} catch (e) {
|
||||
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async listPartnerBills(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
@@ -39,4 +144,203 @@ export class SettlementService {
|
||||
});
|
||||
return serializeBigInt(bills);
|
||||
}
|
||||
|
||||
async listAdminPartnerBills(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
}),
|
||||
this.prisma.partnerBill.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getAdminPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({
|
||||
where: { id },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
||||
const partnerId = BigInt(body.partnerId);
|
||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
||||
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
||||
|
||||
const existing = await this.prisma.partnerBill.findFirst({
|
||||
where: {
|
||||
partnerId,
|
||||
periodStart,
|
||||
status: { not: 'DRAFT' },
|
||||
},
|
||||
});
|
||||
if (existing && existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('该月账单已确认,不可重复生成');
|
||||
}
|
||||
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId },
|
||||
include: { commissionRule: true },
|
||||
});
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
|
||||
? Number(cities[0].commissionRule.orderCommissionRate)
|
||||
: 0.05;
|
||||
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
|
||||
? Number(cities[0].commissionRule.redeemCommissionRate)
|
||||
: 0.03;
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
cityId: { in: cityIds },
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const orderCommission = orders.reduce(
|
||||
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const redeems = await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
storeId: { in: storeIds },
|
||||
createdAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
|
||||
|
||||
const draft = await this.prisma.partnerBill.findFirst({
|
||||
where: { partnerId, periodStart, status: 'DRAFT' },
|
||||
});
|
||||
|
||||
const bill = draft
|
||||
? await this.prisma.partnerBill.update({
|
||||
where: { id: draft.id },
|
||||
data: { orderCommission, redeemCommission, totalAmount, periodEnd },
|
||||
})
|
||||
: await this.prisma.partnerBill.create({
|
||||
data: {
|
||||
billNo: generateBillNo(),
|
||||
partnerId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission,
|
||||
redeemCommission,
|
||||
totalAmount,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async confirmPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'PARTNER_BILL',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'CONFIRMED',
|
||||
amount1: Number(updated.totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'PARTNER_BILL',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'PAID',
|
||||
param1: dto.paymentRef ?? '',
|
||||
amount1: Number(updated.totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
|
||||
const rows = bills.map((b) =>
|
||||
[
|
||||
b.billNo,
|
||||
b.partner.companyName,
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
Number(b.orderCommission),
|
||||
Number(b.redeemCommission),
|
||||
Number(b.totalAmount),
|
||||
b.status,
|
||||
].join(','),
|
||||
);
|
||||
return { csv: [header, ...rows].join('\n'), count: bills.length };
|
||||
}
|
||||
|
||||
async scanDueStorePayouts() {
|
||||
const now = new Date();
|
||||
const due = await this.prisma.storePayout.findMany({
|
||||
where: { status: 'PENDING', expectedPayAt: { lte: now } },
|
||||
take: 100,
|
||||
});
|
||||
return serializeBigInt({ dueCount: due.length, items: due });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ export class TradeController {
|
||||
confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.confirmReceive(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/refund-requests')
|
||||
refundRequest(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { remark?: string },
|
||||
) {
|
||||
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/orders')
|
||||
@@ -85,3 +94,14 @@ export class PartnerOrderController {
|
||||
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/reshipments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerReshipmentController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.tradeService.listPartnerReshipments(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { TradeController, PartnerOrderController } from './trade.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
|
||||
controllers: [TradeController, PartnerOrderController],
|
||||
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
|
||||
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
||||
providers: [TradeService],
|
||||
exports: [TradeService],
|
||||
})
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
|
||||
@@ -29,16 +31,16 @@ import type { Request } from 'express';
|
||||
export class TradeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly ipGeoService: IpGeoService,
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const product = await this.catalogService.getProduct(BigInt(body.productId));
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
@@ -73,7 +75,7 @@ export class TradeService {
|
||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||
|
||||
return {
|
||||
product: serializeBigInt(product),
|
||||
product,
|
||||
quantity: body.quantity,
|
||||
deliveryType,
|
||||
productAmount,
|
||||
@@ -384,6 +386,47 @@ export class TradeService {
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单不可申请退款');
|
||||
}
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
||||
});
|
||||
return this.ticketService.create({
|
||||
ticketType: 'REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId.toString(),
|
||||
remark: remark ?? '用户申请退款',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
ticketType: 'RESHIPMENT',
|
||||
refType: 'ORDER',
|
||||
refId: { in: orders.map((o) => o.id) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(tickets);
|
||||
}
|
||||
|
||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
|
||||
Reference in New Issue
Block a user