diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceTransactionContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceTransactionContracts.cs
new file mode 100644
index 0000000..3194567
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceTransactionContracts.cs
@@ -0,0 +1,329 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Finance;
+
+///
+/// 交易流水筛选请求。
+///
+public class FinanceTransactionFilterRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 开始日期(yyyy-MM-dd)。
+ ///
+ public string? StartDate { get; set; }
+
+ ///
+ /// 结束日期(yyyy-MM-dd)。
+ ///
+ public string? EndDate { get; set; }
+
+ ///
+ /// 交易类型(income/refund/stored_card_recharge/point_redeem)。
+ ///
+ public string? Type { get; set; }
+
+ ///
+ /// 渠道(delivery/pickup/dine_in)。
+ ///
+ public string? Channel { get; set; }
+
+ ///
+ /// 支付方式(wechat/alipay/cash/card/balance)。
+ ///
+ public string? PaymentMethod { get; set; }
+
+ ///
+ /// 关键词(流水号/订单号)。
+ ///
+ public string? Keyword { get; set; }
+}
+
+///
+/// 交易流水列表请求。
+///
+public sealed class FinanceTransactionListRequest : FinanceTransactionFilterRequest
+{
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+}
+
+///
+/// 交易流水详情请求。
+///
+public sealed class FinanceTransactionDetailRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 交易标识(sourceType:sourceId)。
+ ///
+ public string TransactionId { get; set; } = string.Empty;
+}
+
+///
+/// 交易流水列表结果。
+///
+public sealed class FinanceTransactionListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 本页收入。
+ ///
+ public decimal PageIncomeAmount { get; set; }
+
+ ///
+ /// 本页退款。
+ ///
+ public decimal PageRefundAmount { get; set; }
+}
+
+///
+/// 交易流水行。
+///
+public sealed class FinanceTransactionListItemResponse
+{
+ ///
+ /// 交易标识。
+ ///
+ public string TransactionId { get; set; } = string.Empty;
+
+ ///
+ /// 流水号。
+ ///
+ public string TransactionNo { get; set; } = string.Empty;
+
+ ///
+ /// 关联订单号。
+ ///
+ public string? OrderNo { get; set; }
+
+ ///
+ /// 类型编码。
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 类型文案。
+ ///
+ public string TypeText { get; set; } = string.Empty;
+
+ ///
+ /// 渠道文案。
+ ///
+ public string Channel { get; set; } = string.Empty;
+
+ ///
+ /// 支付方式文案。
+ ///
+ public string PaymentMethod { get; set; } = string.Empty;
+
+ ///
+ /// 交易金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 交易时间。
+ ///
+ public string OccurredAt { get; set; } = string.Empty;
+
+ ///
+ /// 备注。
+ ///
+ public string Remark { get; set; } = string.Empty;
+
+ ///
+ /// 是否收入。
+ ///
+ public bool IsIncome { get; set; }
+}
+
+///
+/// 交易流水统计结果。
+///
+public sealed class FinanceTransactionStatsResponse
+{
+ ///
+ /// 总收入。
+ ///
+ public decimal TotalIncome { get; set; }
+
+ ///
+ /// 总退款。
+ ///
+ public decimal TotalRefund { get; set; }
+
+ ///
+ /// 总笔数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 交易流水详情。
+///
+public sealed class FinanceTransactionDetailResponse
+{
+ ///
+ /// 交易标识。
+ ///
+ public string TransactionId { get; set; } = string.Empty;
+
+ ///
+ /// 流水号。
+ ///
+ public string TransactionNo { get; set; } = string.Empty;
+
+ ///
+ /// 类型编码。
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 类型文案。
+ ///
+ public string TypeText { get; set; } = string.Empty;
+
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 关联订单号。
+ ///
+ public string? OrderNo { get; set; }
+
+ ///
+ /// 渠道文案。
+ ///
+ public string Channel { get; set; } = string.Empty;
+
+ ///
+ /// 支付方式文案。
+ ///
+ public string PaymentMethod { get; set; } = string.Empty;
+
+ ///
+ /// 交易金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 交易时间。
+ ///
+ public string OccurredAt { get; set; } = string.Empty;
+
+ ///
+ /// 备注。
+ ///
+ public string Remark { get; set; } = string.Empty;
+
+ ///
+ /// 顾客姓名。
+ ///
+ public string CustomerName { get; set; } = string.Empty;
+
+ ///
+ /// 顾客手机号。
+ ///
+ public string CustomerPhone { get; set; } = string.Empty;
+
+ ///
+ /// 退款单号。
+ ///
+ public string? RefundNo { get; set; }
+
+ ///
+ /// 退款原因。
+ ///
+ public string? RefundReason { get; set; }
+
+ ///
+ /// 会员名称。
+ ///
+ public string? MemberName { get; set; }
+
+ ///
+ /// 会员手机号。
+ ///
+ public string? MemberMobileMasked { get; set; }
+
+ ///
+ /// 充值金额。
+ ///
+ public decimal? RechargeAmount { get; set; }
+
+ ///
+ /// 赠送金额。
+ ///
+ public decimal? GiftAmount { get; set; }
+
+ ///
+ /// 到账金额。
+ ///
+ public decimal? ArrivedAmount { get; set; }
+
+ ///
+ /// 积分变动值。
+ ///
+ public int? PointChangeAmount { get; set; }
+
+ ///
+ /// 积分变动后余额。
+ ///
+ public int? PointBalanceAfterChange { get; set; }
+}
+
+///
+/// 交易流水导出结果。
+///
+public sealed class FinanceTransactionExportResponse
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// 文件内容(Base64)。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 导出总数。
+ ///
+ public int TotalCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceTransactionController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceTransactionController.cs
new file mode 100644
index 0000000..4e27946
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceTransactionController.cs
@@ -0,0 +1,343 @@
+using System.Globalization;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Finance;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 财务中心交易流水。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/finance/transaction")]
+public sealed class FinanceTransactionController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService) : BaseApiController
+{
+ private const string ViewPermission = "tenant:finance:transaction:view";
+ private const string DetailPermission = "tenant:finance:transaction:detail";
+ private const string ExportPermission = "tenant:finance:transaction:export";
+
+ ///
+ /// 查询交易流水列表。
+ ///
+ [HttpGet("list")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> List(
+ [FromQuery] FinanceTransactionListRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验筛选参数。
+ var parsed = await ParseFilterAsync(request, cancellationToken);
+
+ // 2. 发起查询并映射响应。
+ var result = await mediator.Send(new SearchFinanceTransactionListQuery
+ {
+ StoreId = parsed.StoreId,
+ StartAt = parsed.StartAt,
+ EndAt = parsed.EndAt,
+ TransactionType = parsed.TransactionType,
+ DeliveryType = parsed.DeliveryType,
+ PaymentMethod = parsed.PaymentMethod,
+ Keyword = request.Keyword,
+ Page = Math.Max(1, request.Page),
+ PageSize = Math.Clamp(request.PageSize, 1, 200)
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new FinanceTransactionListResultResponse
+ {
+ Items = result.Items.Select(MapListItem).ToList(),
+ Total = result.Total,
+ Page = result.Page,
+ PageSize = result.PageSize,
+ PageIncomeAmount = result.PageIncomeAmount,
+ PageRefundAmount = result.PageRefundAmount
+ });
+ }
+
+ ///
+ /// 查询交易流水统计。
+ ///
+ [HttpGet("stats")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Stats(
+ [FromQuery] FinanceTransactionFilterRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验筛选参数。
+ var parsed = await ParseFilterAsync(request, cancellationToken);
+
+ // 2. 发起查询并映射响应。
+ var result = await mediator.Send(new GetFinanceTransactionStatsQuery
+ {
+ StoreId = parsed.StoreId,
+ StartAt = parsed.StartAt,
+ EndAt = parsed.EndAt,
+ TransactionType = parsed.TransactionType,
+ DeliveryType = parsed.DeliveryType,
+ PaymentMethod = parsed.PaymentMethod,
+ Keyword = request.Keyword
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new FinanceTransactionStatsResponse
+ {
+ TotalIncome = result.TotalIncome,
+ TotalRefund = result.TotalRefund,
+ TotalCount = result.TotalCount
+ });
+ }
+
+ ///
+ /// 查询交易流水详情。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission, DetailPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] FinanceTransactionDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验门店参数与门店访问权限。
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ // 2. 解析交易复合标识。
+ if (!TryParseTransactionId(request.TransactionId, out var sourceType, out var sourceId))
+ {
+ return ApiResponse.Error(ErrorCodes.BadRequest, "transactionId 非法");
+ }
+
+ // 3. 查询详情并返回。
+ var detail = await mediator.Send(new GetFinanceTransactionDetailQuery
+ {
+ StoreId = storeId,
+ SourceType = sourceType,
+ SourceId = sourceId
+ }, cancellationToken);
+
+ if (detail is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "交易流水不存在");
+ }
+
+ return ApiResponse.Ok(MapDetail(detail));
+ }
+
+ ///
+ /// 导出交易流水 CSV。
+ ///
+ [HttpGet("export")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Export(
+ [FromQuery] FinanceTransactionFilterRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析并校验筛选参数。
+ var parsed = await ParseFilterAsync(request, cancellationToken);
+
+ // 2. 发起导出并返回结果。
+ var result = await mediator.Send(new ExportFinanceTransactionCsvQuery
+ {
+ StoreId = parsed.StoreId,
+ StartAt = parsed.StartAt,
+ EndAt = parsed.EndAt,
+ TransactionType = parsed.TransactionType,
+ DeliveryType = parsed.DeliveryType,
+ PaymentMethod = parsed.PaymentMethod,
+ Keyword = request.Keyword
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new FinanceTransactionExportResponse
+ {
+ FileName = result.FileName,
+ FileContentBase64 = result.FileContentBase64,
+ TotalCount = result.TotalCount
+ });
+ }
+
+ private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, FinanceTransactionType? TransactionType, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
+ FinanceTransactionFilterRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var startAt = ParseDateOrNull(request.StartDate);
+ var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
+ if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
+ }
+
+ var transactionType = ParseTransactionType(request.Type);
+ var deliveryType = ParseDeliveryType(request.Channel);
+ var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
+
+ return (storeId, startAt, endAt, transactionType, deliveryType, paymentMethod);
+ }
+
+ private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
+ {
+ var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
+ await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
+ }
+
+ private static DateTime? ParseDateOrNull(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (DateTime.TryParseExact(
+ value,
+ "yyyy-MM-dd",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var parsed))
+ {
+ return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
+ }
+
+ throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
+ }
+
+ private static FinanceTransactionType? ParseTransactionType(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "income" => FinanceTransactionType.Income,
+ "refund" => FinanceTransactionType.Refund,
+ "stored_card_recharge" => FinanceTransactionType.StoredCardRecharge,
+ "point_redeem" => FinanceTransactionType.PointRedeem,
+ _ => null
+ };
+ }
+
+ private static DeliveryType? ParseDeliveryType(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "delivery" => DeliveryType.Delivery,
+ "pickup" => DeliveryType.Pickup,
+ "dine_in" => DeliveryType.DineIn,
+ _ => null
+ };
+ }
+
+ private static PaymentMethod? ParsePaymentMethod(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "wechat" => PaymentMethod.WeChatPay,
+ "alipay" => PaymentMethod.Alipay,
+ "cash" => PaymentMethod.Cash,
+ "card" => PaymentMethod.Card,
+ "balance" => PaymentMethod.Balance,
+ _ => null
+ };
+ }
+
+ private static bool TryParseTransactionId(string? value, out FinanceTransactionSourceType sourceType, out long sourceId)
+ {
+ sourceType = default;
+ sourceId = 0;
+
+ var normalized = (value ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return false;
+ }
+
+ var parts = normalized.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length != 2)
+ {
+ return false;
+ }
+
+ if (!long.TryParse(parts[1], out sourceId) || sourceId <= 0)
+ {
+ return false;
+ }
+
+ sourceType = parts[0].ToLowerInvariant() switch
+ {
+ "payment" => FinanceTransactionSourceType.PaymentRecord,
+ "payment_refund" => FinanceTransactionSourceType.PaymentRefundRecord,
+ "refund_request" => FinanceTransactionSourceType.RefundRequest,
+ "stored_card_recharge" => FinanceTransactionSourceType.StoredCardRechargeRecord,
+ "member_point" => FinanceTransactionSourceType.MemberPointLedger,
+ _ => default
+ };
+
+ return sourceType != default;
+ }
+
+ private static FinanceTransactionListItemResponse MapListItem(FinanceTransactionListItemDto source)
+ {
+ return new FinanceTransactionListItemResponse
+ {
+ TransactionId = source.TransactionId,
+ TransactionNo = source.TransactionNo,
+ OrderNo = source.OrderNo,
+ Type = source.TransactionType,
+ TypeText = source.TransactionTypeText,
+ Channel = source.ChannelText,
+ PaymentMethod = source.PaymentMethodText,
+ Amount = source.AmountSigned,
+ OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ Remark = source.Remark,
+ IsIncome = source.IsIncome
+ };
+ }
+
+ private static FinanceTransactionDetailResponse MapDetail(FinanceTransactionDetailDto source)
+ {
+ return new FinanceTransactionDetailResponse
+ {
+ TransactionId = source.TransactionId,
+ TransactionNo = source.TransactionNo,
+ Type = source.TransactionType,
+ TypeText = source.TransactionTypeText,
+ StoreId = source.StoreId.ToString(),
+ OrderNo = source.OrderNo,
+ Channel = source.ChannelText,
+ PaymentMethod = source.PaymentMethodText,
+ Amount = source.AmountSigned,
+ OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ Remark = source.Remark,
+ CustomerName = source.CustomerName,
+ CustomerPhone = source.CustomerPhone,
+ RefundNo = source.RefundNo,
+ RefundReason = source.RefundReason,
+ MemberName = source.MemberName,
+ MemberMobileMasked = source.MemberMobileMasked,
+ RechargeAmount = source.RechargeAmount,
+ GiftAmount = source.GiftAmount,
+ ArrivedAmount = source.ArrivedAmount,
+ PointChangeAmount = source.PointChangeAmount,
+ PointBalanceAfterChange = source.PointBalanceAfterChange
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Dto/FinanceTransactionDtos.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Dto/FinanceTransactionDtos.cs
new file mode 100644
index 0000000..41c8de7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Dto/FinanceTransactionDtos.cs
@@ -0,0 +1,256 @@
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+
+///
+/// 交易流水列表行。
+///
+public sealed class FinanceTransactionListItemDto
+{
+ ///
+ /// 交易标识。
+ ///
+ public string TransactionId { get; set; } = string.Empty;
+
+ ///
+ /// 流水号。
+ ///
+ public string TransactionNo { get; set; } = string.Empty;
+
+ ///
+ /// 关联订单号。
+ ///
+ public string? OrderNo { get; set; }
+
+ ///
+ /// 交易类型编码。
+ ///
+ public string TransactionType { get; set; } = string.Empty;
+
+ ///
+ /// 交易类型文案。
+ ///
+ public string TransactionTypeText { get; set; } = string.Empty;
+
+ ///
+ /// 渠道文案。
+ ///
+ public string ChannelText { get; set; } = string.Empty;
+
+ ///
+ /// 支付方式文案。
+ ///
+ public string PaymentMethodText { get; set; } = string.Empty;
+
+ ///
+ /// 交易金额(带符号)。
+ ///
+ public decimal AmountSigned { get; set; }
+
+ ///
+ /// 交易时间。
+ ///
+ public DateTime OccurredAt { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string Remark { get; set; } = string.Empty;
+
+ ///
+ /// 是否收入。
+ ///
+ public bool IsIncome { get; set; }
+}
+
+///
+/// 交易流水列表结果。
+///
+public sealed class FinanceTransactionListResultDto
+{
+ ///
+ /// 分页数据。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 当前页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 本页收入合计。
+ ///
+ public decimal PageIncomeAmount { get; set; }
+
+ ///
+ /// 本页退款合计。
+ ///
+ public decimal PageRefundAmount { get; set; }
+}
+
+///
+/// 交易流水统计。
+///
+public sealed class FinanceTransactionStatsDto
+{
+ ///
+ /// 总收入。
+ ///
+ public decimal TotalIncome { get; set; }
+
+ ///
+ /// 总退款。
+ ///
+ public decimal TotalRefund { get; set; }
+
+ ///
+ /// 交易笔数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 交易流水详情。
+///
+public sealed class FinanceTransactionDetailDto
+{
+ ///
+ /// 交易标识。
+ ///
+ public string TransactionId { get; set; } = string.Empty;
+
+ ///
+ /// 流水号。
+ ///
+ public string TransactionNo { get; set; } = string.Empty;
+
+ ///
+ /// 交易类型编码。
+ ///
+ public string TransactionType { get; set; } = string.Empty;
+
+ ///
+ /// 交易类型文案。
+ ///
+ public string TransactionTypeText { get; set; } = string.Empty;
+
+ ///
+ /// 门店标识。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string? OrderNo { get; set; }
+
+ ///
+ /// 渠道文案。
+ ///
+ public string ChannelText { get; set; } = string.Empty;
+
+ ///
+ /// 支付方式文案。
+ ///
+ public string PaymentMethodText { get; set; } = string.Empty;
+
+ ///
+ /// 交易金额(带符号)。
+ ///
+ public decimal AmountSigned { get; set; }
+
+ ///
+ /// 交易时间。
+ ///
+ public DateTime OccurredAt { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string Remark { get; set; } = string.Empty;
+
+ ///
+ /// 顾客姓名。
+ ///
+ public string CustomerName { get; set; } = string.Empty;
+
+ ///
+ /// 顾客手机号。
+ ///
+ public string CustomerPhone { get; set; } = string.Empty;
+
+ ///
+ /// 退款单号。
+ ///
+ public string? RefundNo { get; set; }
+
+ ///
+ /// 退款原因。
+ ///
+ public string? RefundReason { get; set; }
+
+ ///
+ /// 会员名称。
+ ///
+ public string? MemberName { get; set; }
+
+ ///
+ /// 会员手机号脱敏值。
+ ///
+ public string? MemberMobileMasked { get; set; }
+
+ ///
+ /// 充值金额。
+ ///
+ public decimal? RechargeAmount { get; set; }
+
+ ///
+ /// 赠送金额。
+ ///
+ public decimal? GiftAmount { get; set; }
+
+ ///
+ /// 到账金额。
+ ///
+ public decimal? ArrivedAmount { get; set; }
+
+ ///
+ /// 积分变动值。
+ ///
+ public int? PointChangeAmount { get; set; }
+
+ ///
+ /// 积分变动后余额。
+ ///
+ public int? PointBalanceAfterChange { get; set; }
+}
+
+///
+/// 交易流水导出结果。
+///
+public sealed class FinanceTransactionExportDto
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// 文件内容(Base64)。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 导出总数。
+ ///
+ public int TotalCount { get; set; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/ExportFinanceTransactionCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/ExportFinanceTransactionCsvQueryHandler.cs
new file mode 100644
index 0000000..9bfe099
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/ExportFinanceTransactionCsvQueryHandler.cs
@@ -0,0 +1,81 @@
+using System.Globalization;
+using System.Text;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
+
+///
+/// 交易流水 CSV 导出查询处理器。
+///
+public sealed class ExportFinanceTransactionCsvQueryHandler(
+ IFinanceTransactionRepository financeTransactionRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(ExportFinanceTransactionCsvQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 按筛选读取导出数据。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var records = await financeTransactionRepository.ListForExportAsync(
+ tenantId,
+ request.StoreId,
+ request.StartAt,
+ request.EndAt,
+ request.TransactionType,
+ request.DeliveryType,
+ request.PaymentMethod,
+ request.Keyword,
+ cancellationToken);
+
+ // 2. 组装 CSV 并输出 Base64。
+ var csv = BuildCsv(records.Select(FinanceTransactionMapping.ToListItem).ToList());
+ var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
+
+ return new FinanceTransactionExportDto
+ {
+ FileName = $"交易流水_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
+ FileContentBase64 = Convert.ToBase64String(bytes),
+ TotalCount = records.Count
+ };
+ }
+
+ private static string BuildCsv(IReadOnlyList rows)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("流水号,关联订单,类型,渠道,支付方式,金额,交易时间,备注");
+
+ foreach (var row in rows)
+ {
+ var cells = new[]
+ {
+ Escape(row.TransactionNo),
+ Escape(string.IsNullOrWhiteSpace(row.OrderNo) ? "—" : row.OrderNo!),
+ Escape(row.TransactionTypeText),
+ Escape(row.ChannelText),
+ Escape(row.PaymentMethodText),
+ Escape(FinanceTransactionMapping.FormatAmount(row.AmountSigned)),
+ Escape(row.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
+ Escape(string.IsNullOrWhiteSpace(row.Remark) ? "—" : row.Remark)
+ };
+
+ builder.AppendLine(string.Join(',', cells));
+ }
+
+ return builder.ToString();
+ }
+
+ private static string Escape(string value)
+ {
+ if (!value.Contains('"') && !value.Contains(',') && !value.Contains('\n') && !value.Contains('\r'))
+ {
+ return value;
+ }
+
+ return $"\"{value.Replace("\"", "\"\"")}\"";
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/FinanceTransactionMapping.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/FinanceTransactionMapping.cs
new file mode 100644
index 0000000..faf4156
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/FinanceTransactionMapping.cs
@@ -0,0 +1,155 @@
+using System.Globalization;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
+
+///
+/// 交易流水映射与文案转换。
+///
+internal static class FinanceTransactionMapping
+{
+ ///
+ /// 生成交易复合标识。
+ ///
+ public static string BuildTransactionId(FinanceTransactionSourceType sourceType, long sourceId)
+ {
+ var sourceCode = sourceType switch
+ {
+ FinanceTransactionSourceType.PaymentRecord => "payment",
+ FinanceTransactionSourceType.PaymentRefundRecord => "payment_refund",
+ FinanceTransactionSourceType.RefundRequest => "refund_request",
+ FinanceTransactionSourceType.StoredCardRechargeRecord => "stored_card_recharge",
+ FinanceTransactionSourceType.MemberPointLedger => "member_point",
+ _ => "unknown"
+ };
+
+ return $"{sourceCode}:{sourceId}";
+ }
+
+ ///
+ /// 解析交易类型编码。
+ ///
+ public static string ToTransactionTypeCode(FinanceTransactionType transactionType)
+ {
+ return transactionType switch
+ {
+ FinanceTransactionType.Income => "income",
+ FinanceTransactionType.Refund => "refund",
+ FinanceTransactionType.StoredCardRecharge => "stored_card_recharge",
+ FinanceTransactionType.PointRedeem => "point_redeem",
+ _ => "unknown"
+ };
+ }
+
+ ///
+ /// 解析交易类型文案。
+ ///
+ public static string ToTransactionTypeText(FinanceTransactionType transactionType)
+ {
+ return transactionType switch
+ {
+ FinanceTransactionType.Income => "收入",
+ FinanceTransactionType.Refund => "退款",
+ FinanceTransactionType.StoredCardRecharge => "储值充值",
+ FinanceTransactionType.PointRedeem => "积分抵扣",
+ _ => "未知"
+ };
+ }
+
+ ///
+ /// 解析渠道文案。
+ ///
+ public static string ToChannelText(DeliveryType? deliveryType)
+ {
+ return deliveryType switch
+ {
+ DeliveryType.Delivery => "外卖",
+ DeliveryType.Pickup => "自提",
+ DeliveryType.DineIn => "堂食",
+ _ => "—"
+ };
+ }
+
+ ///
+ /// 解析支付方式文案。
+ ///
+ public static string ToPaymentMethodText(PaymentMethod? paymentMethod)
+ {
+ return paymentMethod switch
+ {
+ PaymentMethod.WeChatPay => "微信",
+ PaymentMethod.Alipay => "支付宝",
+ PaymentMethod.Cash => "现金",
+ PaymentMethod.Card => "刷卡",
+ PaymentMethod.Balance => "储值余额",
+ _ => "—"
+ };
+ }
+
+ ///
+ /// 映射列表行。
+ ///
+ public static FinanceTransactionListItemDto ToListItem(FinanceTransactionRecord source)
+ {
+ return new FinanceTransactionListItemDto
+ {
+ TransactionId = BuildTransactionId(source.SourceType, source.SourceId),
+ TransactionNo = source.TransactionNo ?? string.Empty,
+ OrderNo = source.OrderNo,
+ TransactionType = ToTransactionTypeCode(source.TransactionType),
+ TransactionTypeText = ToTransactionTypeText(source.TransactionType),
+ ChannelText = ToChannelText(source.DeliveryType),
+ PaymentMethodText = ToPaymentMethodText(source.PaymentMethod),
+ AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
+ OccurredAt = source.OccurredAt,
+ Remark = string.IsNullOrWhiteSpace(source.Remark) ? "—" : source.Remark.Trim(),
+ IsIncome = source.AmountSigned > 0
+ };
+ }
+
+ ///
+ /// 映射详情。
+ ///
+ public static FinanceTransactionDetailDto ToDetail(FinanceTransactionRecord source)
+ {
+ return new FinanceTransactionDetailDto
+ {
+ TransactionId = BuildTransactionId(source.SourceType, source.SourceId),
+ TransactionNo = source.TransactionNo ?? string.Empty,
+ TransactionType = ToTransactionTypeCode(source.TransactionType),
+ TransactionTypeText = ToTransactionTypeText(source.TransactionType),
+ StoreId = source.StoreId,
+ OrderNo = source.OrderNo,
+ ChannelText = ToChannelText(source.DeliveryType),
+ PaymentMethodText = ToPaymentMethodText(source.PaymentMethod),
+ AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
+ OccurredAt = source.OccurredAt,
+ Remark = string.IsNullOrWhiteSpace(source.Remark) ? "—" : source.Remark.Trim(),
+ CustomerName = string.IsNullOrWhiteSpace(source.CustomerName) ? "—" : source.CustomerName.Trim(),
+ CustomerPhone = string.IsNullOrWhiteSpace(source.CustomerPhone) ? "—" : source.CustomerPhone.Trim(),
+ RefundNo = source.RefundNo,
+ RefundReason = source.RefundReason,
+ MemberName = source.MemberName,
+ MemberMobileMasked = source.MemberMobileMasked,
+ RechargeAmount = source.RechargeAmount,
+ GiftAmount = source.GiftAmount,
+ ArrivedAmount = source.ArrivedAmount,
+ PointChangeAmount = source.PointChangeAmount,
+ PointBalanceAfterChange = source.PointBalanceAfterChange
+ };
+ }
+
+ ///
+ /// 导出金额文本。
+ ///
+ public static string FormatAmount(decimal amountSigned)
+ {
+ var rounded = decimal.Round(amountSigned, 2, MidpointRounding.AwayFromZero);
+ var sign = rounded >= 0 ? "+" : string.Empty;
+ return $"{sign}{rounded.ToString("0.00", CultureInfo.InvariantCulture)}";
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionDetailQueryHandler.cs
new file mode 100644
index 0000000..94d88c7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionDetailQueryHandler.cs
@@ -0,0 +1,32 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
+
+///
+/// 交易流水详情查询处理器。
+///
+public sealed class GetFinanceTransactionDetailQueryHandler(
+ IFinanceTransactionRepository financeTransactionRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetFinanceTransactionDetailQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并查询详情。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var record = await financeTransactionRepository.GetDetailAsync(
+ tenantId,
+ request.StoreId,
+ request.SourceType,
+ request.SourceId,
+ cancellationToken);
+
+ // 2. 映射详情输出。
+ return record is null ? null : FinanceTransactionMapping.ToDetail(record);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionStatsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionStatsQueryHandler.cs
new file mode 100644
index 0000000..181ca13
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionStatsQueryHandler.cs
@@ -0,0 +1,41 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
+
+///
+/// 交易流水统计查询处理器。
+///
+public sealed class GetFinanceTransactionStatsQueryHandler(
+ IFinanceTransactionRepository financeTransactionRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetFinanceTransactionStatsQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并执行统计查询。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var snapshot = await financeTransactionRepository.GetStatsAsync(
+ tenantId,
+ request.StoreId,
+ request.StartAt,
+ request.EndAt,
+ request.TransactionType,
+ request.DeliveryType,
+ request.PaymentMethod,
+ request.Keyword,
+ cancellationToken);
+
+ // 2. 映射统计结果。
+ return new FinanceTransactionStatsDto
+ {
+ TotalIncome = snapshot.TotalIncome,
+ TotalRefund = snapshot.TotalRefund,
+ TotalCount = snapshot.TotalCount
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/SearchFinanceTransactionListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/SearchFinanceTransactionListQueryHandler.cs
new file mode 100644
index 0000000..738c617
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/SearchFinanceTransactionListQueryHandler.cs
@@ -0,0 +1,49 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Handlers;
+
+///
+/// 交易流水列表查询处理器。
+///
+public sealed class SearchFinanceTransactionListQueryHandler(
+ IFinanceTransactionRepository financeTransactionRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(SearchFinanceTransactionListQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并执行分页查询。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var page = Math.Max(1, request.Page);
+ var pageSize = Math.Clamp(request.PageSize, 1, 200);
+
+ var snapshot = await financeTransactionRepository.SearchPageAsync(
+ tenantId,
+ request.StoreId,
+ request.StartAt,
+ request.EndAt,
+ request.TransactionType,
+ request.DeliveryType,
+ request.PaymentMethod,
+ request.Keyword,
+ page,
+ pageSize,
+ cancellationToken);
+
+ // 2. 映射结果并返回。
+ return new FinanceTransactionListResultDto
+ {
+ Items = snapshot.Items.Select(FinanceTransactionMapping.ToListItem).ToList(),
+ Total = snapshot.TotalCount,
+ Page = page,
+ PageSize = pageSize,
+ PageIncomeAmount = snapshot.PageIncomeAmount,
+ PageRefundAmount = snapshot.PageRefundAmount
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/ExportFinanceTransactionCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/ExportFinanceTransactionCsvQuery.cs
new file mode 100644
index 0000000..af5a28f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/ExportFinanceTransactionCsvQuery.cs
@@ -0,0 +1,11 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+///
+/// 交易流水 CSV 导出查询。
+///
+public sealed class ExportFinanceTransactionCsvQuery : FinanceTransactionFilterQueryBase, IRequest
+{
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/FinanceTransactionFilterQueryBase.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/FinanceTransactionFilterQueryBase.cs
new file mode 100644
index 0000000..7523f5f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/FinanceTransactionFilterQueryBase.cs
@@ -0,0 +1,46 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+///
+/// 交易流水筛选查询基类。
+///
+public abstract class FinanceTransactionFilterQueryBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 开始时间(含)。
+ ///
+ public DateTime? StartAt { get; init; }
+
+ ///
+ /// 结束时间(不含)。
+ ///
+ public DateTime? EndAt { get; init; }
+
+ ///
+ /// 交易类型。
+ ///
+ public FinanceTransactionType? TransactionType { get; init; }
+
+ ///
+ /// 渠道。
+ ///
+ public DeliveryType? DeliveryType { get; init; }
+
+ ///
+ /// 支付方式。
+ ///
+ public PaymentMethod? PaymentMethod { get; init; }
+
+ ///
+ /// 关键词。
+ ///
+ public string? Keyword { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionDetailQuery.cs
new file mode 100644
index 0000000..66c760d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionDetailQuery.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+///
+/// 交易流水详情查询。
+///
+public sealed class GetFinanceTransactionDetailQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 来源类型。
+ ///
+ public FinanceTransactionSourceType SourceType { get; init; }
+
+ ///
+ /// 来源标识。
+ ///
+ public long SourceId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionStatsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionStatsQuery.cs
new file mode 100644
index 0000000..49b019b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionStatsQuery.cs
@@ -0,0 +1,11 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+///
+/// 交易流水统计查询。
+///
+public sealed class GetFinanceTransactionStatsQuery : FinanceTransactionFilterQueryBase, IRequest
+{
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/SearchFinanceTransactionListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/SearchFinanceTransactionListQuery.cs
new file mode 100644
index 0000000..f882625
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/SearchFinanceTransactionListQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+///
+/// 交易流水列表查询。
+///
+public sealed class SearchFinanceTransactionListQuery : FinanceTransactionFilterQueryBase, IRequest
+{
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/ExportFinanceTransactionCsvQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/ExportFinanceTransactionCsvQueryValidator.cs
new file mode 100644
index 0000000..59c16fb
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/ExportFinanceTransactionCsvQueryValidator.cs
@@ -0,0 +1,22 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
+
+///
+/// 交易流水导出查询验证器。
+///
+public sealed class ExportFinanceTransactionCsvQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceTransactionCsvQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Keyword).MaximumLength(64);
+ RuleFor(x => x)
+ .Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
+ .WithMessage("开始时间必须早于结束时间");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionDetailQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionDetailQueryValidator.cs
new file mode 100644
index 0000000..2ec5a33
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionDetailQueryValidator.cs
@@ -0,0 +1,27 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
+
+///
+/// 交易流水详情查询验证器。
+///
+public sealed class GetFinanceTransactionDetailQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceTransactionDetailQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.SourceId).GreaterThan(0);
+ RuleFor(x => x.SourceType)
+ .Must(x => x is FinanceTransactionSourceType.PaymentRecord
+ or FinanceTransactionSourceType.PaymentRefundRecord
+ or FinanceTransactionSourceType.RefundRequest
+ or FinanceTransactionSourceType.StoredCardRechargeRecord
+ or FinanceTransactionSourceType.MemberPointLedger)
+ .WithMessage("sourceType 非法");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionStatsQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionStatsQueryValidator.cs
new file mode 100644
index 0000000..a5df671
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionStatsQueryValidator.cs
@@ -0,0 +1,22 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
+
+///
+/// 交易流水统计查询验证器。
+///
+public sealed class GetFinanceTransactionStatsQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceTransactionStatsQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Keyword).MaximumLength(64);
+ RuleFor(x => x)
+ .Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
+ .WithMessage("开始时间必须早于结束时间");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/SearchFinanceTransactionListQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/SearchFinanceTransactionListQueryValidator.cs
new file mode 100644
index 0000000..a46f7c4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/SearchFinanceTransactionListQueryValidator.cs
@@ -0,0 +1,24 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Transactions.Validators;
+
+///
+/// 交易流水列表查询验证器。
+///
+public sealed class SearchFinanceTransactionListQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public SearchFinanceTransactionListQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Page).GreaterThan(0);
+ RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
+ RuleFor(x => x.Keyword).MaximumLength(64);
+ RuleFor(x => x)
+ .Must(x => !x.StartAt.HasValue || !x.EndAt.HasValue || x.StartAt < x.EndAt)
+ .WithMessage("开始时间必须早于结束时间");
+ }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionSourceType.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionSourceType.cs
new file mode 100644
index 0000000..95504c1
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionSourceType.cs
@@ -0,0 +1,32 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 交易来源类型。
+///
+public enum FinanceTransactionSourceType
+{
+ ///
+ /// 支付流水。
+ ///
+ PaymentRecord = 1,
+
+ ///
+ /// 渠道退款流水。
+ ///
+ PaymentRefundRecord = 2,
+
+ ///
+ /// 退款申请(补充来源)。
+ ///
+ RefundRequest = 3,
+
+ ///
+ /// 储值充值记录。
+ ///
+ StoredCardRechargeRecord = 4,
+
+ ///
+ /// 会员积分流水。
+ ///
+ MemberPointLedger = 5
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionType.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionType.cs
new file mode 100644
index 0000000..f61a06b
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionType.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 交易类型。
+///
+public enum FinanceTransactionType
+{
+ ///
+ /// 收入。
+ ///
+ Income = 1,
+
+ ///
+ /// 退款。
+ ///
+ Refund = 2,
+
+ ///
+ /// 储值充值。
+ ///
+ StoredCardRecharge = 3,
+
+ ///
+ /// 积分抵扣。
+ ///
+ PointRedeem = 4
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceTransactionRecord.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceTransactionRecord.cs
new file mode 100644
index 0000000..ceb70c9
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceTransactionRecord.cs
@@ -0,0 +1,173 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+
+namespace TakeoutSaaS.Domain.Finance.Models;
+
+///
+/// 交易流水统一记录。
+///
+public sealed record FinanceTransactionRecord
+{
+ ///
+ /// 来源类型。
+ ///
+ public required FinanceTransactionSourceType SourceType { get; init; }
+
+ ///
+ /// 来源标识。
+ ///
+ public required long SourceId { get; init; }
+
+ ///
+ /// 所属门店。
+ ///
+ public required long StoreId { get; init; }
+
+ ///
+ /// 交易单号。
+ ///
+ public string? TransactionNo { get; init; }
+
+ ///
+ /// 交易类型。
+ ///
+ public required FinanceTransactionType TransactionType { get; init; }
+
+ ///
+ /// 关联订单标识。
+ ///
+ public long? OrderId { get; init; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string? OrderNo { get; init; }
+
+ ///
+ /// 渠道。
+ ///
+ public DeliveryType? DeliveryType { get; init; }
+
+ ///
+ /// 支付方式。
+ ///
+ public PaymentMethod? PaymentMethod { get; init; }
+
+ ///
+ /// 交易金额(收入为正,退款为负)。
+ ///
+ public required decimal AmountSigned { get; init; }
+
+ ///
+ /// 交易时间。
+ ///
+ public required DateTime OccurredAt { get; init; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Remark { get; init; }
+
+ ///
+ /// 顾客姓名。
+ ///
+ public string? CustomerName { get; init; }
+
+ ///
+ /// 顾客手机号。
+ ///
+ public string? CustomerPhone { get; init; }
+
+ ///
+ /// 退款单号。
+ ///
+ public string? RefundNo { get; init; }
+
+ ///
+ /// 退款原因。
+ ///
+ public string? RefundReason { get; init; }
+
+ ///
+ /// 会员名称。
+ ///
+ public string? MemberName { get; init; }
+
+ ///
+ /// 会员手机号脱敏值。
+ ///
+ public string? MemberMobileMasked { get; init; }
+
+ ///
+ /// 充值金额。
+ ///
+ public decimal? RechargeAmount { get; init; }
+
+ ///
+ /// 赠送金额。
+ ///
+ public decimal? GiftAmount { get; init; }
+
+ ///
+ /// 到账金额。
+ ///
+ public decimal? ArrivedAmount { get; init; }
+
+ ///
+ /// 积分变动值。
+ ///
+ public int? PointChangeAmount { get; init; }
+
+ ///
+ /// 积分变动后余额。
+ ///
+ public int? PointBalanceAfterChange { get; init; }
+}
+
+///
+/// 交易流水分页快照。
+///
+public sealed record FinanceTransactionPageSnapshot
+{
+ ///
+ /// 分页记录。
+ ///
+ public required IReadOnlyList Items { get; init; }
+
+ ///
+ /// 总记录数。
+ ///
+ public required int TotalCount { get; init; }
+
+ ///
+ /// 本页收入合计。
+ ///
+ public required decimal PageIncomeAmount { get; init; }
+
+ ///
+ /// 本页退款合计。
+ ///
+ public required decimal PageRefundAmount { get; init; }
+}
+
+///
+/// 交易流水统计快照。
+///
+public sealed record FinanceTransactionStatsSnapshot
+{
+ ///
+ /// 总收入。
+ ///
+ public required decimal TotalIncome { get; init; }
+
+ ///
+ /// 总退款。
+ ///
+ public required decimal TotalRefund { get; init; }
+
+ ///
+ /// 总交易笔数。
+ ///
+ public required int TotalCount { get; init; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceTransactionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceTransactionRepository.cs
new file mode 100644
index 0000000..275f862
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceTransactionRepository.cs
@@ -0,0 +1,66 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+
+namespace TakeoutSaaS.Domain.Finance.Repositories;
+
+///
+/// 财务交易流水仓储契约。
+///
+public interface IFinanceTransactionRepository
+{
+ ///
+ /// 查询交易流水分页。
+ ///
+ Task SearchPageAsync(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询交易流水统计。
+ ///
+ Task GetStatsAsync(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询交易流水详情。
+ ///
+ Task GetDetailAsync(
+ long tenantId,
+ long storeId,
+ FinanceTransactionSourceType sourceType,
+ long sourceId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询导出数据。
+ ///
+ Task> ListForExportAsync(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 499c1ad..cbd7dff 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
@@ -53,6 +54,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceTransactionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceTransactionRepository.cs
new file mode 100644
index 0000000..b8e2d60
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceTransactionRepository.cs
@@ -0,0 +1,506 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Membership.Enums;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 财务交易流水 EF Core 仓储实现。
+///
+public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context) : IFinanceTransactionRepository
+{
+ ///
+ public async Task SearchPageAsync(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 构建筛选查询并读取总量。
+ var normalizedPage = Math.Max(1, page);
+ var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
+ var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
+ var totalCount = await query.CountAsync(cancellationToken);
+
+ if (totalCount == 0)
+ {
+ return new FinanceTransactionPageSnapshot
+ {
+ Items = [],
+ TotalCount = 0,
+ PageIncomeAmount = 0,
+ PageRefundAmount = 0
+ };
+ }
+
+ // 2. 分页读取记录并完成统一映射。
+ var rows = await query
+ .OrderByDescending(item => item.OccurredAt)
+ .ThenByDescending(item => item.SourceId)
+ .Skip((normalizedPage - 1) * normalizedPageSize)
+ .Take(normalizedPageSize)
+ .ToListAsync(cancellationToken);
+
+ var items = rows.Select(MapToRecord).ToList();
+
+ // 3. 汇总本页收入与退款。
+ var pageIncomeAmount = items
+ .Where(item => item.AmountSigned > 0)
+ .Sum(item => item.AmountSigned);
+ var pageRefundAmount = items
+ .Where(item => item.AmountSigned < 0)
+ .Sum(item => Math.Abs(item.AmountSigned));
+
+ return new FinanceTransactionPageSnapshot
+ {
+ Items = items,
+ TotalCount = totalCount,
+ PageIncomeAmount = decimal.Round(pageIncomeAmount, 2, MidpointRounding.AwayFromZero),
+ PageRefundAmount = decimal.Round(pageRefundAmount, 2, MidpointRounding.AwayFromZero)
+ };
+ }
+
+ ///
+ public async Task GetStatsAsync(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 构建筛选查询并聚合总览指标。
+ var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
+
+ var summary = await query
+ .GroupBy(_ => 1)
+ .Select(group => new
+ {
+ TotalIncome = group
+ .Where(item => item.AmountSigned > 0)
+ .Sum(item => item.AmountSigned),
+ TotalRefund = group
+ .Where(item => item.AmountSigned < 0)
+ .Sum(item => Math.Abs(item.AmountSigned)),
+ TotalCount = group.Count()
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (summary is null)
+ {
+ return new FinanceTransactionStatsSnapshot
+ {
+ TotalIncome = 0,
+ TotalRefund = 0,
+ TotalCount = 0
+ };
+ }
+
+ return new FinanceTransactionStatsSnapshot
+ {
+ TotalIncome = decimal.Round(summary.TotalIncome, 2, MidpointRounding.AwayFromZero),
+ TotalRefund = decimal.Round(summary.TotalRefund, 2, MidpointRounding.AwayFromZero),
+ TotalCount = summary.TotalCount
+ };
+ }
+
+ ///
+ public async Task GetDetailAsync(
+ long tenantId,
+ long storeId,
+ FinanceTransactionSourceType sourceType,
+ long sourceId,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 按来源组合键定位唯一流水。
+ var query = BuildQuery(
+ tenantId,
+ storeId,
+ startAt: null,
+ endAt: null,
+ transactionType: null,
+ deliveryType: null,
+ paymentMethod: null,
+ keyword: null);
+
+ var row = await query
+ .Where(item => item.SourceType == sourceType && item.SourceId == sourceId)
+ .OrderByDescending(item => item.OccurredAt)
+ .ThenByDescending(item => item.SourceId)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ return row is null ? null : MapToRecord(row);
+ }
+
+ ///
+ public async Task> ListForExportAsync(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 按筛选读取导出数据,限定上限保障性能。
+ var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
+
+ var rows = await query
+ .OrderByDescending(item => item.OccurredAt)
+ .ThenByDescending(item => item.SourceId)
+ .Take(20_000)
+ .ToListAsync(cancellationToken);
+
+ return rows.Select(MapToRecord).ToList();
+ }
+
+ private IQueryable BuildQuery(
+ long tenantId,
+ long storeId,
+ DateTime? startAt,
+ DateTime? endAt,
+ FinanceTransactionType? transactionType,
+ DeliveryType? deliveryType,
+ PaymentMethod? paymentMethod,
+ string? keyword)
+ {
+ // 1. 收入流水(支付成功)。
+ var incomeQuery =
+ from payment in context.PaymentRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on payment.OrderId equals order.Id
+ where payment.TenantId == tenantId
+ && order.TenantId == tenantId
+ && payment.Status == PaymentStatus.Paid
+ select new TransactionProjection
+ {
+ SourceType = FinanceTransactionSourceType.PaymentRecord,
+ SourceId = payment.Id,
+ StoreId = order.StoreId,
+ TransactionType = FinanceTransactionType.Income,
+ TransactionNo = payment.TradeNo,
+ OrderId = order.Id,
+ OrderNo = order.OrderNo,
+ DeliveryType = order.DeliveryType,
+ PaymentMethod = payment.Method,
+ AmountSigned = payment.Amount,
+ OccurredAt = payment.PaidAt ?? payment.CreatedAt,
+ Remark = payment.Remark,
+ CustomerName = order.CustomerName,
+ CustomerPhone = order.CustomerPhone
+ };
+
+ // 2. 渠道退款流水(优先来源)。
+ var channelRefundQuery =
+ from refund in context.PaymentRefundRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on refund.OrderId equals order.Id
+ join payment in context.PaymentRecords.AsNoTracking()
+ on refund.PaymentRecordId equals payment.Id into paymentGroup
+ from matchedPayment in paymentGroup.DefaultIfEmpty()
+ where refund.TenantId == tenantId
+ && order.TenantId == tenantId
+ && refund.Status == PaymentRefundStatus.Succeeded
+ select new TransactionProjection
+ {
+ SourceType = FinanceTransactionSourceType.PaymentRefundRecord,
+ SourceId = refund.Id,
+ StoreId = order.StoreId,
+ TransactionType = FinanceTransactionType.Refund,
+ TransactionNo = refund.ChannelRefundId,
+ OrderId = order.Id,
+ OrderNo = order.OrderNo,
+ DeliveryType = order.DeliveryType,
+ PaymentMethod = matchedPayment == null ? null : matchedPayment.Method,
+ AmountSigned = 0 - refund.Amount,
+ OccurredAt = refund.CompletedAt ?? refund.RequestedAt,
+ Remark = null,
+ CustomerName = order.CustomerName,
+ CustomerPhone = order.CustomerPhone
+ };
+
+ // 3. 退款申请流水(补充来源:没有渠道退款记录时使用)。
+ var requestRefundQuery =
+ from refund in context.RefundRequests.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on refund.OrderId equals order.Id
+ where refund.TenantId == tenantId
+ && order.TenantId == tenantId
+ && refund.Status == RefundStatus.Refunded
+ && !context.PaymentRefundRecords
+ .AsNoTracking()
+ .Any(channelRefund =>
+ channelRefund.TenantId == tenantId
+ && channelRefund.OrderId == refund.OrderId
+ && channelRefund.Status == PaymentRefundStatus.Succeeded)
+ select new TransactionProjection
+ {
+ SourceType = FinanceTransactionSourceType.RefundRequest,
+ SourceId = refund.Id,
+ StoreId = order.StoreId,
+ TransactionType = FinanceTransactionType.Refund,
+ TransactionNo = refund.RefundNo,
+ OrderId = order.Id,
+ OrderNo = order.OrderNo,
+ DeliveryType = order.DeliveryType,
+ PaymentMethod = context.PaymentRecords
+ .AsNoTracking()
+ .Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
+ .OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
+ .ThenByDescending(payment => payment.Id)
+ .Select(payment => (PaymentMethod?)payment.Method)
+ .FirstOrDefault(),
+ AmountSigned = 0 - refund.Amount,
+ OccurredAt = refund.ProcessedAt ?? refund.RequestedAt,
+ Remark = refund.ReviewNotes,
+ RefundNo = refund.RefundNo,
+ RefundReason = refund.Reason,
+ CustomerName = order.CustomerName,
+ CustomerPhone = order.CustomerPhone
+ };
+
+ // 4. 储值充值流水。
+ var rechargeQuery =
+ from recharge in context.MemberStoredCardRechargeRecords.AsNoTracking()
+ where recharge.TenantId == tenantId
+ select new TransactionProjection
+ {
+ SourceType = FinanceTransactionSourceType.StoredCardRechargeRecord,
+ SourceId = recharge.Id,
+ StoreId = recharge.StoreId,
+ TransactionType = FinanceTransactionType.StoredCardRecharge,
+ TransactionNo = recharge.RecordNo,
+ OrderId = null,
+ OrderNo = null,
+ DeliveryType = null,
+ PaymentMethod = recharge.PaymentMethod,
+ AmountSigned = recharge.RechargeAmount,
+ OccurredAt = recharge.RechargedAt,
+ Remark = recharge.Remark,
+ MemberName = recharge.MemberName,
+ MemberMobileMasked = recharge.MemberMobileMasked,
+ RechargeAmount = recharge.RechargeAmount,
+ GiftAmount = recharge.GiftAmount,
+ ArrivedAmount = recharge.ArrivedAmount
+ };
+
+ // 5. 积分抵扣流水(仅统计可关联订单)。
+ var pointRedeemQuery =
+ from pointLedger in context.MemberPointLedgers.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on pointLedger.SourceId equals (long?)order.Id
+ where pointLedger.TenantId == tenantId
+ && order.TenantId == tenantId
+ && pointLedger.SourceId.HasValue
+ && pointLedger.Reason == PointChangeReason.Redeem
+ && pointLedger.ChangeAmount < 0
+ select new TransactionProjection
+ {
+ SourceType = FinanceTransactionSourceType.MemberPointLedger,
+ SourceId = pointLedger.Id,
+ StoreId = order.StoreId,
+ TransactionType = FinanceTransactionType.PointRedeem,
+ TransactionNo = null,
+ OrderId = order.Id,
+ OrderNo = order.OrderNo,
+ DeliveryType = order.DeliveryType,
+ PaymentMethod = context.PaymentRecords
+ .AsNoTracking()
+ .Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
+ .OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
+ .ThenByDescending(payment => payment.Id)
+ .Select(payment => (PaymentMethod?)payment.Method)
+ .FirstOrDefault(),
+ AmountSigned = order.DiscountAmount,
+ OccurredAt = pointLedger.OccurredAt,
+ Remark = null,
+ PointChangeAmount = pointLedger.ChangeAmount,
+ PointBalanceAfterChange = pointLedger.BalanceAfterChange,
+ CustomerName = order.CustomerName,
+ CustomerPhone = order.CustomerPhone
+ };
+
+ // 6. 合并多来源并下推统一筛选条件。
+ var query = incomeQuery
+ .Concat(channelRefundQuery)
+ .Concat(requestRefundQuery)
+ .Concat(rechargeQuery)
+ .Concat(pointRedeemQuery)
+ .Where(item => item.StoreId == storeId);
+
+ if (startAt.HasValue)
+ {
+ query = query.Where(item => item.OccurredAt >= startAt.Value);
+ }
+
+ if (endAt.HasValue)
+ {
+ query = query.Where(item => item.OccurredAt < endAt.Value);
+ }
+
+ if (transactionType.HasValue)
+ {
+ query = query.Where(item => item.TransactionType == transactionType.Value);
+ }
+
+ if (deliveryType.HasValue)
+ {
+ query = query.Where(item => item.DeliveryType == deliveryType.Value);
+ }
+
+ if (paymentMethod.HasValue)
+ {
+ query = query.Where(item => item.PaymentMethod == paymentMethod.Value);
+ }
+
+ var normalizedKeyword = (keyword ?? string.Empty).Trim();
+ if (!string.IsNullOrWhiteSpace(normalizedKeyword))
+ {
+ var like = $"%{normalizedKeyword}%";
+ query = query.Where(item =>
+ EF.Functions.ILike(item.TransactionNo ?? string.Empty, like)
+ || EF.Functions.ILike(item.OrderNo ?? string.Empty, like)
+ || EF.Functions.ILike(item.RefundNo ?? string.Empty, like)
+ || EF.Functions.ILike(item.MemberName ?? string.Empty, like)
+ || EF.Functions.ILike(item.MemberMobileMasked ?? string.Empty, like));
+ }
+
+ return query;
+ }
+
+ private static FinanceTransactionRecord MapToRecord(TransactionProjection source)
+ {
+ return new FinanceTransactionRecord
+ {
+ SourceType = source.SourceType,
+ SourceId = source.SourceId,
+ StoreId = source.StoreId,
+ TransactionNo = ResolveTransactionNo(source),
+ TransactionType = source.TransactionType,
+ OrderId = source.OrderId,
+ OrderNo = source.OrderNo,
+ DeliveryType = source.DeliveryType,
+ PaymentMethod = source.PaymentMethod,
+ AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
+ OccurredAt = source.OccurredAt,
+ Remark = ResolveRemark(source),
+ CustomerName = source.CustomerName,
+ CustomerPhone = source.CustomerPhone,
+ RefundNo = source.RefundNo,
+ RefundReason = source.RefundReason,
+ MemberName = source.MemberName,
+ MemberMobileMasked = source.MemberMobileMasked,
+ RechargeAmount = source.RechargeAmount,
+ GiftAmount = source.GiftAmount,
+ ArrivedAmount = source.ArrivedAmount,
+ PointChangeAmount = source.PointChangeAmount,
+ PointBalanceAfterChange = source.PointBalanceAfterChange
+ };
+ }
+
+ private static string ResolveTransactionNo(TransactionProjection source)
+ {
+ if (!string.IsNullOrWhiteSpace(source.TransactionNo))
+ {
+ return source.TransactionNo.Trim();
+ }
+
+ return source.SourceType switch
+ {
+ FinanceTransactionSourceType.PaymentRecord => $"PAY{source.SourceId}",
+ FinanceTransactionSourceType.PaymentRefundRecord => $"REFUND{source.SourceId}",
+ FinanceTransactionSourceType.RefundRequest => $"REFREQ{source.SourceId}",
+ FinanceTransactionSourceType.StoredCardRechargeRecord => $"RECHARGE{source.SourceId}",
+ FinanceTransactionSourceType.MemberPointLedger => $"POINT{source.SourceId}",
+ _ => $"TXN{source.SourceId}"
+ };
+ }
+
+ private static string ResolveRemark(TransactionProjection source)
+ {
+ if (!string.IsNullOrWhiteSpace(source.Remark))
+ {
+ return source.Remark.Trim();
+ }
+
+ if (source.TransactionType == FinanceTransactionType.PointRedeem && source.PointChangeAmount.HasValue)
+ {
+ return $"积分抵扣{Math.Abs(source.PointChangeAmount.Value)}分";
+ }
+
+ return source.TransactionType switch
+ {
+ FinanceTransactionType.Income => "订单收款",
+ FinanceTransactionType.Refund => "订单退款",
+ FinanceTransactionType.StoredCardRecharge => "会员储值充值",
+ FinanceTransactionType.PointRedeem => "积分抵扣",
+ _ => "--"
+ };
+ }
+
+ private sealed class TransactionProjection
+ {
+ public required FinanceTransactionSourceType SourceType { get; init; }
+
+ public required long SourceId { get; init; }
+
+ public required long StoreId { get; init; }
+
+ public string? TransactionNo { get; init; }
+
+ public required FinanceTransactionType TransactionType { get; init; }
+
+ public long? OrderId { get; init; }
+
+ public string? OrderNo { get; init; }
+
+ public DeliveryType? DeliveryType { get; init; }
+
+ public PaymentMethod? PaymentMethod { get; init; }
+
+ public required decimal AmountSigned { get; init; }
+
+ public required DateTime OccurredAt { get; init; }
+
+ public string? Remark { get; init; }
+
+ public string? CustomerName { get; init; }
+
+ public string? CustomerPhone { get; init; }
+
+ public string? RefundNo { get; init; }
+
+ public string? RefundReason { get; init; }
+
+ public string? MemberName { get; init; }
+
+ public string? MemberMobileMasked { get; init; }
+
+ public decimal? RechargeAmount { get; init; }
+
+ public decimal? GiftAmount { get; init; }
+
+ public decimal? ArrivedAmount { get; init; }
+
+ public int? PointChangeAmount { get; init; }
+
+ public int? PointBalanceAfterChange { get; init; }
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260304093000_SeedFinanceTransactionMenuAndPermissions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260304093000_SeedFinanceTransactionMenuAndPermissions.cs
new file mode 100644
index 0000000..1c9e283
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260304093000_SeedFinanceTransactionMenuAndPermissions.cs
@@ -0,0 +1,301 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
+
+///
+/// 写入交易流水菜单与权限定义。
+///
+[DbContext(typeof(IdentityDbContext))]
+[Migration("20260304093000_SeedFinanceTransactionMenuAndPermissions")]
+public sealed class SeedFinanceTransactionMenuAndPermissions : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ """
+ DO $$
+ DECLARE
+ v_parent_permission_id bigint;
+ v_view_permission_id bigint;
+ v_detail_permission_id bigint;
+ v_export_permission_id bigint;
+ v_parent_menu_id bigint;
+ v_transaction_menu_id bigint;
+ v_permission_seed_base bigint := 840000000000000000;
+ v_menu_seed_base bigint := 850000000000000000;
+ BEGIN
+ -- 1. 确保财务权限分组存在。
+ SELECT "Id"
+ INTO v_parent_permission_id
+ FROM public.permissions
+ WHERE "Code" = 'group:tenant:finance'
+ ORDER BY "Id"
+ LIMIT 1;
+
+ IF v_parent_permission_id IS NULL THEN
+ v_parent_permission_id := v_permission_seed_base + 1;
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_parent_permission_id, '财务中心', 'group:tenant:finance', '财务中心权限分组',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ 0, 5000, 'group', 1)
+ ON CONFLICT ("Code") DO NOTHING;
+ END IF;
+
+ -- 2. Upsert 交易流水查看权限。
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_permission_seed_base + 11, '交易流水查看', 'tenant:finance:transaction:view', '查看交易流水列表与统计',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ v_parent_permission_id, 5010, 'leaf', 1)
+ ON CONFLICT ("Code") DO UPDATE
+ SET "Name" = EXCLUDED."Name",
+ "Description" = EXCLUDED."Description",
+ "ParentId" = EXCLUDED."ParentId",
+ "SortOrder" = EXCLUDED."SortOrder",
+ "Type" = EXCLUDED."Type",
+ "Portal" = EXCLUDED."Portal",
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW();
+
+ -- 3. Upsert 交易流水详情权限。
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_permission_seed_base + 12, '交易流水详情', 'tenant:finance:transaction:detail', '查看交易流水详情抽屉',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ v_parent_permission_id, 5020, 'leaf', 1)
+ ON CONFLICT ("Code") DO UPDATE
+ SET "Name" = EXCLUDED."Name",
+ "Description" = EXCLUDED."Description",
+ "ParentId" = EXCLUDED."ParentId",
+ "SortOrder" = EXCLUDED."SortOrder",
+ "Type" = EXCLUDED."Type",
+ "Portal" = EXCLUDED."Portal",
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW();
+
+ -- 4. Upsert 交易流水导出权限。
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_permission_seed_base + 13, '交易流水导出', 'tenant:finance:transaction:export', '导出交易流水 CSV',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ v_parent_permission_id, 5030, 'leaf', 1)
+ ON CONFLICT ("Code") DO UPDATE
+ SET "Name" = EXCLUDED."Name",
+ "Description" = EXCLUDED."Description",
+ "ParentId" = EXCLUDED."ParentId",
+ "SortOrder" = EXCLUDED."SortOrder",
+ "Type" = EXCLUDED."Type",
+ "Portal" = EXCLUDED."Portal",
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW();
+
+ -- 5. 回填权限 ID。
+ SELECT "Id" INTO v_view_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:view' LIMIT 1;
+ SELECT "Id" INTO v_detail_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:detail' LIMIT 1;
+ SELECT "Id" INTO v_export_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:transaction:export' LIMIT 1;
+
+ -- 6. 确保租户端财务父菜单存在。
+ SELECT "Id"
+ INTO v_parent_menu_id
+ FROM public.menu_definitions
+ WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
+ ORDER BY "Id"
+ LIMIT 1;
+
+ IF v_parent_menu_id IS NULL THEN
+ v_parent_menu_id := v_menu_seed_base + 1;
+ INSERT INTO public.menu_definitions (
+ "Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
+ "IsIframe", "Link", "KeepAlive", "SortOrder",
+ "RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
+ "CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
+ VALUES (
+ v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '财务中心', 'lucide:wallet',
+ FALSE, NULL, FALSE, 500,
+ '', '', '', NULL,
+ NOW(), NULL, NULL, NULL, NULL, NULL, 1)
+ ON CONFLICT ("Id") DO NOTHING;
+ END IF;
+
+ -- 7. Upsert 交易流水菜单。
+ SELECT "Id"
+ INTO v_transaction_menu_id
+ FROM public.menu_definitions
+ WHERE "Portal" = 1 AND "Path" = '/finance/transaction'
+ ORDER BY "DeletedAt" NULLS FIRST, "Id"
+ LIMIT 1;
+
+ IF v_transaction_menu_id IS NULL THEN
+ v_transaction_menu_id := v_menu_seed_base + 11;
+ INSERT INTO public.menu_definitions (
+ "Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
+ "IsIframe", "Link", "KeepAlive", "SortOrder",
+ "RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
+ "CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
+ VALUES (
+ v_transaction_menu_id, v_parent_menu_id, 'TransactionFlow', '/finance/transaction', '/finance/transaction/index', '交易流水', 'lucide:receipt',
+ FALSE, NULL, TRUE, 510,
+ 'tenant:finance:transaction:view', 'tenant:finance:transaction:view', '', NULL,
+ NOW(), NULL, NULL, NULL, NULL, NULL, 1)
+ ON CONFLICT ("Id") DO NOTHING;
+ ELSE
+ UPDATE public.menu_definitions
+ SET "ParentId" = v_parent_menu_id,
+ "Name" = 'TransactionFlow',
+ "Component" = '/finance/transaction/index',
+ "Title" = '交易流水',
+ "Icon" = 'lucide:receipt',
+ "IsIframe" = FALSE,
+ "Link" = NULL,
+ "KeepAlive" = TRUE,
+ "SortOrder" = 510,
+ "RequiredPermissions" = 'tenant:finance:transaction:view',
+ "MetaPermissions" = 'tenant:finance:transaction:view',
+ "MetaRoles" = '',
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW(),
+ "Portal" = 1
+ WHERE "Id" = v_transaction_menu_id;
+ END IF;
+
+ -- 8. 为 tenant-admin 角色直接授予新权限。
+ INSERT INTO public.role_permissions (
+ "Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
+ SELECT
+ ABS(HASHTEXTEXTENDED('tenant-admin:' || role."Id"::text || ':' || permission_id::text, 0)),
+ role."Id",
+ permission_id,
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ role."TenantId",
+ 1
+ FROM public.roles role
+ CROSS JOIN LATERAL (
+ SELECT UNNEST(ARRAY[v_view_permission_id, v_detail_permission_id, v_export_permission_id]) AS permission_id
+ ) item
+ WHERE role."Code" = 'tenant-admin'
+ AND role."DeletedAt" IS NULL
+ AND item.permission_id IS NOT NULL
+ ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
+ SET "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW(),
+ "Portal" = 1;
+
+ -- 9. 将旧财务权限角色映射到新交易流水查看/详情权限。
+ INSERT INTO public.role_permissions (
+ "Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
+ SELECT
+ ABS(HASHTEXTEXTENDED('legacy-view:' || source."RoleId"::text || ':' || item.permission_id::text, 0)),
+ source."RoleId",
+ item.permission_id,
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ source."TenantId",
+ 1
+ FROM (
+ SELECT DISTINCT rp."RoleId", rp."TenantId"
+ FROM public.role_permissions rp
+ JOIN public.permissions p ON p."Id" = rp."PermissionId"
+ WHERE p."Code" IN ('tenant:finance:statement:view', 'tenant:finance:income:view')
+ AND rp."DeletedAt" IS NULL
+ ) source
+ CROSS JOIN LATERAL (
+ SELECT UNNEST(ARRAY[v_view_permission_id, v_detail_permission_id]) AS permission_id
+ ) item
+ WHERE item.permission_id IS NOT NULL
+ ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
+ SET "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW(),
+ "Portal" = 1;
+
+ -- 10. 将旧导出权限角色映射到新交易流水导出权限。
+ INSERT INTO public.role_permissions (
+ "Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
+ SELECT
+ ABS(HASHTEXTEXTENDED('legacy-export:' || source."RoleId"::text || ':' || v_export_permission_id::text, 0)),
+ source."RoleId",
+ v_export_permission_id,
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ source."TenantId",
+ 1
+ FROM (
+ SELECT DISTINCT rp."RoleId", rp."TenantId"
+ FROM public.role_permissions rp
+ JOIN public.permissions p ON p."Id" = rp."PermissionId"
+ WHERE p."Code" IN ('tenant:finance:statement:export', 'tenant:finance:income:export')
+ AND rp."DeletedAt" IS NULL
+ ) source
+ WHERE v_export_permission_id IS NOT NULL
+ ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
+ SET "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW(),
+ "Portal" = 1;
+ END $$;
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ """
+ DO $$
+ BEGIN
+ DELETE FROM public.role_permissions
+ WHERE "PermissionId" IN (
+ SELECT "Id"
+ FROM public.permissions
+ WHERE "Code" IN (
+ 'tenant:finance:transaction:view',
+ 'tenant:finance:transaction:detail',
+ 'tenant:finance:transaction:export'));
+
+ DELETE FROM public.menu_definitions
+ WHERE "Portal" = 1 AND "Path" = '/finance/transaction';
+
+ DELETE FROM public.permissions
+ WHERE "Code" IN (
+ 'tenant:finance:transaction:view',
+ 'tenant:finance:transaction:detail',
+ 'tenant:finance:transaction:export');
+ END $$;
+ """);
+ }
+}