feat(finance): add tenant settlement query backend
This commit is contained in:
@@ -171,6 +171,203 @@ public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context)
|
||||
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,
|
||||
@@ -385,6 +582,50 @@ public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context)
|
||||
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
|
||||
@@ -503,4 +744,60 @@ public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context)
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTenantVerificationSettlementChannels : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AlipayPid",
|
||||
table: "tenant_verification_profiles",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: true,
|
||||
comment: "支付宝 PID。");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "WeChatMerchantNo",
|
||||
table: "tenant_verification_profiles",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: true,
|
||||
comment: "微信商户号。");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AlipayPid",
|
||||
table: "tenant_verification_profiles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WeChatMerchantNo",
|
||||
table: "tenant_verification_profiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9711,6 +9711,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("text")
|
||||
.HasComment("附加资料(JSON)。");
|
||||
|
||||
b.Property<string>("AlipayPid")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("支付宝 PID。");
|
||||
|
||||
b.Property<string>("BankAccountName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
@@ -9810,6 +9815,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("WeChatMerchantNo")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("微信商户号。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
|
||||
Reference in New Issue
Block a user