diff --git a/backend/services/wallet-service/prisma/migrations/20260104000000_add_offline_settlement_deductions/migration.sql b/backend/services/wallet-service/prisma/migrations/20260104000000_add_offline_settlement_deductions/migration.sql new file mode 100644 index 00000000..dfaaa57a --- /dev/null +++ b/backend/services/wallet-service/prisma/migrations/20260104000000_add_offline_settlement_deductions/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "offline_settlement_deductions" ( + "deduction_id" BIGSERIAL NOT NULL, + "account_sequence" VARCHAR(20) NOT NULL, + "user_id" BIGINT NOT NULL, + "original_ledger_entry_id" BIGINT NOT NULL, + "original_amount" DECIMAL(20,8) NOT NULL, + "deducted_amount" DECIMAL(20,8) NOT NULL, + "deduction_ledger_entry_id" BIGINT NOT NULL, + "deduction_order_no" VARCHAR(50) NOT NULL, + "pending_action_id" VARCHAR(100), + "operator_id" VARCHAR(100) NOT NULL, + "memo" VARCHAR(500), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "offline_settlement_deductions_pkey" PRIMARY KEY ("deduction_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "offline_settlement_deductions_original_ledger_entry_id_key" ON "offline_settlement_deductions"("original_ledger_entry_id"); + +-- CreateIndex +CREATE INDEX "offline_settlement_deductions_account_sequence_idx" ON "offline_settlement_deductions"("account_sequence"); + +-- CreateIndex +CREATE INDEX "offline_settlement_deductions_user_id_idx" ON "offline_settlement_deductions"("user_id"); + +-- CreateIndex +CREATE INDEX "offline_settlement_deductions_pending_action_id_idx" ON "offline_settlement_deductions"("pending_action_id"); + +-- CreateIndex +CREATE INDEX "offline_settlement_deductions_deduction_order_no_idx" ON "offline_settlement_deductions"("deduction_order_no"); + +-- CreateIndex +CREATE INDEX "offline_settlement_deductions_created_at_idx" ON "offline_settlement_deductions"("created_at"); diff --git a/backend/services/wallet-service/prisma/schema.prisma b/backend/services/wallet-service/prisma/schema.prisma index 1164b38a..fa9b3a32 100644 --- a/backend/services/wallet-service/prisma/schema.prisma +++ b/backend/services/wallet-service/prisma/schema.prisma @@ -366,3 +366,48 @@ model ProcessedEvent { @@index([eventType]) @@index([processedAt]) } + +// ============================================ +// 线下结算扣减记录表 +// 记录哪些 REWARD_SETTLED 流水已被"线下结算"扣减过 +// 用于全额扣减模式:当管理员不输入金额时, +// 自动扣减用户所有未处理的结算收益 +// ============================================ +model OfflineSettlementDeduction { + id BigInt @id @default(autoincrement()) @map("deduction_id") + accountSequence String @map("account_sequence") @db.VarChar(20) + userId BigInt @map("user_id") + + // 关联的原结算流水ID (wallet_ledger_entries.entry_id) + originalLedgerEntryId BigInt @unique @map("original_ledger_entry_id") + + // 原结算金额 + originalAmount Decimal @map("original_amount") @db.Decimal(20, 8) + + // 实际扣减的金额 (通常与原金额相同) + deductedAmount Decimal @map("deducted_amount") @db.Decimal(20, 8) + + // 生成的扣减流水ID + deductionLedgerEntryId BigInt @map("deduction_ledger_entry_id") + + // 扣减订单号 + deductionOrderNo String @map("deduction_order_no") @db.VarChar(50) + + // 关联的待办操作ID + pendingActionId String? @map("pending_action_id") @db.VarChar(100) + + // 操作者 + operatorId String @map("operator_id") @db.VarChar(100) + + // 备注 + memo String? @map("memo") @db.VarChar(500) + + createdAt DateTime @default(now()) @map("created_at") + + @@map("offline_settlement_deductions") + @@index([accountSequence]) + @@index([userId]) + @@index([pendingActionId]) + @@index([deductionOrderNo]) + @@index([createdAt]) +} diff --git a/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts b/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts index e4cea227..05725725 100644 --- a/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts +++ b/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts @@ -301,4 +301,47 @@ export class InternalWalletController { this.logger.log(`特殊扣减结果: ${JSON.stringify(result)}`); return result; } + + // =============== 全额线下结算扣减 API =============== + + @Get('offline-settlement/unprocessed/:accountSequence') + @Public() + @ApiOperation({ summary: '查询未处理的结算收益记录(内部API) - 用于全额线下结算扣减' }) + @ApiParam({ name: 'accountSequence', description: '账户序列号' }) + @ApiResponse({ status: 200, description: '未处理的结算记录列表' }) + async getUnprocessedSettlements(@Param('accountSequence') accountSequence: string) { + this.logger.log(`========== offline-settlement/unprocessed 请求 ==========`); + this.logger.log(`accountSequence: ${accountSequence}`); + + const result = await this.walletService.getUnprocessedSettlements(accountSequence); + + this.logger.log(`查询结果: ${result.count} 条记录, 总金额: ${result.totalAmount}`); + return result; + } + + @Post('offline-settlement/execute') + @Public() + @ApiOperation({ summary: '执行全额线下结算扣减(内部API) - 扣减用户所有未处理的结算收益' }) + @ApiResponse({ status: 200, description: '扣减结果' }) + async executeOfflineSettlementDeduction( + @Body() dto: { + accountSequence: string; + pendingActionId?: string; + operatorId: string; + customMemo?: string; + }, + ) { + this.logger.log(`========== offline-settlement/execute 请求 ==========`); + this.logger.log(`请求参数: ${JSON.stringify(dto)}`); + + const result = await this.walletService.executeOfflineSettlementDeduction({ + accountSequence: dto.accountSequence, + pendingActionId: dto.pendingActionId, + operatorId: dto.operatorId, + customMemo: dto.customMemo, + }); + + this.logger.log(`全额线下结算扣减结果: ${JSON.stringify(result)}`); + return result; + } } diff --git a/backend/services/wallet-service/src/application/services/wallet-application.service.ts b/backend/services/wallet-service/src/application/services/wallet-application.service.ts index 9bb8a771..6949a303 100644 --- a/backend/services/wallet-service/src/application/services/wallet-application.service.ts +++ b/backend/services/wallet-service/src/application/services/wallet-application.service.ts @@ -2488,4 +2488,303 @@ export class WalletApplicationService { const random = Math.random().toString(36).substring(2, 8).toUpperCase(); return `SD${dateStr}${timeStr}${random}`; } + + // =============== 全额线下结算扣减 =============== + + /** + * 查询用户未处理的结算收益记录 + * 用于全额线下结算扣减模式:查询所有 REWARD_SETTLED 类型的流水, + * 排除已被线下结算扣减过的记录 + */ + async getUnprocessedSettlements(accountSequence: string): Promise<{ + entries: Array<{ + id: bigint; + amount: number; + createdAt: Date; + memo: string | null; + allocationType: string | null; + }>; + totalAmount: number; + count: number; + }> { + this.logger.log(`[getUnprocessedSettlements] 查询未处理结算记录: accountSequence=${accountSequence}`); + + // 查询所有 REWARD_SETTLED 类型的正向流水(入账) + const settledEntries = await this.prisma.ledgerEntry.findMany({ + where: { + accountSequence, + entryType: LedgerEntryType.REWARD_SETTLED, + amount: { gt: 0 }, // 只查询正向(入账)记录 + }, + orderBy: { createdAt: 'asc' }, + }); + + // 查询已被线下结算扣减过的流水ID + const processedEntryIds = await this.prisma.offlineSettlementDeduction.findMany({ + where: { accountSequence }, + select: { originalLedgerEntryId: true }, + }); + const processedIdSet = new Set(processedEntryIds.map(p => p.originalLedgerEntryId)); + + // 过滤出未处理的记录 + const unprocessedEntries = settledEntries.filter(entry => !processedIdSet.has(entry.id)); + + const Decimal = require('decimal.js').default; + let totalAmount = new Decimal(0); + const entries = unprocessedEntries.map(entry => { + const amount = new Decimal(entry.amount.toString()).toNumber(); + totalAmount = totalAmount.plus(amount); + // 从 payloadJson 中提取 allocationType + const payloadJson = entry.payloadJson as Record | null; + const allocationType = payloadJson?.allocationType as string | null; + return { + id: entry.id, + amount, + createdAt: entry.createdAt, + memo: entry.memo, + allocationType, + }; + }); + + this.logger.log(`[getUnprocessedSettlements] 找到 ${entries.length} 条未处理记录, 总金额: ${totalAmount.toNumber()}`); + + return { + entries, + totalAmount: totalAmount.toNumber(), + count: entries.length, + }; + } + + /** + * 执行全额线下结算扣减 + * 当 amount 为 0 或未提供时,自动扣减用户所有未处理的结算收益 + * 每笔结算记录生成一条扣减流水,并记录到 offline_settlement_deductions 表 + */ + async executeOfflineSettlementDeduction(command: { + accountSequence: string; + pendingActionId?: string; + operatorId: string; + customMemo?: string; // 自定义备注,如果不提供则使用默认备注 + }): Promise<{ + success: boolean; + totalDeducted: number; + deductionCount: number; + orderNos: string[]; + newBalance: number; + details: Array<{ + originalEntryId: string; + originalAmount: number; + deductedAmount: number; + orderNo: string; + }>; + }> { + this.logger.log(`[executeOfflineSettlementDeduction] 执行全额线下结算扣减: accountSequence=${command.accountSequence}`); + + // 1. 查询未处理的结算记录 + const unprocessed = await this.getUnprocessedSettlements(command.accountSequence); + + if (unprocessed.count === 0) { + this.logger.log(`[executeOfflineSettlementDeduction] 没有未处理的结算记录`); + // 查询当前余额返回 + const wallet = await this.walletRepo.findByAccountSequence(command.accountSequence); + return { + success: true, + totalDeducted: 0, + deductionCount: 0, + orderNos: [], + newBalance: wallet?.balances.usdt.available.value ?? 0, + details: [], + }; + } + + // 2. 查找钱包 + const wallet = await this.walletRepo.findByAccountSequence(command.accountSequence); + if (!wallet) { + throw new WalletNotFoundError(`accountSequence: ${command.accountSequence}`); + } + + const Decimal = require('decimal.js').default; + const totalToDeduct = new Decimal(unprocessed.totalAmount); + + // 3. 验证余额是否足够(这里不做检查,因为是全额扣减,可能用户已经花费了部分) + // 如果余额不足,扣减到0为止 + const currentAvailable = new Decimal(wallet.balances.usdt.available.value); + const actualDeductTotal = Decimal.min(totalToDeduct, currentAvailable); + + if (actualDeductTotal.lte(0)) { + this.logger.log(`[executeOfflineSettlementDeduction] 余额为0,无法扣减`); + return { + success: true, + totalDeducted: 0, + deductionCount: 0, + orderNos: [], + newBalance: 0, + details: [], + }; + } + + // 4. 生成主订单号(用于整批扣减) + const batchOrderNo = this.generateOfflineSettlementBatchOrderNo(); + const defaultMemo = '该收益已由用户与上级间线下完成现金收付'; + const memo = command.customMemo || defaultMemo; + + // 5. 在事务中执行所有扣减 + const details: Array<{ + originalEntryId: string; + originalAmount: number; + deductedAmount: number; + orderNo: string; + }> = []; + const orderNos: string[] = []; + let newBalance = 0; + let totalDeducted = new Decimal(0); + let remainingBalance = currentAvailable; + + await this.prisma.$transaction(async (tx) => { + // 获取钱包最新状态 + const walletRecord = await tx.walletAccount.findUnique({ + where: { accountSequence: command.accountSequence }, + }); + + if (!walletRecord) { + throw new Error(`Wallet not found: ${command.accountSequence}`); + } + + remainingBalance = new Decimal(walletRecord.usdtAvailable.toString()); + const currentVersion = walletRecord.version; + + // 逐笔处理每条结算记录 + for (let i = 0; i < unprocessed.entries.length; i++) { + const entry = unprocessed.entries[i]; + + // 计算本笔实际扣减金额(不超过剩余余额) + const entryAmount = new Decimal(entry.amount); + const actualDeduct = Decimal.min(entryAmount, remainingBalance); + + if (actualDeduct.lte(0)) { + this.logger.log(`[executeOfflineSettlementDeduction] 余额不足,跳过剩余记录`); + break; + } + + // 生成本笔扣减订单号 + const orderNo = `${batchOrderNo}-${String(i + 1).padStart(3, '0')}`; + orderNos.push(orderNo); + + // 更新剩余余额 + remainingBalance = remainingBalance.minus(actualDeduct); + totalDeducted = totalDeducted.plus(actualDeduct); + + // 构建扣减备注(包含原结算信息) + const allocationTypeName = this.getAllocationTypeName(entry.allocationType); + const deductionMemo = `线下结算扣减: ${memo} [原结算: ${allocationTypeName || '收益结算'}, 金额: ${entry.amount}, 时间: ${entry.createdAt.toISOString().slice(0, 10)}]`; + + // 创建扣减流水记录 + const ledgerEntry = await tx.ledgerEntry.create({ + data: { + accountSequence: command.accountSequence, + userId: walletRecord.userId, + entryType: LedgerEntryType.SPECIAL_DEDUCTION, + amount: actualDeduct.negated(), // 负数表示扣减 + assetType: 'USDT', + balanceAfter: remainingBalance, + refOrderId: orderNo, + memo: deductionMemo, + payloadJson: { + operatorId: command.operatorId, + pendingActionId: command.pendingActionId, + originalLedgerEntryId: entry.id.toString(), + originalAmount: entry.amount, + deductedAmount: actualDeduct.toNumber(), + reason: memo, + isOfflineSettlement: true, + batchOrderNo, + executedAt: new Date().toISOString(), + }, + }, + }); + + // 创建线下结算扣减记录(标记该结算已被处理) + await tx.offlineSettlementDeduction.create({ + data: { + accountSequence: command.accountSequence, + userId: walletRecord.userId, + originalLedgerEntryId: entry.id, + originalAmount: entry.amount, + deductedAmount: actualDeduct, + deductionLedgerEntryId: ledgerEntry.id, + deductionOrderNo: orderNo, + pendingActionId: command.pendingActionId, + operatorId: command.operatorId, + memo: deductionMemo, + }, + }); + + details.push({ + originalEntryId: entry.id.toString(), + originalAmount: entry.amount, + deductedAmount: actualDeduct.toNumber(), + orderNo, + }); + } + + // 更新钱包余额 + newBalance = remainingBalance.toNumber(); + const updateResult = await tx.walletAccount.updateMany({ + where: { + id: walletRecord.id, + version: currentVersion, + }, + data: { + usdtAvailable: remainingBalance, + version: currentVersion + 1, + updatedAt: new Date(), + }, + }); + + if (updateResult.count === 0) { + throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`); + } + + this.logger.log(`[executeOfflineSettlementDeduction] 成功扣减 ${details.length} 笔, 总金额: ${totalDeducted.toNumber()}, 新余额: ${newBalance}`); + }); + + // 6. 清除缓存 + await this.walletCacheService.invalidateWallet(wallet.userId.value); + + return { + success: true, + totalDeducted: totalDeducted.toNumber(), + deductionCount: details.length, + orderNos, + newBalance, + details, + }; + } + + /** + * 生成线下结算批次订单号 + */ + private generateOfflineSettlementBatchOrderNo(): string { + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10).replace(/-/g, ''); + const timeStr = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `OSD${dateStr}${timeStr}${random}`; + } + + /** + * 获取权益分配类型的中文名称 + */ + private getAllocationTypeName(allocationType: string | null | undefined): string | null { + if (!allocationType) return null; + const nameMap: Record = { + 'SHARE_RIGHT': '分享权益', + 'PROVINCE_AREA_RIGHT': '省区域权益', + 'PROVINCE_TEAM_RIGHT': '省团队权益', + 'CITY_AREA_RIGHT': '市区域权益', + 'CITY_TEAM_RIGHT': '市团队权益', + 'COMMUNITY_RIGHT': '社区权益', + }; + return nameMap[allocationType] || allocationType; + } } diff --git a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx index ef147e13..b4e43655 100644 --- a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx @@ -123,6 +123,7 @@ export default function PendingActionsPage() { const [specialDeductionForm, setSpecialDeductionForm] = useState({ amount: '', reason: '', + isOfflineSettlement: false, // 是否为全额线下结算模式 }); // 单个操作编辑时使用的表单 @@ -174,7 +175,7 @@ export default function PendingActionsPage() { actionParamsMap: {}, expiresAt: '', }); - setSpecialDeductionForm({ amount: '', reason: '' }); + setSpecialDeductionForm({ amount: '', reason: '', isOfflineSettlement: false }); setShowCreateModal(true); }; @@ -293,6 +294,20 @@ export default function PendingActionsPage() { for (const actionCode of formData.selectedActions) { // 特殊扣减有专用表单 if (actionCode === ACTION_CODES.SPECIAL_DEDUCTION) { + // 全额线下结算模式 + if (specialDeductionForm.isOfflineSettlement) { + // 全额模式下,金额留空或为0,reason 使用默认或自定义 + const reason = specialDeductionForm.reason.trim() || '该收益已由用户与上级间线下完成现金收付'; + actionParamsMap[actionCode] = { + amount: 0, // 0 表示全额扣减模式 + reason, + createdBy: 'admin', // TODO: 从登录状态获取 + isOfflineSettlement: true, + }; + continue; + } + + // 普通特殊扣减模式 - 需要输入金额 const amount = parseFloat(specialDeductionForm.amount); if (isNaN(amount) || amount <= 0) { toast.error('特殊扣减金额必须大于 0'); @@ -306,6 +321,7 @@ export default function PendingActionsPage() { amount, reason: specialDeductionForm.reason.trim(), createdBy: 'admin', // TODO: 从登录状态获取 + isOfflineSettlement: false, }; continue; } @@ -751,25 +767,71 @@ export default function PendingActionsPage() {
特殊扣减设置
-
-
- - setSpecialDeductionForm({ ...specialDeductionForm, amount: e.target.value })} - placeholder="请输入扣减金额" - min={0} - step={0.01} - /> + + {/* 扣减模式选择 */} +
+ +
+ +
+ + {/* 全额线下结算模式说明 */} + {specialDeductionForm.isOfflineSettlement && ( +
+
+
+ 全额线下结算模式 +

系统将自动查询该用户所有已结算到钱包的绿积分收益,逐笔扣减并标记为"线下已结算"。

+

适用场景:用户已在线下与上级完成现金收付,需要将系统中的收益记录清零。

+
+
+ )} + + {/* 指定金额模式 - 显示金额输入框 */} + {!specialDeductionForm.isOfflineSettlement && ( +
+
+ + setSpecialDeductionForm({ ...specialDeductionForm, amount: e.target.value })} + placeholder="请输入扣减金额" + min={0} + step={0.01} + /> +
+
+ )} +
- +