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")
|
contributionDistributed Boolean @default(false) @map("contribution_distributed")
|
||||||
contributionDistributedAt DateTime? @map("contribution_distributed_at")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([accountSequence])
|
@@index([accountSequence])
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,14 @@ export class ContributionCalculationService {
|
||||||
await this.unitOfWork.executeInTransaction(async () => {
|
await this.unitOfWork.executeInTransaction(async () => {
|
||||||
await this.saveDistributionResult(result, adoption.originalAdoptionId, adoption.accountSequence);
|
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);
|
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[]>;
|
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));
|
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;
|
const client = tx ?? this.client;
|
||||||
await client.syncedAdoption.update({
|
await client.syncedAdoption.update({
|
||||||
where: { originalAdoptionId },
|
where: { originalAdoptionId },
|
||||||
data: {
|
data: {
|
||||||
contributionDistributed: true,
|
contributionDistributed: true,
|
||||||
contributionDistributedAt: new Date(),
|
contributionDistributedAt: new Date(),
|
||||||
|
distributionSummary: distributionSummary ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ model SyncedAdoption {
|
||||||
adoptionDate DateTime @db.Date
|
adoptionDate DateTime @db.Date
|
||||||
status String? // 认种状态
|
status String? // 认种状态
|
||||||
contributionPerTree Decimal @db.Decimal(20, 10)
|
contributionPerTree Decimal @db.Decimal(20, 10)
|
||||||
|
distributionSummary String? @db.Text // 分配明细摘要 (JSON格式)
|
||||||
syncedAt DateTime @default(now())
|
syncedAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -768,16 +768,55 @@ export class UsersService {
|
||||||
totalAmount += a.treeCount * Number(a.contributionPerTree);
|
totalAmount += a.treeCount * Number(a.contributionPerTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化认种记录
|
// 获取每笔认种的实际分配明细(从 contribution_records 统计)
|
||||||
const items = adoptions.map((a) => ({
|
const adoptionIds = adoptions.map((a) => a.originalAdoptionId);
|
||||||
id: a.id,
|
const distributionByAdoption = await this.getDistributionByAdoptions(adoptionIds);
|
||||||
originalAdoptionId: a.originalAdoptionId.toString(),
|
|
||||||
treeCount: a.treeCount,
|
// 格式化认种记录,包含分配明细
|
||||||
adoptionDate: a.adoptionDate,
|
const items = adoptions.map((a) => {
|
||||||
status: a.status || 'ACTIVE',
|
const totalContribution = a.treeCount * Number(a.contributionPerTree);
|
||||||
contributionPerTree: a.contributionPerTree.toString(),
|
const distribution = distributionByAdoption.get(a.originalAdoptionId.toString());
|
||||||
totalContribution: (a.treeCount * Number(a.contributionPerTree)).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 {
|
return {
|
||||||
summary: {
|
summary: {
|
||||||
|
|
@ -972,4 +1011,102 @@ export class UsersService {
|
||||||
if (!phone || phone.length < 7) return phone;
|
if (!phone || phone.length < 7) return phone;
|
||||||
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
|
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 className="text-right">单棵算力</TableHead>
|
||||||
<TableHead className="text-right">总算力</TableHead>
|
<TableHead className="text-right">总算力</TableHead>
|
||||||
|
<TableHead>分配明细</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>认种日期</TableHead>
|
<TableHead>认种日期</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -149,7 +150,7 @@ export function PlantingLedger({ accountSequence }: PlantingLedgerProps) {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
暂无认种记录
|
暂无认种记录
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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">{formatNumber(item.treeCount)}</TableCell>
|
||||||
<TableCell className="text-right font-mono">{formatDecimal(item.contributionPerTree || '0', 4)}</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 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>
|
<TableCell>
|
||||||
<Badge variant={getStatusVariant(item.status)}>
|
<Badge variant={getStatusVariant(item.status)}>
|
||||||
{plantingStatusLabels[item.status] || item.status}
|
{plantingStatusLabels[item.status] || item.status}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue