804 lines
30 KiB
C#
804 lines
30 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 财务交易流水 EF Core 仓储实现。
|
|
/// </summary>
|
|
public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context) : IFinanceTransactionRepository
|
|
{
|
|
/// <inheritdoc />
|
|
public async Task<FinanceTransactionPageSnapshot> 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)
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FinanceTransactionStatsSnapshot> 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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FinanceTransactionRecord?> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FinanceTransactionRecord>> 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();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FinanceSettlementStatsSnapshot> 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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FinanceSettlementAccountSnapshot?> 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 自动到账"
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FinanceSettlementPageSnapshot> 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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FinanceSettlementDetailItemSnapshot>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FinanceSettlementListItemSnapshot>> 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<TransactionProjection> 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<SettlementPaymentProjection> 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;
|
|
}
|
|
}
|