feat(wallet-service): add offline settlement deduction feature

Add new functionality for admins to automatically deduct all settled
earnings when creating special deductions with amount=0, marking
each record to prevent duplicate deductions.

- Add OfflineSettlementDeduction model to track deducted records
- Add API endpoints for querying unprocessed settlements and executing batch deduction
- Add mode selection UI in admin-web pending-actions
- Add offline settlement card display in mobile-app special deduction page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-04 06:56:39 -08:00
parent 46b68e8652
commit 251fee4f1e
10 changed files with 1114 additions and 53 deletions

View File

@ -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");

View File

@ -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])
}

View File

@ -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;
}
}

View File

@ -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<string, unknown> | 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<string, string> = {
'SHARE_RIGHT': '分享权益',
'PROVINCE_AREA_RIGHT': '省区域权益',
'PROVINCE_TEAM_RIGHT': '省团队权益',
'CITY_AREA_RIGHT': '市区域权益',
'CITY_TEAM_RIGHT': '市团队权益',
'COMMUNITY_RIGHT': '社区权益',
};
return nameMap[allocationType] || allocationType;
}
}

View File

@ -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) {
// 全额模式下金额留空或为0reason 使用默认或自定义
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() {
<div className={styles.pendingActions__specialDeductionTitle}>
</div>
<div className={styles.pendingActions__formRow}>
<div className={styles.pendingActions__formGroup}>
<label> (绿) *</label>
<input
type="number"
value={specialDeductionForm.amount}
onChange={(e) => setSpecialDeductionForm({ ...specialDeductionForm, amount: e.target.value })}
placeholder="请输入扣减金额"
min={0}
step={0.01}
/>
{/* 扣减模式选择 */}
<div className={styles.pendingActions__formGroup}>
<label></label>
<div className={styles.pendingActions__radioGroup}>
<label className={styles.pendingActions__radioItem}>
<input
type="radio"
name="deductionMode"
checked={!specialDeductionForm.isOfflineSettlement}
onChange={() => setSpecialDeductionForm({ ...specialDeductionForm, isOfflineSettlement: false })}
/>
<span></span>
</label>
<label className={styles.pendingActions__radioItem}>
<input
type="radio"
name="deductionMode"
checked={specialDeductionForm.isOfflineSettlement}
onChange={() => setSpecialDeductionForm({ ...specialDeductionForm, isOfflineSettlement: true, amount: '' })}
/>
<span>线</span>
</label>
</div>
</div>
{/* 全额线下结算模式说明 */}
{specialDeductionForm.isOfflineSettlement && (
<div className={styles.pendingActions__modeHint}>
<div className={styles.pendingActions__modeHintIcon}>&#9432;</div>
<div className={styles.pendingActions__modeHintText}>
<strong>线</strong>
<p>绿"线下已结算"</p>
<p>线</p>
</div>
</div>
)}
{/* 指定金额模式 - 显示金额输入框 */}
{!specialDeductionForm.isOfflineSettlement && (
<div className={styles.pendingActions__formRow}>
<div className={styles.pendingActions__formGroup}>
<label> (绿) *</label>
<input
type="number"
value={specialDeductionForm.amount}
onChange={(e) => setSpecialDeductionForm({ ...specialDeductionForm, amount: e.target.value })}
placeholder="请输入扣减金额"
min={0}
step={0.01}
/>
</div>
</div>
)}
<div className={styles.pendingActions__formGroup}>
<label>/ *</label>
<label>
{specialDeductionForm.isOfflineSettlement ? '扣减原因/备注 (可选,留空使用默认)' : '扣减原因/备注 *'}
</label>
<textarea
value={specialDeductionForm.reason}
onChange={(e) => setSpecialDeductionForm({ ...specialDeductionForm, reason: e.target.value })}
placeholder="请详细说明扣减原因,用户会在确认页面看到此信息"
placeholder={specialDeductionForm.isOfflineSettlement
? '默认备注:该收益已由用户与上级间线下完成现金收付'
: '请详细说明扣减原因,用户会在确认页面看到此信息'}
rows={3}
/>
<span className={styles.pendingActions__formHint}>

View File

@ -445,4 +445,89 @@
content: '⚠️';
}
}
// 单选按钮组
&__radioGroup {
display: flex;
gap: 16px;
}
&__radioItem {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid $border-color;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
input[type='radio'] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: $primary-color;
}
span {
font-size: 14px;
color: $text-primary;
}
&:hover {
border-color: $primary-color;
background: #f0f5ff;
}
&:has(input:checked) {
border-color: $primary-color;
background: #e6f4ff;
span {
color: $primary-color;
font-weight: 500;
}
}
}
// 全额线下结算模式提示
&__modeHint {
display: flex;
gap: 12px;
padding: 12px 16px;
background: #e6f4ff;
border: 1px solid #91caff;
border-radius: 8px;
margin-bottom: 12px;
}
&__modeHintIcon {
flex-shrink: 0;
font-size: 18px;
color: #1677ff;
line-height: 1.4;
}
&__modeHintText {
flex: 1;
strong {
display: block;
font-size: 14px;
font-weight: 600;
color: #1677ff;
margin-bottom: 6px;
}
p {
margin: 0 0 4px 0;
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
}
}
}

