feat(settlement): 门店未出账手动提现与总部审核(OPT-010)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 } } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user