feat(trading): 成交明细显示完整卖出信息(销毁倍数、有效积分股、手续费等)

- 后端Trade表新增originalQuantity字段存储原始卖出数量
- quantity字段改为存储有效积分股(含销毁倍数)
- API返回完整明细:销毁倍数、有效积分股、交易总额、进入积分股池
- 前端成交明细页面显示完整卖出信息,类似确认卖出弹窗样式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-20 04:07:52 -08:00
parent 7da98c248b
commit 8728fdce4c
6 changed files with 144 additions and 78 deletions

View File

@ -196,10 +196,11 @@ model Trade {
sellOrderId String @map("sell_order_id")
buyerSequence String @map("buyer_sequence")
sellerSequence String @map("seller_sequence")
price Decimal @db.Decimal(30, 18)
quantity Decimal @db.Decimal(30, 8) // 实际成交量
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量
effectiveQty Decimal @default(0) @map("effective_qty") @db.Decimal(30, 8) // 有效量quantity + burnQuantity
price Decimal @db.Decimal(30, 18)
quantity Decimal @db.Decimal(30, 8) // 有效积分股(含销毁倍数)
originalQuantity Decimal @default(0) @map("original_quantity") @db.Decimal(30, 8) // 原始卖出数量
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量
effectiveQty Decimal @default(0) @map("effective_qty") @db.Decimal(30, 8) // 有效量保留兼容同quantity
amount Decimal @db.Decimal(30, 8) // 卖方实际收到金额(扣除手续费后)
fee Decimal @default(0) @db.Decimal(30, 8) // 交易手续费10%进入积分股池)
// 交易来源标识

View File

@ -197,6 +197,7 @@ export class OrderService {
const sellerReceiveAmount = new Money(sellerGrossAmount.value.minus(tradeFee.value));
// 保存成交记录(包含销毁信息、手续费和来源标识)
// quantity 存储有效积分股含销毁倍数originalQuantity 存储原始卖出数量
await this.prisma.trade.create({
data: {
tradeNo: match.trade.tradeNo,
@ -205,7 +206,8 @@ export class OrderService {
buyerSequence: match.buyOrder.accountSequence,
sellerSequence: match.sellOrder.accountSequence,
price: match.trade.price.value,
quantity: tradeQuantity.value,
quantity: effectiveQuantity.value, // 有效积分股(倍数后)
originalQuantity: tradeQuantity.value, // 原始卖出数量
burnQuantity: burnQuantity.value,
effectiveQty: effectiveQuantity.value,
amount: sellerReceiveAmount.value,

View File

@ -215,6 +215,7 @@ export class OrderRepository {
/**
*
*
*/
async findTradesByAccountSequence(
accountSequence: string,
@ -225,10 +226,13 @@ export class OrderRepository {
tradeNo: string;
type: 'BUY' | 'SELL';
price: string;
quantity: string;
amount: string;
fee: string;
netAmount: string;
quantity: string; // 有效积分股(含销毁倍数)
originalQuantity: string; // 原始卖出数量
burnQuantity: string; // 销毁数量
burnMultiplier: string; // 销毁倍数
grossAmount: string; // 交易总额(扣除手续费前)
fee: string; // 手续费(进入积分股池)
netAmount: string; // 实际获得(扣除手续费后)
counterparty: string;
createdAt: Date;
}>;
@ -253,30 +257,51 @@ export class OrderRepository {
// 合并并转换格式
const allTrades = [
...buyerTrades.map((t) => ({
id: t.id,
tradeNo: t.tradeNo,
type: 'BUY' as const,
price: t.price.toString(),
quantity: t.quantity.toString(),
amount: t.amount.toString(),
fee: '0', // 买方不支付手续费
netAmount: t.amount.toString(),
counterparty: t.sellerSequence,
createdAt: t.createdAt,
})),
...sellerTrades.map((t) => ({
id: t.id,
tradeNo: t.tradeNo,
type: 'SELL' as const,
price: t.price.toString(),
quantity: t.quantity.toString(),
amount: t.amount.toString(), // 这是扣除手续费后的金额
fee: t.fee.toString(),
netAmount: t.amount.toString(), // amount 已经是 net扣除手续费后
counterparty: t.buyerSequence,
createdAt: t.createdAt,
})),
...buyerTrades.map((t) => {
// 买方quantity 是有效积分股originalQuantity 是原始数量
// 买方支付金额 = 原始数量 × 价格
const originalQty = t.originalQuantity?.toString() || t.quantity.toString();
const buyerPayAmount = Number(originalQty) * Number(t.price);
return {
id: t.id,
tradeNo: t.tradeNo,
type: 'BUY' as const,
price: t.price.toString(),
quantity: t.quantity.toString(), // 有效积分股(买方获得的积分股)
originalQuantity: originalQty, // 原始数量
burnQuantity: '0',
burnMultiplier: '1', // 买方无销毁倍数
grossAmount: buyerPayAmount.toString(), // 买方支付总额
fee: '0', // 买方不支付手续费
netAmount: buyerPayAmount.toString(), // 买方支付总额
counterparty: t.sellerSequence,
createdAt: t.createdAt,
};
}),
...sellerTrades.map((t) => {
// 卖方:计算销毁倍数 = 有效积分股 / 原始数量
const effectiveQty = Number(t.quantity);
const originalQty = Number(t.originalQuantity || t.quantity);
const burnMultiplier = originalQty > 0 ? effectiveQty / originalQty : 1;
// 交易总额 = 有效积分股 × 价格
const grossAmount = effectiveQty * Number(t.price);
// 实际获得 = 交易总额 - 手续费 (即 amount 字段)
return {
id: t.id,
tradeNo: t.tradeNo,
type: 'SELL' as const,
price: t.price.toString(),
quantity: t.quantity.toString(), // 有效积分股
originalQuantity: (t.originalQuantity || t.quantity).toString(), // 原始卖出数量
burnQuantity: t.burnQuantity.toString(), // 销毁数量
burnMultiplier: burnMultiplier.toString(), // 销毁倍数
grossAmount: grossAmount.toString(), // 交易总额
fee: t.fee.toString(), // 手续费(进入积分股池)
netAmount: t.amount.toString(), // 实际获得(扣除手续费后)
counterparty: t.buyerSequence,
createdAt: t.createdAt,
};
}),
];
// 按时间排序

View File

@ -88,7 +88,7 @@ class OrdersPageModel {
}
}
/// Model
/// Model
class TradeRecordModel extends TradeRecord {
const TradeRecordModel({
required super.id,
@ -96,7 +96,10 @@ class TradeRecordModel extends TradeRecord {
required super.type,
required super.price,
required super.quantity,
required super.amount,
required super.originalQuantity,
required super.burnQuantity,
required super.burnMultiplier,
required super.grossAmount,
required super.fee,
required super.netAmount,
required super.counterparty,
@ -110,7 +113,10 @@ class TradeRecordModel extends TradeRecord {
type: json['type'] == 'BUY' ? TradeType.buy : TradeType.sell,
price: json['price']?.toString() ?? '0',
quantity: json['quantity']?.toString() ?? '0',
amount: json['amount']?.toString() ?? '0',
originalQuantity: json['originalQuantity']?.toString() ?? json['quantity']?.toString() ?? '0',
burnQuantity: json['burnQuantity']?.toString() ?? '0',
burnMultiplier: json['burnMultiplier']?.toString() ?? '1',
grossAmount: json['grossAmount']?.toString() ?? '0',
fee: json['fee']?.toString() ?? '0',
netAmount: json['netAmount']?.toString() ?? '0',
counterparty: json['counterparty']?.toString() ?? '',

View File

@ -2,7 +2,7 @@ import 'package:equatable/equatable.dart';
enum TradeType { buy, sell }
///
///
class TradeRecord extends Equatable {
/// ID
final String id;
@ -12,13 +12,19 @@ class TradeRecord extends Equatable {
final TradeType type;
///
final String price;
///
///
final String quantity;
///
final String amount;
/// 0
///
final String originalQuantity;
///
final String burnQuantity;
///
final String burnMultiplier;
///
final String grossAmount;
/// 0
final String fee;
/// /
///
final String netAmount;
///
final String counterparty;
@ -31,7 +37,10 @@ class TradeRecord extends Equatable {
required this.type,
required this.price,
required this.quantity,
required this.amount,
required this.originalQuantity,
required this.burnQuantity,
required this.burnMultiplier,
required this.grossAmount,
required this.fee,
required this.netAmount,
required this.counterparty,
@ -47,6 +56,12 @@ class TradeRecord extends Equatable {
return feeValue > 0;
}
/// 1
bool get hasBurn {
final multiplier = double.tryParse(burnMultiplier) ?? 1;
return multiplier > 1;
}
@override
List<Object?> get props => [id, tradeNo, type, price, quantity, fee];
}

View File

@ -358,11 +358,6 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
final typeColor = isBuy ? _green : _red;
final typeText = isBuy ? '买入' : '卖出';
// netAmount + fee
final grossAmount = isBuy
? double.tryParse(trade.amount) ?? 0
: (double.tryParse(trade.netAmount) ?? 0) + (double.tryParse(trade.fee) ?? 0);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
@ -373,7 +368,7 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -396,35 +391,28 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
),
const SizedBox(height: 12),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('价格', _formatPrice(trade.price)),
_buildInfoItem('数量', _formatQuantity(trade.quantity)),
],
),
const SizedBox(height: 8),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('交易总额', '${formatAmount(grossAmount.toString())} 积分值'),
if (trade.hasFee)
_buildInfoItem('手续费(10%)', '${formatAmount(trade.fee)} 积分值', valueColor: _red),
],
),
//
if (trade.isSell && trade.hasFee) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('实际收到', '${formatAmount(trade.netAmount)} 积分值', valueColor: _green),
const SizedBox(),
],
if (isBuy) ...[
//
_buildDetailRow('成交价格', '${_formatPrice(trade.price)} 积分值'),
_buildDetailRow('成交数量', '${_formatQuantity(trade.quantity)} 积分股'),
_buildDetailRow('支付总额', '${formatAmount(trade.grossAmount)} 积分值'),
] else ...[
//
_buildDetailRow('卖出数量', '${_formatQuantity(trade.originalQuantity)} 积分股'),
_buildDetailRow('卖出价格', '${_formatPrice(trade.price)} 积分值'),
_buildDetailRow('销毁倍数', _formatMultiplier(trade.burnMultiplier)),
_buildDetailRow('有效积分股', _formatQuantity(trade.quantity)),
_buildDetailRow('交易总额', '${formatAmount(trade.grossAmount)} 积分值'),
_buildDetailRow(
'进入积分股池',
'${formatAmount(trade.fee)} 积分值 (10%)',
valueColor: const Color(0xFFE67E22),
),
_buildDetailRow(
'实际获得',
'${formatAmount(trade.netAmount)} 积分值',
valueColor: _green,
isBold: true,
),
],
@ -447,6 +435,35 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
);
}
Widget _buildDetailRow(String label, String value, {Color? valueColor, bool isBold = false}) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(fontSize: 13, color: _grayText),
),
Text(
value,
style: TextStyle(
fontSize: 13,
color: valueColor ?? _darkText,
fontWeight: isBold ? FontWeight.w600 : FontWeight.w500,
fontFamily: 'monospace',
),
),
],
),
);
}
String _formatMultiplier(String multiplier) {
final value = double.tryParse(multiplier) ?? 1;
return value.toStringAsFixed(4);
}
Widget _buildInfoItem(String label, String value, {Color? valueColor}) {
return Row(
mainAxisSize: MainAxisSize.min,