View File

@ -116,9 +116,20 @@ export const ACTION_CODE_OPTIONS = [
*
*/
export interface SpecialDeductionParams {
amount: number; // 扣减金额
amount: number; // 扣减金额 (0 表示全额线下结算模式)
reason: string; // 扣减原因/备注
createdBy: string; // 创建人
isOfflineSettlement?: boolean; // 是否为全额线下结算模式
}
/**
* 线
* amount 0
*/
export interface OfflineSettlementDeductionParams {
isOfflineSettlement: true; // 标记为全额线下结算模式
reason: string; // 扣减原因/备注 (默认: "该收益已由用户与上级间线下完成现金收付")
createdBy: string; // 创建人
}
/**

View File

@ -951,6 +951,184 @@ class WalletService {
rethrow;
}
}
///
///
/// GET /wallets/offline-settlement/unprocessed/:accountSequence
/// 线
Future<UnprocessedSettlementsResult> getUnprocessedSettlements({
required String accountSequence,
}) async {
try {
debugPrint('[WalletService] ========== 查询未处理结算记录 ==========');
debugPrint('[WalletService] accountSequence: $accountSequence');
final response = await _apiClient.get(
'/wallets/offline-settlement/unprocessed/$accountSequence',
);
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
debugPrint('[WalletService] 响应数据: ${response.data}');
if (response.statusCode == 200) {
final responseData = response.data as Map<String, dynamic>;
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
final result = UnprocessedSettlementsResult.fromJson(data);
debugPrint('[WalletService] 查询成功: ${result.count} 条记录, 总金额: ${result.totalAmount}');
debugPrint('[WalletService] ================================');
return result;
}
throw Exception('查询失败: ${response.statusCode}');
} catch (e, stackTrace) {
debugPrint('[WalletService] !!!!!!!!!! 查询未处理结算记录异常 !!!!!!!!!!');
debugPrint('[WalletService] 错误: $e');
debugPrint('[WalletService] 堆栈: $stackTrace');
rethrow;
}
}
/// 线
///
/// POST /wallets/offline-settlement/execute
///
Future<OfflineSettlementDeductionResult> executeOfflineSettlementDeduction({
required String accountSequence,
required String pendingActionId,
required String operatorId,
String? customMemo,
}) async {
try {
debugPrint('[WalletService] ========== 执行全额线下结算扣减 ==========');
debugPrint('[WalletService] accountSequence: $accountSequence');
debugPrint('[WalletService] pendingActionId: $pendingActionId');
debugPrint('[WalletService] operatorId: $operatorId');
debugPrint('[WalletService] customMemo: $customMemo');
final response = await _apiClient.post(
'/wallets/offline-settlement/execute',
data: {
'accountSequence': accountSequence,
'pendingActionId': pendingActionId,
'operatorId': operatorId,
if (customMemo != null && customMemo.isNotEmpty) 'customMemo': customMemo,
},
);
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
debugPrint('[WalletService] 响应数据: ${response.data}');
if (response.statusCode == 200 || response.statusCode == 201) {
final responseData = response.data as Map<String, dynamic>;
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
final result = OfflineSettlementDeductionResult.fromJson(data);
debugPrint('[WalletService] 全额扣减成功: ${result.deductionCount} 笔, 总金额: ${result.totalDeducted}');
debugPrint('[WalletService] ================================');
return result;
}
throw Exception('全额扣减失败: ${response.statusCode}');
} catch (e, stackTrace) {
debugPrint('[WalletService] !!!!!!!!!! 全额线下结算扣减异常 !!!!!!!!!!');
debugPrint('[WalletService] 错误: $e');
debugPrint('[WalletService] 堆栈: $stackTrace');
rethrow;
}
}
}
///
class UnprocessedSettlementsResult {
final List<UnprocessedSettlementEntry> entries;
final double totalAmount;
final int count;
UnprocessedSettlementsResult({
required this.entries,
required this.totalAmount,
required this.count,
});
factory UnprocessedSettlementsResult.fromJson(Map<String, dynamic> json) {
final entriesList = json['entries'] as List<dynamic>? ?? [];
return UnprocessedSettlementsResult(
entries: entriesList
.map((e) => UnprocessedSettlementEntry.fromJson(e as Map<String, dynamic>))
.toList(),
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
count: json['count'] ?? 0,
);
}
}
///
class UnprocessedSettlementEntry {
final String id;
final double amount;
final DateTime createdAt;
final String? memo;
final String? allocationType;
UnprocessedSettlementEntry({
required this.id,
required this.amount,
required this.createdAt,
this.memo,
this.allocationType,
});
factory UnprocessedSettlementEntry.fromJson(Map<String, dynamic> json) {
return UnprocessedSettlementEntry(
id: json['id']?.toString() ?? '',
amount: (json['amount'] ?? 0).toDouble(),
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
: DateTime.now(),
memo: json['memo'],
allocationType: json['allocationType'],
);
}
///
String get allocationTypeName {
const nameMap = {
'SHARE_RIGHT': '分享权益',
'PROVINCE_AREA_RIGHT': '省区域权益',
'PROVINCE_TEAM_RIGHT': '省团队权益',
'CITY_AREA_RIGHT': '市区域权益',
'CITY_TEAM_RIGHT': '市团队权益',
'COMMUNITY_RIGHT': '社区权益',
};
return nameMap[allocationType] ?? allocationType ?? '收益结算';
}
}
/// 线
class OfflineSettlementDeductionResult {
final bool success;
final double totalDeducted;
final int deductionCount;
final List<String> orderNos;
final double newBalance;
OfflineSettlementDeductionResult({
required this.success,
required this.totalDeducted,
required this.deductionCount,
required this.orderNos,
required this.newBalance,
});
factory OfflineSettlementDeductionResult.fromJson(Map<String, dynamic> json) {
final orderNosList = json['orderNos'] as List<dynamic>? ?? [];
return OfflineSettlementDeductionResult(
success: json['success'] as bool? ?? false,
totalDeducted: (json['totalDeducted'] ?? 0).toDouble(),
deductionCount: json['deductionCount'] ?? 0,
orderNos: orderNosList.map((e) => e.toString()).toList(),
newBalance: (json['newBalance'] ?? 0).toDouble(),
);
}
}
///

View File

@ -174,10 +174,13 @@ class _PendingActionsPageState extends ConsumerState<PendingActionsPage> {
//
final amount = (action.actionParams?['amount'] ?? 0).toDouble();
final reason = action.actionParams?['reason'] as String? ?? '管理员扣减';
final isOfflineSettlement = action.actionParams?['isOfflineSettlement'] == true;
if (amount <= 0) {
debugPrint('[PendingActionsPage] 特殊扣减金额无效: $amount');
return true; //
// 线amount 0 isOfflineSettlement true
// amount > 0
if (amount <= 0 && !isOfflineSettlement) {
debugPrint('[PendingActionsPage] 特殊扣减金额无效且非线下结算模式: $amount');
return true; //
}
final result = await Navigator.of(context).push<bool>(
@ -187,6 +190,7 @@ class _PendingActionsPageState extends ConsumerState<PendingActionsPage> {
pendingActionId: action.id.toString(),
amount: amount,
reason: reason,
isOfflineSettlement: isOfflineSettlement,
),
),
),

View File

@ -8,11 +8,13 @@ class SpecialDeductionParams {
final String pendingActionId;
final double amount;
final String reason;
final bool isOfflineSettlement; // 线
SpecialDeductionParams({
required this.pendingActionId,
required this.amount,
required this.reason,
this.isOfflineSettlement = false, //
});
}
@ -35,14 +37,18 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
bool _isExecuting = false;
String? _errorMessage;
WalletResponse? _walletInfo;
UnprocessedSettlementsResult? _unprocessedSettlements; //
// 线
bool get _isOfflineSettlementMode => widget.params.isOfflineSettlement || widget.params.amount <= 0;
@override
void initState() {
super.initState();
_loadWalletInfo();
_loadData();
}
Future<void> _loadWalletInfo() async {
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
@ -50,15 +56,31 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
try {
final walletService = ref.read(walletServiceProvider);
final accountService = ref.read(accountServiceProvider);
//
final wallet = await walletService.getMyWallet();
//
UnprocessedSettlementsResult? unprocessed;
if (_isOfflineSettlementMode) {
final accountSequence = await accountService.getUserSerialNum();
if (accountSequence != null && accountSequence.isNotEmpty) {
unprocessed = await walletService.getUnprocessedSettlements(
accountSequence: accountSequence,
);
}
}
setState(() {
_walletInfo = wallet;
_unprocessedSettlements = unprocessed;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = '加载钱包信息失败: $e';
_errorMessage = '加载数据失败: $e';
});
}
}
@ -66,15 +88,18 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
Future<void> _executeDeduction() async {
if (_walletInfo == null) return;
//
if (_walletInfo!.balances.usdt.available < widget.params.amount) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('余额不足,无法执行扣减'),
backgroundColor: Colors.red,
),
);
return;
// 0
if (!_isOfflineSettlementMode) {
//
if (_walletInfo!.balances.usdt.available < widget.params.amount) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('余额不足,无法执行扣减'),
backgroundColor: Colors.red,
),
);
return;
}
}
setState(() {
@ -91,25 +116,55 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
}
final walletService = ref.read(walletServiceProvider);
final result = await walletService.executeSpecialDeduction(
accountSequence: accountSequence,
pendingActionId: widget.params.pendingActionId,
amount: widget.params.amount,
reason: widget.params.reason,
);
if (result.success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('扣减成功,新余额: ${result.newBalance.toStringAsFixed(2)} 绿积分'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true);
if (_isOfflineSettlementMode) {
// 线
final result = await walletService.executeOfflineSettlementDeduction(
accountSequence: accountSequence,
pendingActionId: widget.params.pendingActionId,
operatorId: 'user:$accountSequence',
customMemo: widget.params.reason.isNotEmpty ? widget.params.reason : null,
);
if (result.success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.deductionCount > 0
? '线下结算完成,共扣减 ${result.deductionCount} 笔,总计 ${result.totalDeducted.toStringAsFixed(2)} 绿积分'
: '没有需要扣减的结算记录',
),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true);
}
} else {
throw Exception('扣减失败');
}
} else {
throw Exception('扣减失败');
//
final result = await walletService.executeSpecialDeduction(
accountSequence: accountSequence,
pendingActionId: widget.params.pendingActionId,
amount: widget.params.amount,
reason: widget.params.reason,
);
if (result.success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('扣减成功,新余额: ${result.newBalance.toStringAsFixed(2)} 绿积分'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true);
}
} else {
throw Exception('扣减失败');
}
}
} catch (e) {
setState(() {
@ -212,7 +267,7 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loadWalletInfo,
onPressed: _loadData,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFD4AF37),
foregroundColor: Colors.white,
@ -246,6 +301,12 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
}
Widget _buildDeductionInfoCard() {
//
if (_isOfflineSettlementMode) {
return _buildOfflineSettlementCard();
}
//
return Container(
decoration: BoxDecoration(
color: Colors.white,
@ -334,10 +395,248 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
);
}
/// 线
Widget _buildOfflineSettlementCard() {
final settlements = _unprocessedSettlements;
final totalAmount = settlements?.totalAmount ?? 0;
final count = settlements?.count ?? 0;
final reason = widget.params.reason.isNotEmpty
? widget.params.reason
: '该收益已由用户与上级间线下完成现金收付';
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFE6F4FF),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.sync_alt,
color: Color(0xFF1677FF),
size: 24,
),
),
const SizedBox(width: 12),
const Text(
'线下结算扣减',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
const SizedBox(height: 16),
//
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFE6F4FF),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF91CAFF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'此操作将扣减您所有已结算的收益',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1677FF),
),
),
const SizedBox(height: 4),
Text(
reason,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.5,
),
),
],
),
),
const SizedBox(height: 20),
//
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF7E6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD591)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'待扣减笔数',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
Text(
'$count',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'待扣减总额',
style: TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
Text(
'${totalAmount.toStringAsFixed(2)} 绿积分',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFE53935),
),
),
],
),
],
),
),
//
if (settlements != null && settlements.entries.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'结算记录明细',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: settlements.entries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final entry = settlements.entries[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.allocationTypeName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 2),
Text(
entry.createdAt.toString().substring(0, 10),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
],
),
),
Text(
'${entry.amount.toStringAsFixed(2)} 绿积分',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFFE53935),
),
),
],
),
);
},
),
),
],
//
if (settlements == null || settlements.entries.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'暂无待扣减的结算记录',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
),
],
),
),
);
}
Widget _buildBalanceCard() {
final available = _walletInfo?.balances.usdt.available ?? 0;
final afterDeduction = available - widget.params.amount;
final isInsufficient = afterDeduction < 0;
//
final deductionAmount = _isOfflineSettlementMode
? (_unprocessedSettlements?.totalAmount ?? 0).clamp(0, available)
: widget.params.amount;
final afterDeduction = available - deductionAmount;
final isInsufficient = !_isOfflineSettlementMode && afterDeduction < 0;
return Container(
decoration: BoxDecoration(
@ -379,15 +678,15 @@ class _SpecialDeductionPageState extends ConsumerState<SpecialDeductionPage> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'扣减金额',
style: TextStyle(
Text(
_isOfflineSettlementMode ? '预计扣减' : '扣减金额',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
),
),
Text(
'-${widget.params.amount.toStringAsFixed(2)} 绿积分',
'-${deductionAmount.toStringAsFixed(2)} 绿积分',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,