feat(contribution): display distribution details with actual amounts
Changes:
1. contribution-service:
- Add distributionSummary field to SyncedAdoption schema
- Store distribution summary after contribution calculation
2. mining-admin-service:
- Add distributionSummary field to SyncedAdoption schema
- Calculate actual distribution from contribution_records table
- Return distribution details in planting ledger API
3. mining-admin-web:
- Display distribution details in planting ledger table
- Show: 70%(amount) personal, 12%(amount) operation,
1%(amount) province, 2%(amount) city, 15%(amount) team
- Show team distribution breakdown (distributed vs unallocated)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d97daa524
commit
23dabb0219
|
|
@ -58,6 +58,10 @@ model SyncedAdoption {
|
|||
contributionDistributed Boolean @default(false) @map("contribution_distributed")
|
||||
contributionDistributedAt DateTime? @map("contribution_distributed_at")
|
||||
|
||||
// 分配明细摘要 (JSON 字符串格式)
|
||||
// 格式: { personal: {rate, amount}, operation: {rate, amount}, province: {rate, amount}, city: {rate, amount}, team: {rate, amount, distributed, unallocated} }
|
||||
distributionSummary String? @map("distribution_summary") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([accountSequence])
|
||||
|
|
|
|||
|
|
@ -75,8 +75,14 @@ export class ContributionCalculationService {
|
|||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
await this.saveDistributionResult(result, adoption.originalAdoptionId, adoption.accountSequence);
|
||||
|
||||
// 标记认种已处理
|
||||
await this.syncedDataRepository.markAdoptionContributionDistributed(adoption.originalAdoptionId);
|
||||
// 构建分配摘要
|
||||
const distributionSummary = this.buildDistributionSummary(adoption, result);
|
||||
|
||||
// 标记认种已处理,同时保存分配摘要
|
||||
await this.syncedDataRepository.markAdoptionContributionDistributed(
|
||||
adoption.originalAdoptionId,
|
||||
JSON.stringify(distributionSummary),
|
||||
);
|
||||
|
||||
// 更新认种人的解锁状态(如果是首次认种)
|
||||
await this.updateAdopterUnlockStatus(adoption.accountSequence);
|
||||
|
|
@ -322,4 +328,69 @@ export class ContributionCalculationService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分配摘要
|
||||
*/
|
||||
private buildDistributionSummary(
|
||||
adoption: { treeCount: number; contributionPerTree: { toString: () => string } },
|
||||
result: ContributionDistributionResult,
|
||||
): object {
|
||||
const totalContribution = adoption.treeCount * Number(adoption.contributionPerTree.toString());
|
||||
|
||||
// 计算团队分配的实际金额
|
||||
let teamDistributed = 0;
|
||||
let teamUnallocated = 0;
|
||||
|
||||
// TEAM_LEVEL 分配
|
||||
for (const record of result.teamLevelRecords) {
|
||||
teamDistributed += Number(record.amount.value.toString());
|
||||
}
|
||||
|
||||
// TEAM_BONUS 分配
|
||||
for (const record of result.teamBonusRecords) {
|
||||
teamDistributed += Number(record.amount.value.toString());
|
||||
}
|
||||
|
||||
// 未分配的团队算力(归总部)
|
||||
for (const unalloc of result.unallocatedContributions) {
|
||||
teamUnallocated += Number(unalloc.amount.value.toString());
|
||||
}
|
||||
|
||||
// 系统账户分配
|
||||
const operationAmount = result.systemContributions.find(s => s.accountType === 'OPERATION')?.amount.value.toString() || '0';
|
||||
const provinceAmount = result.systemContributions.find(s => s.accountType === 'PROVINCE')?.amount.value.toString() || '0';
|
||||
const cityAmount = result.systemContributions.find(s => s.accountType === 'CITY')?.amount.value.toString() || '0';
|
||||
|
||||
return {
|
||||
totalContribution: totalContribution.toFixed(4),
|
||||
personal: {
|
||||
rate: 0.70,
|
||||
amount: result.personalRecord.amount.value.toString(),
|
||||
label: '认种人账户',
|
||||
},
|
||||
operation: {
|
||||
rate: 0.12,
|
||||
amount: operationAmount,
|
||||
label: '运营账户',
|
||||
},
|
||||
province: {
|
||||
rate: 0.01,
|
||||
amount: provinceAmount,
|
||||
label: '省公司账户',
|
||||
},
|
||||
city: {
|
||||
rate: 0.02,
|
||||
amount: cityAmount,
|
||||
label: '市公司账户',
|
||||
},
|
||||
team: {
|
||||
rate: 0.15,
|
||||
amount: (totalContribution * 0.15).toFixed(4),
|
||||
distributed: teamDistributed.toFixed(4),
|
||||
unallocated: teamUnallocated.toFixed(4),
|
||||
label: '团队分配',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,9 +118,13 @@ export interface ISyncedDataRepository {
|
|||
findAdoptionsByAccountSequence(accountSequence: string): Promise<SyncedAdoption[]>;
|
||||
|
||||
/**
|
||||
* 标记认种贡献值已分配
|
||||
* 标记认种贡献值已分配,同时保存分配摘要
|
||||
*/
|
||||
markAdoptionContributionDistributed(originalAdoptionId: bigint, tx?: any): Promise<void>;
|
||||
markAdoptionContributionDistributed(
|
||||
originalAdoptionId: bigint,
|
||||
distributionSummary?: string,
|
||||
tx?: any,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取账户的总认种棵数
|
||||
|
|
|
|||
|
|
@ -153,13 +153,18 @@ export class SyncedDataRepository implements ISyncedDataRepository {
|
|||
return records.map((r) => this.toSyncedAdoption(r));
|
||||
}
|
||||
|
||||
async markAdoptionContributionDistributed(originalAdoptionId: bigint, tx?: any): Promise<void> {
|
||||
async markAdoptionContributionDistributed(
|
||||
originalAdoptionId: bigint,
|
||||
distributionSummary?: string,
|
||||
tx?: any,
|
||||
): Promise<void> {
|
||||
const client = tx ?? this.client;
|
||||
await client.syncedAdoption.update({
|
||||
where: { originalAdoptionId },
|
||||
data: {
|
||||
contributionDistributed: true,
|
||||
contributionDistributedAt: new Date(),
|
||||
distributionSummary: distributionSummary ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ model SyncedAdoption {
|
|||
adoptionDate DateTime @db.Date
|
||||
status String? // 认种状态
|
||||
contributionPerTree Decimal @db.Decimal(20, 10)
|
||||
distributionSummary String? @db.Text // 分配明细摘要 (JSON格式)
|
||||
syncedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
|
|
|||
|
|
@ -768,16 +768,55 @@ export class UsersService {
|
|||
totalAmount += a.treeCount * Number(a.contributionPerTree);
|
||||
}
|
||||
|
||||
// 格式化认种记录
|
||||
const items = adoptions.map((a) => ({
|
||||
id: a.id,
|
||||
originalAdoptionId: a.originalAdoptionId.toString(),
|
||||
treeCount: a.treeCount,
|
||||
adoptionDate: a.adoptionDate,
|
||||
status: a.status || 'ACTIVE',
|
||||
contributionPerTree: a.contributionPerTree.toString(),
|
||||
totalContribution: (a.treeCount * Number(a.contributionPerTree)).toString(),
|
||||
}));
|
||||
// 获取每笔认种的实际分配明细(从 contribution_records 统计)
|
||||
const adoptionIds = adoptions.map((a) => a.originalAdoptionId);
|
||||
const distributionByAdoption = await this.getDistributionByAdoptions(adoptionIds);
|
||||
|
||||
// 格式化认种记录,包含分配明细
|
||||
const items = adoptions.map((a) => {
|
||||
const totalContribution = a.treeCount * Number(a.contributionPerTree);
|
||||
const distribution = distributionByAdoption.get(a.originalAdoptionId.toString());
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
originalAdoptionId: a.originalAdoptionId.toString(),
|
||||
treeCount: a.treeCount,
|
||||
adoptionDate: a.adoptionDate,
|
||||
status: a.status || 'ACTIVE',
|
||||
contributionPerTree: a.contributionPerTree.toString(),
|
||||
totalContribution: totalContribution.toString(),
|
||||
// 分配明细
|
||||
distribution: distribution || {
|
||||
personal: {
|
||||
rate: 0.70,
|
||||
amount: (totalContribution * 0.70).toFixed(4),
|
||||
label: '认种人账户',
|
||||
},
|
||||
operation: {
|
||||
rate: 0.12,
|
||||
amount: (totalContribution * 0.12).toFixed(4),
|
||||
label: '运营账户',
|
||||
},
|
||||
province: {
|
||||
rate: 0.01,
|
||||
amount: (totalContribution * 0.01).toFixed(4),
|
||||
label: '省公司账户',
|
||||
},
|
||||
city: {
|
||||
rate: 0.02,
|
||||
amount: (totalContribution * 0.02).toFixed(4),
|
||||
label: '市公司账户',
|
||||
},
|
||||
team: {
|
||||
rate: 0.15,
|
||||
amount: (totalContribution * 0.15).toFixed(4),
|
||||
distributed: '0',
|
||||
unallocated: (totalContribution * 0.15).toFixed(4),
|
||||
label: '团队分配',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
summary: {
|
||||
|
|
@ -972,4 +1011,102 @@ export class UsersService {
|
|||
if (!phone || phone.length < 7) return phone;
|
||||
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认种的实际分配明细
|
||||
* 从 contribution_records 表统计每笔认种的实际分配
|
||||
*/
|
||||
private async getDistributionByAdoptions(
|
||||
adoptionIds: bigint[],
|
||||
): Promise<Map<string, any>> {
|
||||
const result = new Map<string, any>();
|
||||
|
||||
if (adoptionIds.length === 0) return result;
|
||||
|
||||
// 查询这些认种产生的所有算力记录
|
||||
const records = await this.prisma.syncedContributionRecord.findMany({
|
||||
where: { sourceAdoptionId: { in: adoptionIds } },
|
||||
select: {
|
||||
sourceAdoptionId: true,
|
||||
sourceType: true,
|
||||
amount: true,
|
||||
baseContribution: true,
|
||||
treeCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 按认种ID分组统计
|
||||
const byAdoption = new Map<string, {
|
||||
personalAmount: number;
|
||||
teamLevelAmount: number;
|
||||
teamBonusAmount: number;
|
||||
totalContribution: number;
|
||||
}>();
|
||||
|
||||
for (const record of records) {
|
||||
const adoptionId = record.sourceAdoptionId.toString();
|
||||
if (!byAdoption.has(adoptionId)) {
|
||||
byAdoption.set(adoptionId, {
|
||||
personalAmount: 0,
|
||||
teamLevelAmount: 0,
|
||||
teamBonusAmount: 0,
|
||||
totalContribution: record.treeCount * Number(record.baseContribution),
|
||||
});
|
||||
}
|
||||
const stats = byAdoption.get(adoptionId)!;
|
||||
|
||||
const amount = Number(record.amount);
|
||||
switch (record.sourceType) {
|
||||
case 'PERSONAL':
|
||||
stats.personalAmount += amount;
|
||||
break;
|
||||
case 'TEAM_LEVEL':
|
||||
stats.teamLevelAmount += amount;
|
||||
break;
|
||||
case 'TEAM_BONUS':
|
||||
stats.teamBonusAmount += amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建分配明细
|
||||
for (const [adoptionId, stats] of byAdoption) {
|
||||
const totalContribution = stats.totalContribution;
|
||||
const teamTotal = totalContribution * 0.15;
|
||||
const teamDistributed = stats.teamLevelAmount + stats.teamBonusAmount;
|
||||
const teamUnallocated = teamTotal - teamDistributed;
|
||||
|
||||
result.set(adoptionId, {
|
||||
personal: {
|
||||
rate: 0.70,
|
||||
amount: stats.personalAmount.toFixed(4),
|
||||
label: '认种人账户',
|
||||
},
|
||||
operation: {
|
||||
rate: 0.12,
|
||||
amount: (totalContribution * 0.12).toFixed(4),
|
||||
label: '运营账户',
|
||||
},
|
||||
province: {
|
||||
rate: 0.01,
|
||||
amount: (totalContribution * 0.01).toFixed(4),
|
||||
label: '省公司账户',
|
||||
},
|
||||
city: {
|
||||
rate: 0.02,
|
||||
amount: (totalContribution * 0.02).toFixed(4),
|
||||
label: '市公司账户',
|
||||
},
|
||||
team: {
|
||||
rate: 0.15,
|
||||
amount: teamTotal.toFixed(4),
|
||||
distributed: teamDistributed.toFixed(4),
|
||||
unallocated: Math.max(0, teamUnallocated).toFixed(4),
|
||||
label: '团队分配',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export function PlantingLedger({ accountSequence }: PlantingLedgerProps) {
|
|||
<TableHead className="text-right">认种数量</TableHead>
|
||||
<TableHead className="text-right">单棵算力</TableHead>
|
||||
<TableHead className="text-right">总算力</TableHead>
|
||||
<TableHead>分配明细</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>认种日期</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -149,7 +150,7 @@ export function PlantingLedger({ accountSequence }: PlantingLedgerProps) {
|
|||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
暂无认种记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -160,6 +161,34 @@ export function PlantingLedger({ accountSequence }: PlantingLedgerProps) {
|
|||
<TableCell className="text-right font-mono">{formatNumber(item.treeCount)}</TableCell>
|
||||
<TableCell className="text-right font-mono">{formatDecimal(item.contributionPerTree || '0', 4)}</TableCell>
|
||||
<TableCell className="text-right font-mono">{formatDecimal(item.totalContribution || item.totalAmount || '0', 4)}</TableCell>
|
||||
<TableCell>
|
||||
{item.distribution ? (
|
||||
<div className="text-xs space-y-0.5">
|
||||
<div className="text-green-600">
|
||||
①{(item.distribution.personal.rate * 100).toFixed(0)}%({formatDecimal(item.distribution.personal.amount, 2)}){item.distribution.personal.label}
|
||||
</div>
|
||||
<div className="text-blue-600">
|
||||
②{(item.distribution.operation.rate * 100).toFixed(0)}%({formatDecimal(item.distribution.operation.amount, 2)}){item.distribution.operation.label}
|
||||
</div>
|
||||
<div className="text-blue-600">
|
||||
③{(item.distribution.province.rate * 100).toFixed(0)}%({formatDecimal(item.distribution.province.amount, 2)}){item.distribution.province.label}
|
||||
</div>
|
||||
<div className="text-blue-600">
|
||||
④{(item.distribution.city.rate * 100).toFixed(0)}%({formatDecimal(item.distribution.city.amount, 2)}){item.distribution.city.label}
|
||||
</div>
|
||||
<div className="text-orange-600">
|
||||
⑤{(item.distribution.team.rate * 100).toFixed(0)}%({formatDecimal(item.distribution.team.amount, 2)}){item.distribution.team.label}
|
||||
{item.distribution.team.distributed && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
[已分配:{formatDecimal(item.distribution.team.distributed, 2)} / 待分配:{formatDecimal(item.distribution.team.unallocated, 2)}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(item.status)}>
|
||||
{plantingStatusLabels[item.status] || item.status}
|
||||
|
|
|
|||
Loading…
Reference in New Issue