feat: 新增财务交易流水后端模块
This commit is contained in:
@@ -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<IMemberRepository, EfMemberRepository>();
|
||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
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 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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
|
||||
|
||||
/// <summary>
|
||||
/// 写入交易流水菜单与权限定义。
|
||||
/// </summary>
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260304093000_SeedFinanceTransactionMenuAndPermissions")]
|
||||
public sealed class SeedFinanceTransactionMenuAndPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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 $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 $$;
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user