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:
parent
46b68e8652
commit
251fee4f1e
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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}>ⓘ</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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; // 创建人
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 特殊扣减结果
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue