From d437b146d1a9abce7bb12005ab6cb4ba6e7fde97 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 4 Mar 2026 11:33:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=B5=81=E6=B0=B4=E5=90=8E=E7=AB=AF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Finance/FinanceTransactionContracts.cs | 329 ++++++++++++ .../FinanceTransactionController.cs | 343 ++++++++++++ .../Dto/FinanceTransactionDtos.cs | 256 +++++++++ ...ExportFinanceTransactionCsvQueryHandler.cs | 81 +++ .../Handlers/FinanceTransactionMapping.cs | 155 ++++++ ...GetFinanceTransactionDetailQueryHandler.cs | 32 ++ .../GetFinanceTransactionStatsQueryHandler.cs | 41 ++ ...earchFinanceTransactionListQueryHandler.cs | 49 ++ .../ExportFinanceTransactionCsvQuery.cs | 11 + .../FinanceTransactionFilterQueryBase.cs | 46 ++ .../GetFinanceTransactionDetailQuery.cs | 26 + .../GetFinanceTransactionStatsQuery.cs | 11 + .../SearchFinanceTransactionListQuery.cs | 20 + ...portFinanceTransactionCsvQueryValidator.cs | 22 + ...tFinanceTransactionDetailQueryValidator.cs | 27 + ...etFinanceTransactionStatsQueryValidator.cs | 22 + ...rchFinanceTransactionListQueryValidator.cs | 24 + .../Enums/FinanceTransactionSourceType.cs | 32 ++ .../Finance/Enums/FinanceTransactionType.cs | 27 + .../Models/FinanceTransactionRecord.cs | 173 ++++++ .../IFinanceTransactionRepository.cs | 66 +++ .../AppServiceCollectionExtensions.cs | 2 + .../EfFinanceTransactionRepository.cs | 506 ++++++++++++++++++ ...eedFinanceTransactionMenuAndPermissions.cs | 301 +++++++++++ 24 files changed, 2602 insertions(+) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceTransactionContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceTransactionController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Dto/FinanceTransactionDtos.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/ExportFinanceTransactionCsvQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/FinanceTransactionMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/GetFinanceTransactionStatsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Handlers/SearchFinanceTransactionListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/ExportFinanceTransactionCsvQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/FinanceTransactionFilterQueryBase.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/GetFinanceTransactionStatsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Queries/SearchFinanceTransactionListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/ExportFinanceTransactionCsvQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionDetailQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/GetFinanceTransactionStatsQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Transactions/Validators/SearchFinanceTransactionListQueryValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionSourceType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceTransactionType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceTransactionRecord.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceTransactionRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceTransactionRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260304093000_SeedFinanceTransactionMenuAndPermissions.cs 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 $$; + """); + } +}