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:
hailin 2026-01-12 09:23:02 -08:00
parent 8d97daa524
commit 23dabb0219
7 changed files with 267 additions and 16 deletions

View File

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

View File

@ -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: '团队分配',
},
};
}
}

View File

@ -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>;
/**
*

View File

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

View File

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

View File

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

View File

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