feat(trading): 成交明细显示完整卖出信息(销毁倍数、有效积分股、手续费等)
- 后端Trade表新增originalQuantity字段存储原始卖出数量 - quantity字段改为存储有效积分股(含销毁倍数) - API返回完整明细:销毁倍数、有效积分股、交易总额、进入积分股池 - 前端成交明细页面显示完整卖出信息,类似确认卖出弹窗样式 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7da98c248b
commit
8728fdce4c
|
|
@ -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%进入积分股池)
|
||||
// 交易来源标识
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
// 按时间排序
|
||||
|
|
|
|||
|
|
@ -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() ?? '',
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue