feat(settlement): 门店未出账手动提现与总部审核(OPT-010)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 15:32:01 +08:00
parent 666bcb8633
commit f3353bbce5
29 changed files with 1733 additions and 15 deletions
+50
View File
@@ -314,6 +314,12 @@ enum StorePayoutStatus {
PAID
}
enum StoreWithdrawStatus {
PENDING_REVIEW
REJECTED
PAID
}
enum ThirdPartyProvider {
WECHAT_PAY
WECHAT_REFUND
@@ -1064,6 +1070,8 @@ model Store {
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
/// Online test: only listed phones can see store on C-end when enabled
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
/// FIN-001:允许未出账手动提现的白名单门店
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1077,6 +1085,7 @@ model Store {
ratings StoreRating[]
payouts StorePayout[]
storeBills StoreBill[]
withdrawRequests StoreWithdrawRequest[]
visibilityPhones StoreVisibilityPhone[]
@@index([cityId, status])
@@ -1121,6 +1130,7 @@ model StoreAccount {
childAccounts StoreAccount[] @relation("StoreAccountHierarchy")
bindings StoreAccountStore[]
redeemPendingRecords RedeemPendingRecord[]
withdrawRequests StoreWithdrawRequest[]
@@index([parentAccountId])
@@map("store_account")
@@ -1432,12 +1442,52 @@ model StorePayout {
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
storeBill StoreBill? @relation(fields: [storeBillId], references: [id], onDelete: SetNull)
withdrawItem StoreWithdrawPayoutItem?
@@index([storeId, status])
@@index([storeBillId])
@@map("store_payout")
}
model StoreWithdrawRequest {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
withdrawNo String @unique @map("withdraw_no") @db.VarChar(32)
storeId BigInt @map("store_id") @db.UnsignedBigInt
storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
payoutCount Int @default(0) @map("payout_count")
status StoreWithdrawStatus @default(PENDING_REVIEW)
rejectReason String? @map("reject_reason") @db.VarChar(512)
appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3)
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
reviewedByHqId BigInt? @map("reviewed_by_hq_id") @db.UnsignedBigInt
paidAt DateTime? @map("paid_at") @db.DateTime(3)
paymentRef String? @map("payment_ref") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Restrict)
items StoreWithdrawPayoutItem[]
@@index([storeId, status])
@@index([status, appliedAt])
@@map("store_withdraw_request")
}
model StoreWithdrawPayoutItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
withdrawRequestId BigInt @map("withdraw_request_id") @db.UnsignedBigInt
storePayoutId BigInt @unique @map("store_payout_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
withdrawRequest StoreWithdrawRequest @relation(fields: [withdrawRequestId], references: [id], onDelete: Cascade)
storePayout StorePayout @relation(fields: [storePayoutId], references: [id], onDelete: Restrict)
@@index([withdrawRequestId])
@@map("store_withdraw_payout_item")
}
model WineryBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
@@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
/** 门店主账号专用(提现等资金操作) */
@Injectable()
export class ShopPrimaryGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'STORE') {
throw new ForbiddenException('仅门店主账号可操作');
}
const account = await this.prisma.storeAccount.findUnique({
where: { id: user.actorId },
select: { isPrimary: true },
});
if (!account || account.isPrimary !== 1) {
throw new ForbiddenException('仅门店主账号可申请提现');
}
return true;
}
}
@@ -61,6 +61,8 @@ export const HqOperationAction = {
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
STORE_WITHDRAW_APPROVE: 'STORE_WITHDRAW_APPROVE',
STORE_WITHDRAW_REJECT: 'STORE_WITHDRAW_REJECT',
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
@@ -164,6 +166,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
[HqOperationAction.STORE_WITHDRAW_APPROVE]: '门店提现审核通过',
[HqOperationAction.STORE_WITHDRAW_REJECT]: '门店提现驳回',
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
@@ -10,6 +10,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
{ key: 'winery_bank', label: '酒厂银行账户' },
{ key: 'finance', label: '财务结算' },
];
const G = {
@@ -21,6 +22,7 @@ const G = {
app: 'app',
deploy: 'deploy',
winery_bank: 'winery_bank',
finance: 'finance',
} as const;
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
@@ -187,6 +189,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
type: 'string',
requiresRestart: false,
},
{
key: 'STORE_WITHDRAW_DAILY_LIMIT',
label: '门店未出账提现单日上限(元)',
group: G.finance,
type: 'number',
requiresRestart: false,
description: 'FIN-002:单店单日提现上限,默认 5000',
placeholder: '5000',
},
];
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
@@ -7,6 +7,7 @@ import { AlertService } from '../common/alert/alert.service';
* 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/
@Injectable()
export class SettlementScheduler {
@@ -48,6 +49,34 @@ export class SettlementScheduler {
}
}
/** FIN-003:工作日 18:05 扫描超时未审门店提现 */
@Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' })
async handleWithdrawOverdueAlert() {
this.logger.log('Store withdraw overdue scan start');
try {
const summary = await this.settlementService.scanOverdueStoreWithdrawals();
this.logger.log(`Store withdraw overdue: ${JSON.stringify(summary)}`);
if (summary.overdueCount > 0) {
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现超时未审',
detail: `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
});
}
} catch (e) {
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
this.alert.notify({
level: 'P1',
category: 'job',
title: '门店提现超时扫描失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
});
}
}
@Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' })
async handleMonthlyPartnerBills() {
this.logger.log('Monthly partner bills job start');
@@ -23,6 +23,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service';
import {
HqPermissionGuard,
@@ -63,6 +64,7 @@ import { CommonModule } from '../common/common.module';
PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard,
ShopPrimaryGuard,
HqPermissionsResolver,
HqPermissionGuard,
],
@@ -80,6 +82,7 @@ import { CommonModule } from '../common/common.module';
PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard,
ShopPrimaryGuard,
HqPermissionsResolver,
HqPermissionGuard,
],
@@ -40,6 +40,21 @@ function num(v: Prisma.Decimal | number | string | null | undefined): number {
return typeof v === 'number' ? v : Number(v);
}
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
const day = appliedAt.getDay();
if (day === 0 || day === 6) return false;
const deadline = new Date(
appliedAt.getFullYear(),
appliedAt.getMonth(),
appliedAt.getDate(),
18,
0,
0,
0,
);
return now.getTime() > deadline.getTime();
}
@Injectable()
export class AdminDashboardService {
constructor(private readonly prisma: PrismaService) {}
@@ -63,6 +78,7 @@ export class AdminDashboardService {
pendingBills,
pendingPartnerDraftBills,
openTickets,
pendingWithdrawRows,
] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
this.prisma.user.count({
@@ -85,8 +101,18 @@ export class AdminDashboardService {
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
this.prisma.storeWithdrawRequest.findMany({
where: { status: 'PENDING_REVIEW' },
select: { appliedAt: true },
}),
]);
const now = new Date();
const pendingStoreWithdrawals = pendingWithdrawRows.length;
const overdueStoreWithdrawals = pendingWithdrawRows.filter((r) =>
isWithdrawOverdue(r.appliedAt, now),
).length;
return {
usersTotal,
guestUsers,
@@ -101,6 +127,8 @@ export class AdminDashboardService {
pendingBills,
pendingPartnerDraftBills,
openTickets,
pendingStoreWithdrawals,
overdueStoreWithdrawals,
ordersByStatus: ordersByStatus.map((row) => ({
status: row.status,
count: row._count.status,
@@ -357,6 +357,9 @@ export class AdminStoresService {
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.withdrawWhitelistEnabled !== undefined
? { withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled }
: {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
@@ -516,6 +519,7 @@ export class AdminStoresService {
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled,
withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
@@ -138,6 +138,11 @@ export class CreateStoreDto {
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** FIN-001:允许未出账手动提现 */
@IsOptional()
@IsBoolean()
withdrawWhitelistEnabled?: boolean;
}
export class UpdateStoreDto {
@@ -237,6 +242,11 @@ export class UpdateStoreDto {
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** FIN-001:允许未出账手动提现 */
@IsOptional()
@IsBoolean()
withdrawWhitelistEnabled?: boolean;
}
export class CreateStoreAccountDto {
@@ -5,6 +5,7 @@ import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
@@ -59,6 +60,98 @@ export class ShopPayoutController {
}
}
@Controller('shop/withdraw')
@UseGuards(JwtAuthGuard, ShopStoreGuard)
export class ShopWithdrawController {
constructor(private readonly settlementService: SettlementService) {}
@Get('summary')
summary(@CurrentUser() user: AuthUser) {
return this.settlementService.getShopWithdrawSummary(user.actorId, user.storeId!);
}
@Get('requests')
requests(
@CurrentUser() user: AuthUser,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('status') status?: string,
) {
return this.settlementService.listShopWithdrawRequests(user.actorId, user.storeId!, {
page: Number(page),
pageSize: Number(pageSize),
status,
});
}
@Post()
@UseGuards(ShopPrimaryGuard)
apply(@CurrentUser() user: AuthUser, @Body() body: { amount?: number }) {
return this.settlementService.createStoreWithdrawRequest(user.actorId, user.storeId!, body);
}
}
@Controller('admin/store-withdrawals')
@UseGuards(HqAuthGuard)
export class AdminStoreWithdrawController {
constructor(private readonly settlementService: SettlementService) {}
@Get('overdue-summary')
overdueSummary() {
return this.settlementService.getStoreWithdrawOverdueSummary();
}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminStoreWithdrawals({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminStoreWithdrawal(BigInt(id));
}
@Post(':id/approve')
@HqOperation({
action: HqOperationAction.STORE_WITHDRAW_APPROVE,
refType: 'STORE_WITHDRAW',
refIdParam: 'id',
includeBody: true,
})
approve(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { paymentRef?: string },
) {
return this.settlementService.approveStoreWithdraw(BigInt(id), user.actorId, body);
}
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.STORE_WITHDRAW_REJECT,
refType: 'STORE_WITHDRAW',
refIdParam: 'id',
includeBody: true,
})
reject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { reason?: string },
) {
if (!body?.reason?.trim()) {
throw new BadRequestException('请填写驳回理由');
}
return this.settlementService.rejectStoreWithdraw(BigInt(id), user.actorId, body.reason);
}
}
@Controller('admin/store-payouts')
@UseGuards(HqAuthGuard)
export class AdminStorePayoutController {
@@ -9,10 +9,12 @@ import {
AdminPartnerBillController,
AdminStoreBillController,
AdminStorePayoutController,
AdminStoreWithdrawController,
AdminWineryBillController,
PartnerMeController,
SettlementController,
ShopPayoutController,
ShopWithdrawController,
} from './settlement.controller';
@Module({
@@ -21,6 +23,8 @@ import {
SettlementController,
PartnerMeController,
ShopPayoutController,
ShopWithdrawController,
AdminStoreWithdrawController,
AdminStorePayoutController,
AdminStoreBillController,
AdminPartnerBillController,
@@ -1,7 +1,17 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { calcLogisticsFeeByBottles, type LogisticsPricingRule } from '@dukang/domain';
import {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
DEFAULT_XFX_LOGISTICS_PRICING,
WINERY_SETTLEMENT_RATE,
} from '@dukang/shared-types';
import {
calcLogisticsFeeByBottles,
pickPayoutsForWithdrawAmount,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
type LogisticsPricingRule,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
@@ -33,6 +43,28 @@ function round2(n: number) {
return Math.round(n * 100) / 100;
}
function getStoreWithdrawDailyLimit(): number {
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
}
/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
const day = appliedAt.getDay(); // 0 Sun … 6 Sat
if (day === 0 || day === 6) return false;
const deadline = new Date(
appliedAt.getFullYear(),
appliedAt.getMonth(),
appliedAt.getDate(),
18,
0,
0,
0,
);
return now.getTime() > deadline.getTime();
}
@Injectable()
export class SettlementService {
constructor(
@@ -99,6 +131,440 @@ export class SettlementService {
return serializeBigInt({ items, total, page, pageSize });
}
// ─── Store withdraw (未出账手动提现) ─────────────────
private async assertShopStoreAccess(storeAccountId: bigint, storeId: bigint) {
await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
});
}
private async listAvailableUnbilledPayouts(storeId: bigint) {
return this.prisma.storePayout.findMany({
where: {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
const start = startOfDay(now);
const end = new Date(start);
end.setDate(end.getDate() + 1);
const agg = await this.prisma.storeWithdrawRequest.aggregate({
where: {
storeId,
status: { in: ['PENDING_REVIEW', 'PAID'] },
appliedAt: { gte: start, lt: end },
},
_sum: { amount: true },
});
return Number(agg._sum.amount ?? 0);
}
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
await this.assertShopStoreAccess(storeAccountId, storeId);
const [store, account, available, pending, todayApplied] = await Promise.all([
this.prisma.store.findUniqueOrThrow({
where: { id: storeId },
select: { withdrawWhitelistEnabled: true },
}),
this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
select: {
isPrimary: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
}),
this.listAvailableUnbilledPayouts(storeId),
this.prisma.storeWithdrawRequest.findFirst({
where: { storeId, status: 'PENDING_REVIEW' },
select: { id: true, amount: true },
}),
this.todayWithdrawAppliedAmount(storeId),
]);
const availableAmount = sumUnbilledPayoutAmount(
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
);
const dailyLimit = getStoreWithdrawDailyLimit();
const hasBankAccount = !!(
account.bankAccountName?.trim() &&
account.bankAccountNo?.trim()
);
return {
availableAmount,
pendingReviewAmount: pending ? Number(pending.amount) : 0,
todayAppliedAmount: todayApplied,
dailyLimit,
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
whitelistEnabled: store.withdrawWhitelistEnabled,
isPrimary: account.isPrimary === 1,
hasBankAccount,
hasPendingRequest: !!pending,
bankAccount: {
bankAccountName: account.bankAccountName,
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
},
};
}
async createStoreWithdrawRequest(
storeAccountId: bigint,
storeId: bigint,
dto?: { amount?: number },
) {
await this.assertShopStoreAccess(storeAccountId, storeId);
const [store, account, available, pending, todayApplied] = await Promise.all([
this.prisma.store.findUniqueOrThrow({
where: { id: storeId },
select: { withdrawWhitelistEnabled: true },
}),
this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
select: {
isPrimary: true,
bankAccountName: true,
bankAccountNo: true,
},
}),
this.listAvailableUnbilledPayouts(storeId),
this.prisma.storeWithdrawRequest.findFirst({
where: { storeId, status: 'PENDING_REVIEW' },
select: { id: true },
}),
this.todayWithdrawAppliedAmount(storeId),
]);
if (account.isPrimary !== 1) {
throw new BadRequestException('仅门店主账号可申请提现');
}
const availableAmount = sumUnbilledPayoutAmount(
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
);
const dailyLimit = getStoreWithdrawDailyLimit();
const hasBankAccount = !!(
account.bankAccountName?.trim() &&
account.bankAccountNo?.trim()
);
const requestAmount =
dto?.amount != null && Number.isFinite(Number(dto.amount))
? round2(Number(dto.amount))
: availableAmount;
const guard = validateStoreWithdraw({
whitelistEnabled: store.withdrawWhitelistEnabled,
availableAmount,
requestAmount,
todayApplied,
dailyLimit,
hasPendingRequest: !!pending,
hasBankAccount,
});
if (!guard.ok) throw new BadRequestException(guard.message);
const picked = pickPayoutsForWithdrawAmount(
available.map((p) => ({ id: p.id, payoutAmount: Number(p.payoutAmount) })),
requestAmount,
);
if (!picked.ok) throw new BadRequestException(picked.message);
const created = await this.prisma.$transaction(async (tx) => {
const stillPending = await tx.storeWithdrawRequest.findFirst({
where: { storeId, status: 'PENDING_REVIEW' },
select: { id: true },
});
if (stillPending) {
throw new BadRequestException('已有待审核提现申请,请等待处理完成');
}
const payoutIds = picked.selected.map((p) => p.id);
const locked = await tx.storePayout.findMany({
where: {
id: { in: payoutIds },
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
},
select: { id: true, payoutAmount: true },
});
if (locked.length !== payoutIds.length) {
throw new BadRequestException('可提余额已变化,请刷新后重试');
}
const amount = round2(locked.reduce((s, p) => s + Number(p.payoutAmount), 0));
const req = await tx.storeWithdrawRequest.create({
data: {
withdrawNo: generateBillNo('SW'),
storeId,
storeAccountId,
amount,
payoutCount: locked.length,
status: 'PENDING_REVIEW',
},
});
await tx.storeWithdrawPayoutItem.createMany({
data: locked.map((p) => ({
withdrawRequestId: req.id,
storePayoutId: p.id,
})),
});
return req;
});
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
storeId,
eventName: 'store_withdraw_applied',
refType: 'STORE_WITHDRAW',
refId: created.id,
extraJson: {
amount: Number(created.amount),
payoutCount: created.payoutCount,
},
});
return serializeBigInt(created);
}
async listShopWithdrawRequests(
storeAccountId: bigint,
storeId: bigint,
query: { page?: number; pageSize?: number; status?: string },
) {
await this.assertShopStoreAccess(storeAccountId, storeId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreWithdrawRequestWhereInput = { storeId };
if (query.status) {
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
}
const [items, total] = await Promise.all([
this.prisma.storeWithdrawRequest.findMany({
where,
orderBy: { appliedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.storeWithdrawRequest.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async listAdminStoreWithdrawals(query: {
page?: number;
pageSize?: number;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreWithdrawRequestWhereInput = {};
if (query.status) {
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
}
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
where.appliedAt = {};
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
if (query.dateTo) {
const end = new Date(query.dateTo);
end.setHours(23, 59, 59, 999);
where.appliedAt.lte = end;
}
}
const [items, total, aggregates] = await Promise.all([
this.prisma.storeWithdrawRequest.findMany({
where,
orderBy: [{ appliedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
}),
this.prisma.storeWithdrawRequest.count({ where }),
this.prisma.storeWithdrawRequest.aggregate({
where,
_sum: { amount: true },
_count: true,
}),
]);
const now = new Date();
const mapped = items.map((row) => ({
...row,
overdue:
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt, now) : false,
}));
return serializeBigInt({
items: mapped,
total,
page,
pageSize,
summary: {
count: aggregates._count,
totalAmount: Number(aggregates._sum.amount ?? 0),
},
});
}
async getAdminStoreWithdrawal(id: bigint) {
const row = await this.prisma.storeWithdrawRequest.findUnique({
where: { id },
include: {
store: {
select: {
id: true,
name: true,
cityName: true,
phone: true,
withdrawWhitelistEnabled: true,
},
},
storeAccount: {
select: {
id: true,
name: true,
phone: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
items: {
include: {
storePayout: {
include: {
redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } },
},
},
},
},
},
});
if (!row) throw new NotFoundException('提现申请不存在');
return serializeBigInt({
...row,
overdue:
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
});
}
async approveStoreWithdraw(
id: bigint,
hqAccountId: bigint,
dto?: { paymentRef?: string },
) {
const row = await this.prisma.storeWithdrawRequest.findUnique({
where: { id },
include: { items: { select: { storePayoutId: true } } },
});
if (!row) throw new NotFoundException('提现申请不存在');
if (row.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待审核提现可审核通过');
}
const paidAt = new Date();
const updated = await this.prisma.$transaction(async (tx) => {
const req = await tx.storeWithdrawRequest.update({
where: { id },
data: {
status: 'PAID',
reviewedAt: paidAt,
reviewedByHqId: hqAccountId,
paidAt,
paymentRef: dto?.paymentRef?.trim() || null,
},
});
await tx.storePayout.updateMany({
where: {
id: { in: row.items.map((i) => i.storePayoutId) },
status: 'PENDING',
},
data: { status: 'PAID', paidAt },
});
return req;
});
this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', {
storeId: row.storeId,
eventName: 'store_withdraw_paid',
refType: 'STORE_WITHDRAW',
refId: id,
extraJson: {
amount: Number(row.amount),
paymentRef: dto?.paymentRef,
},
});
return serializeBigInt(updated);
}
async rejectStoreWithdraw(id: bigint, hqAccountId: bigint, reason: string) {
const row = await this.prisma.storeWithdrawRequest.findUnique({
where: { id },
});
if (!row) throw new NotFoundException('提现申请不存在');
if (row.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待审核提现可驳回');
}
const rejectReason = reason.trim();
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
const updated = await this.prisma.$transaction(async (tx) => {
const req = await tx.storeWithdrawRequest.update({
where: { id },
data: {
status: 'REJECTED',
rejectReason,
reviewedAt: new Date(),
reviewedByHqId: hqAccountId,
},
});
// 释放 payout 锁定,允许再次提现
await tx.storeWithdrawPayoutItem.deleteMany({ where: { withdrawRequestId: id } });
return req;
});
return serializeBigInt(updated);
}
async getStoreWithdrawOverdueSummary() {
const pending = await this.prisma.storeWithdrawRequest.findMany({
where: { status: 'PENDING_REVIEW' },
select: { id: true, appliedAt: true, amount: true },
});
const now = new Date();
const overdue = pending.filter((r) => isWithdrawOverdue(r.appliedAt, now));
return {
pendingCount: pending.length,
overdueCount: overdue.length,
overdueAmount: round2(overdue.reduce((s, r) => s + Number(r.amount), 0)),
pendingAmount: round2(pending.reduce((s, r) => s + Number(r.amount), 0)),
};
}
/** FIN-003:工作日 18:00 扫描超时未审提现 */
async scanOverdueStoreWithdrawals() {
const summary = await this.getStoreWithdrawOverdueSummary();
return summary;
}
async listAdminStorePayouts(query: {
page?: number;
pageSize?: number;
@@ -277,6 +743,9 @@ export class SettlementService {
where: {
storeBillId: null,
createdAt: { gte: start, lt: end },
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
withdrawItem: null,
status: 'PENDING',
},
include: { store: { select: { id: true, settlementRate: true } } },
});