feat: 新增财务交易流水后端模块

This commit is contained in:
2026-03-04 11:33:29 +08:00
parent 2970134200
commit d437b146d1
24 changed files with 2602 additions and 0 deletions

View File

@@ -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>();

View File

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

View File

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