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") sellOrderId String @map("sell_order_id")
buyerSequence String @map("buyer_sequence") buyerSequence String @map("buyer_sequence")
sellerSequence String @map("seller_sequence") sellerSequence String @map("seller_sequence")
price Decimal @db.Decimal(30, 18) price Decimal @db.Decimal(30, 18)
quantity Decimal @db.Decimal(30, 8) // 实际成交量 quantity Decimal @db.Decimal(30, 8) // 有效积分股(含销毁倍数)
burnQuantity Decimal @default(0) @map("burn_quantity") @db.Decimal(30, 8) // 卖出销毁量 originalQuantity Decimal @default(0) @map("original_quantity") @db.Decimal(30, 8) // 原始卖出数量
effectiveQty Decimal @default(0) @map("effective_qty") @db.Decimal(30, 8) // 有效量quantity + burnQuantity 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) // 卖方实际收到金额(扣除手续费后) amount Decimal @db.Decimal(30, 8) // 卖方实际收到金额(扣除手续费后)
fee Decimal @default(0) @db.Decimal(30, 8) // 交易手续费10%进入积分股池) 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)); const sellerReceiveAmount = new Money(sellerGrossAmount.value.minus(tradeFee.value));
// 保存成交记录(包含销毁信息、手续费和来源标识) // 保存成交记录(包含销毁信息、手续费和来源标识)
// quantity 存储有效积分股含销毁倍数originalQuantity 存储原始卖出数量
await this.prisma.trade.create({ await this.prisma.trade.create({
data: { data: {
tradeNo: match.trade.tradeNo, tradeNo: match.trade.tradeNo,
@ -205,7 +206,8 @@ export class OrderService {
buyerSequence: match.buyOrder.accountSequence, buyerSequence: match.buyOrder.accountSequence,
sellerSequence: match.sellOrder.accountSequence, sellerSequence: match.sellOrder.accountSequence,
price: match.trade.price.value, price: match.trade.price.value,
quantity: tradeQuantity.value, quantity: effectiveQuantity.value, // 有效积分股(倍数后)
originalQuantity: tradeQuantity.value, // 原始卖出数量
burnQuantity: burnQuantity.value, burnQuantity: burnQuantity.value,
effectiveQty: effectiveQuantity.value, effectiveQty: effectiveQuantity.value,
amount: sellerReceiveAmount.value, amount: sellerReceiveAmount.value,

View File

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

View File

@ -2,7 +2,7 @@ import 'package:equatable/equatable.dart';
enum TradeType { buy, sell } enum TradeType { buy, sell }
/// ///
class TradeRecord extends Equatable { class TradeRecord extends Equatable {
/// ID /// ID
final String id; final String id;
@ -12,13 +12,19 @@ class TradeRecord extends Equatable {
final TradeType type; final TradeType type;
/// ///
final String price; final String price;
/// ///
final String quantity; final String quantity;
/// ///
final String amount; final String originalQuantity;
/// 0 ///
final String burnQuantity;
///
final String burnMultiplier;
///
final String grossAmount;
/// 0
final String fee; final String fee;
/// / ///
final String netAmount; final String netAmount;
/// ///
final String counterparty; final String counterparty;
@ -31,7 +37,10 @@ class TradeRecord extends Equatable {
required this.type, required this.type,
required this.price, required this.price,
required this.quantity, required this.quantity,
required this.amount, required this.originalQuantity,
required this.burnQuantity,
required this.burnMultiplier,
required this.grossAmount,
required this.fee, required this.fee,
required this.netAmount, required this.netAmount,
required this.counterparty, required this.counterparty,
@ -47,6 +56,12 @@ class TradeRecord extends Equatable {
return feeValue > 0; return feeValue > 0;
} }
/// 1
bool get hasBurn {
final multiplier = double.tryParse(burnMultiplier) ?? 1;
return multiplier > 1;
}
@override @override
List<Object?> get props => [id, tradeNo, type, price, quantity, fee]; 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 typeColor = isBuy ? _green : _red;
final typeText = isBuy ? '买入' : '卖出'; 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( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -373,7 +368,7 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// //
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -396,35 +391,28 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// if (isBuy) ...[
Row( //
mainAxisAlignment: MainAxisAlignment.spaceBetween, _buildDetailRow('成交价格', '${_formatPrice(trade.price)} 积分值'),
children: [ _buildDetailRow('成交数量', '${_formatQuantity(trade.quantity)} 积分股'),
_buildInfoItem('价格', _formatPrice(trade.price)), _buildDetailRow('支付总额', '${formatAmount(trade.grossAmount)} 积分值'),
_buildInfoItem('数量', _formatQuantity(trade.quantity)), ] else ...[
], //
), _buildDetailRow('卖出数量', '${_formatQuantity(trade.originalQuantity)} 积分股'),
const SizedBox(height: 8), _buildDetailRow('卖出价格', '${_formatPrice(trade.price)} 积分值'),
_buildDetailRow('销毁倍数', _formatMultiplier(trade.burnMultiplier)),
// _buildDetailRow('有效积分股', _formatQuantity(trade.quantity)),
Row( _buildDetailRow('交易总额', '${formatAmount(trade.grossAmount)} 积分值'),
mainAxisAlignment: MainAxisAlignment.spaceBetween, _buildDetailRow(
children: [ '进入积分股池',
_buildInfoItem('交易总额', '${formatAmount(grossAmount.toString())} 积分值'), '${formatAmount(trade.fee)} 积分值 (10%)',
if (trade.hasFee) valueColor: const Color(0xFFE67E22),
_buildInfoItem('手续费(10%)', '${formatAmount(trade.fee)} 积分值', valueColor: _red), ),
], _buildDetailRow(
), '实际获得',
'${formatAmount(trade.netAmount)} 积分值',
// valueColor: _green,
if (trade.isSell && trade.hasFee) ...[ isBold: true,
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem('实际收到', '${formatAmount(trade.netAmount)} 积分值', valueColor: _green),
const SizedBox(),
],
), ),
], ],
@ -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}) { Widget _buildInfoItem(String label, String value, {Color? valueColor}) {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,