feat(finance): add tenant settlement query backend

This commit is contained in:
2026-03-04 15:48:37 +08:00
parent 39e28c1a62
commit b0bb87d97c
25 changed files with 11599 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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")