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(); } /// public async Task GetSettlementStatsAsync( long tenantId, long storeId, DateTime currentUtc, CancellationToken cancellationToken = default) { var utcNow = NormalizeUtc(currentUtc); var todayStart = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, 0, 0, 0, DateTimeKind.Utc); var tomorrowStart = todayStart.AddDays(1); var yesterdayStart = todayStart.AddDays(-1); var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); var monthEnd = monthStart.AddMonths(1); var query = BuildSettlementPaymentQuery( tenantId, storeId, startAt: null, endAt: null, paymentMethod: null); var summary = await query .GroupBy(_ => 1) .Select(group => new { TodayArrivedAmount = group .Where(item => item.PaidAt >= todayStart && item.PaidAt < tomorrowStart) .Sum(item => item.Amount), YesterdayArrivedAmount = group .Where(item => item.PaidAt >= yesterdayStart && item.PaidAt < todayStart) .Sum(item => item.Amount), CurrentMonthArrivedAmount = group .Where(item => item.PaidAt >= monthStart && item.PaidAt < monthEnd) .Sum(item => item.Amount), CurrentMonthTransactionCount = group .Count(item => item.PaidAt >= monthStart && item.PaidAt < monthEnd) }) .FirstOrDefaultAsync(cancellationToken); if (summary is null) { return new FinanceSettlementStatsSnapshot { TodayArrivedAmount = 0, YesterdayArrivedAmount = 0, CurrentMonthArrivedAmount = 0, CurrentMonthTransactionCount = 0 }; } return new FinanceSettlementStatsSnapshot { TodayArrivedAmount = decimal.Round(summary.TodayArrivedAmount, 2, MidpointRounding.AwayFromZero), YesterdayArrivedAmount = decimal.Round(summary.YesterdayArrivedAmount, 2, MidpointRounding.AwayFromZero), CurrentMonthArrivedAmount = decimal.Round(summary.CurrentMonthArrivedAmount, 2, MidpointRounding.AwayFromZero), CurrentMonthTransactionCount = summary.CurrentMonthTransactionCount }; } /// public async Task GetSettlementAccountAsync( long tenantId, CancellationToken cancellationToken = default) { var profile = await context.TenantVerificationProfiles .AsNoTracking() .Where(item => item.TenantId == tenantId && item.DeletedAt == null) .Select(item => new { item.BankName, item.BankAccountName, item.BankAccountNumber, item.WeChatMerchantNo, item.AlipayPid }) .FirstOrDefaultAsync(cancellationToken); if (profile is null) { return null; } return new FinanceSettlementAccountSnapshot { BankName = (profile.BankName ?? string.Empty).Trim(), BankAccountName = (profile.BankAccountName ?? string.Empty).Trim(), BankAccountNoMasked = MaskBankAccountNo(profile.BankAccountNumber), WechatMerchantNoMasked = MaskWechatMerchantNo(profile.WeChatMerchantNo), AlipayPidMasked = MaskAlipayPid(profile.AlipayPid), SettlementPeriodText = "T+1 自动到账" }; } /// public async Task SearchSettlementPageAsync( long tenantId, long storeId, DateTime? startAt, DateTime? endAt, PaymentMethod? paymentMethod, int page, int pageSize, CancellationToken cancellationToken = default) { var normalizedPage = Math.Max(1, page); var normalizedPageSize = Math.Clamp(pageSize, 1, 200); var groupedQuery = BuildSettlementPaymentQuery(tenantId, storeId, startAt, endAt, paymentMethod) .GroupBy(item => new { ArrivedDate = item.PaidAt.Date, item.PaymentMethod }) .Select(group => new FinanceSettlementListItemSnapshot { ArrivedDate = DateTime.SpecifyKind(group.Key.ArrivedDate, DateTimeKind.Utc), PaymentMethod = group.Key.PaymentMethod, TransactionCount = group.Count(), ArrivedAmount = decimal.Round(group.Sum(item => item.Amount), 2, MidpointRounding.AwayFromZero) }); var totalCount = await groupedQuery.CountAsync(cancellationToken); if (totalCount == 0) { return new FinanceSettlementPageSnapshot { Items = [], TotalCount = 0 }; } var items = await groupedQuery .OrderByDescending(item => item.ArrivedDate) .ThenBy(item => item.PaymentMethod) .Skip((normalizedPage - 1) * normalizedPageSize) .Take(normalizedPageSize) .ToListAsync(cancellationToken); return new FinanceSettlementPageSnapshot { Items = items, TotalCount = totalCount }; } /// public async Task> GetSettlementDetailsAsync( long tenantId, long storeId, DateTime arrivedDate, PaymentMethod paymentMethod, int take, CancellationToken cancellationToken = default) { var arrivedDay = NormalizeUtc(arrivedDate); var dayStart = new DateTime(arrivedDay.Year, arrivedDay.Month, arrivedDay.Day, 0, 0, 0, DateTimeKind.Utc); var dayEnd = dayStart.AddDays(1); var normalizedTake = Math.Clamp(take, 1, 200); return await BuildSettlementPaymentQuery( tenantId, storeId, dayStart, dayEnd, paymentMethod) .OrderByDescending(item => item.PaidAt) .ThenByDescending(item => item.PaymentRecordId) .Select(item => new FinanceSettlementDetailItemSnapshot { OrderNo = item.OrderNo, Amount = decimal.Round(item.Amount, 2, MidpointRounding.AwayFromZero), PaidAt = item.PaidAt }) .Take(normalizedTake) .ToListAsync(cancellationToken); } /// public async Task> ListSettlementForExportAsync( long tenantId, long storeId, DateTime? startAt, DateTime? endAt, PaymentMethod? paymentMethod, CancellationToken cancellationToken = default) { return await BuildSettlementPaymentQuery(tenantId, storeId, startAt, endAt, paymentMethod) .GroupBy(item => new { ArrivedDate = item.PaidAt.Date, item.PaymentMethod }) .Select(group => new FinanceSettlementListItemSnapshot { ArrivedDate = DateTime.SpecifyKind(group.Key.ArrivedDate, DateTimeKind.Utc), PaymentMethod = group.Key.PaymentMethod, TransactionCount = group.Count(), ArrivedAmount = decimal.Round(group.Sum(item => item.Amount), 2, MidpointRounding.AwayFromZero) }) .OrderByDescending(item => item.ArrivedDate) .ThenBy(item => item.PaymentMethod) .Take(20_000) .ToListAsync(cancellationToken); } 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 IQueryable BuildSettlementPaymentQuery( long tenantId, long storeId, DateTime? startAt, DateTime? endAt, PaymentMethod? paymentMethod) { var query = 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 && order.StoreId == storeId && payment.Status == PaymentStatus.Paid && payment.PaidAt.HasValue && (payment.Method == PaymentMethod.WeChatPay || payment.Method == PaymentMethod.Alipay) select new SettlementPaymentProjection { PaymentRecordId = payment.Id, OrderNo = order.OrderNo, PaymentMethod = payment.Method, Amount = payment.Amount, PaidAt = payment.PaidAt!.Value }; if (startAt.HasValue) { query = query.Where(item => item.PaidAt >= startAt.Value); } if (endAt.HasValue) { query = query.Where(item => item.PaidAt < endAt.Value); } if (paymentMethod.HasValue) { query = query.Where(item => item.PaymentMethod == paymentMethod.Value); } 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; } } private sealed class SettlementPaymentProjection { public required long PaymentRecordId { get; init; } public required string OrderNo { get; init; } public required PaymentMethod PaymentMethod { get; init; } public required decimal Amount { get; init; } public required DateTime PaidAt { get; init; } } private static DateTime NormalizeUtc(DateTime value) { return value.Kind switch { DateTimeKind.Utc => value, DateTimeKind.Local => value.ToUniversalTime(), _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) }; } private static string MaskBankAccountNo(string? value) { var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray()); if (digits.Length >= 4) { return $"****{digits[^4..]}"; } return digits; } private static string MaskWechatMerchantNo(string? value) { var normalized = (value ?? string.Empty).Trim(); if (normalized.Length >= 4) { return $"{normalized[..2]}{new string('x', normalized.Length - 2)}"; } return normalized; } private static string MaskAlipayPid(string? value) { var normalized = (value ?? string.Empty).Trim(); if (normalized.Length > 6) { return $"{normalized[..4]}{new string('x', normalized.Length - 4)}"; } return normalized; } }