feat(finance): add cost management backend module

This commit is contained in:
2026-03-04 16:07:16 +08:00
parent 39e28c1a62
commit fa6e376b86
24 changed files with 3001 additions and 0 deletions

View File

@@ -55,6 +55,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -8,6 +8,7 @@ using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Distribution.Entities;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Engagement.Entities;
using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.GroupBuying.Entities;
using TakeoutSaaS.Domain.Inventory.Entities;
using TakeoutSaaS.Domain.Membership.Entities;
@@ -94,6 +95,14 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
/// <summary>
/// 成本录入汇总。
/// </summary>
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
/// <summary>
/// 成本录入明细。
/// </summary>
public DbSet<FinanceCostEntryItem> FinanceCostEntryItems => Set<FinanceCostEntryItem>();
/// <summary>
/// 配额包定义。
/// </summary>
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
@@ -525,6 +534,8 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
@@ -1042,6 +1053,46 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.TenantId).IsUnique();
}
private static void ConfigureFinanceCostEntry(EntityTypeBuilder<FinanceCostEntry> builder)
{
builder.ToTable("finance_cost_entries");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Dimension).HasConversion<int>().IsRequired();
builder.Property(x => x.StoreId);
builder.Property(x => x.CostMonth).IsRequired();
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
builder.Property(x => x.TotalAmount).HasPrecision(18, 2);
builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth, x.Category }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth });
}
private static void ConfigureFinanceCostEntryItem(EntityTypeBuilder<FinanceCostEntryItem> builder)
{
builder.ToTable("finance_cost_entry_items");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.EntryId).IsRequired();
builder.Property(x => x.Dimension).HasConversion<int>().IsRequired();
builder.Property(x => x.StoreId);
builder.Property(x => x.CostMonth).IsRequired();
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
builder.Property(x => x.ItemName).HasMaxLength(64).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2);
builder.Property(x => x.Quantity).HasPrecision(18, 2);
builder.Property(x => x.UnitPrice).HasPrecision(18, 2);
builder.Property(x => x.SortOrder).HasDefaultValue(100);
builder.HasOne<FinanceCostEntry>()
.WithMany()
.HasForeignKey(x => x.EntryId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => x.EntryId);
builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth, x.Category, x.SortOrder });
}
private static void ConfigureTenantAnnouncement(EntityTypeBuilder<TenantAnnouncement> builder)
{
builder.ToTable("tenant_announcements");

View File

@@ -0,0 +1,527 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 成本管理 EF Core 仓储实现。
/// </summary>
public sealed class EfFinanceCostRepository(TakeoutAppDbContext context) : IFinanceCostRepository
{
private static readonly FinanceCostCategory[] CategoryOrder =
[
FinanceCostCategory.FoodMaterial,
FinanceCostCategory.Labor,
FinanceCostCategory.FixedExpense,
FinanceCostCategory.PackagingConsumable
];
/// <inheritdoc />
public async Task<FinanceCostMonthSnapshot> GetMonthSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
CancellationToken cancellationToken = default)
{
// 1. 归一化月份并加载分类快照。
var normalizedMonth = NormalizeMonthStart(costMonth);
var normalizedStoreId = NormalizeStoreId(dimension, storeId);
var categories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
// 2. 读取本月营业额(真实订单与支付记录聚合)。
var monthRevenue = await GetRevenueByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
return new FinanceCostMonthSnapshot
{
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
MonthRevenue = monthRevenue,
Categories = categories
};
}
/// <inheritdoc />
public async Task SaveMonthSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
IReadOnlyList<FinanceCostCategorySnapshot> categories,
CancellationToken cancellationToken = default)
{
// 1. 归一化入参与分类数据。
var normalizedMonth = NormalizeMonthStart(costMonth);
var normalizedStoreId = NormalizeStoreId(dimension, storeId);
var normalizedCategories = NormalizeCategoriesForSave(categories);
// 2. 删除同维度同月份历史记录(先删明细,再删汇总)。
var existingEntries = await context.FinanceCostEntries
.Where(item =>
item.TenantId == tenantId &&
item.Dimension == dimension &&
item.CostMonth == normalizedMonth &&
((dimension == FinanceCostDimension.Store && item.StoreId == normalizedStoreId) ||
(dimension == FinanceCostDimension.Tenant && item.StoreId == null)))
.ToListAsync(cancellationToken);
var existingEntryIds = existingEntries.Select(item => item.Id).ToList();
if (existingEntryIds.Count > 0)
{
var existingItems = await context.FinanceCostEntryItems
.Where(item => existingEntryIds.Contains(item.EntryId))
.ToListAsync(cancellationToken);
context.FinanceCostEntryItems.RemoveRange(existingItems);
}
context.FinanceCostEntries.RemoveRange(existingEntries);
await context.SaveChangesAsync(cancellationToken);
// 3. 新增汇总行并持久化,拿到主键。
var newEntries = normalizedCategories
.Select(item => new FinanceCostEntry
{
TenantId = tenantId,
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
Category = item.Category,
TotalAmount = RoundAmount(item.TotalAmount)
})
.ToList();
if (newEntries.Count > 0)
{
await context.FinanceCostEntries.AddRangeAsync(newEntries, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
// 4. 写入明细项并持久化。
var entryIdMap = newEntries.ToDictionary(item => item.Category, item => item.Id);
var newItems = new List<FinanceCostEntryItem>();
foreach (var category in normalizedCategories)
{
if (!entryIdMap.TryGetValue(category.Category, out var entryId))
{
continue;
}
foreach (var detail in category.Items.OrderBy(item => item.SortOrder).ThenBy(item => item.ItemName))
{
newItems.Add(new FinanceCostEntryItem
{
TenantId = tenantId,
EntryId = entryId,
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
Category = category.Category,
ItemName = detail.ItemName.Trim(),
Amount = RoundAmount(detail.Amount),
Quantity = detail.Quantity.HasValue ? RoundAmount(detail.Quantity.Value) : null,
UnitPrice = detail.UnitPrice.HasValue ? RoundAmount(detail.UnitPrice.Value) : null,
SortOrder = detail.SortOrder
});
}
}
if (newItems.Count > 0)
{
await context.FinanceCostEntryItems.AddRangeAsync(newItems, cancellationToken);
}
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<FinanceCostAnalysisSnapshot> GetAnalysisSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
int trendMonthCount,
CancellationToken cancellationToken = default)
{
// 1. 归一化参数并生成趋势月份序列。
var normalizedMonth = NormalizeMonthStart(costMonth);
var normalizedStoreId = NormalizeStoreId(dimension, storeId);
var normalizedTrendCount = Math.Clamp(trendMonthCount, 3, 12);
var trendMonths = BuildTrendMonths(normalizedMonth, normalizedTrendCount);
// 2. 读取当前月分类、营业额、已支付订单量。
var currentCategories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
var currentTotalCost = RoundAmount(currentCategories.Sum(item => item.TotalAmount));
var currentFoodAmount = RoundAmount(currentCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.FoodMaterial)
?.TotalAmount ?? 0m);
var currentRevenue = await GetRevenueByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
var currentPaidOrderCount = await GetPaidOrderCountByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
// 3. 计算环比变化(与上月总成本对比)。
var previousMonth = normalizedMonth.AddMonths(-1);
var previousCategories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
previousMonth,
cancellationToken);
var previousTotalCost = RoundAmount(previousCategories.Sum(item => item.TotalAmount));
var monthOnMonthChangeRate = CalculateMonthOnMonthChangeRate(currentTotalCost, previousTotalCost);
// 4. 组装趋势与明细表行。
var trends = new List<FinanceCostTrendSnapshot>(trendMonths.Count);
var detailRows = new List<FinanceCostMonthlyDetailSnapshot>(trendMonths.Count);
foreach (var month in trendMonths)
{
var monthCategories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
month,
cancellationToken);
var monthRevenue = await GetRevenueByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
month,
cancellationToken);
var foodAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.FoodMaterial)
?.TotalAmount ?? 0m);
var laborAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.Labor)
?.TotalAmount ?? 0m);
var fixedAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.FixedExpense)
?.TotalAmount ?? 0m);
var packagingAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.PackagingConsumable)
?.TotalAmount ?? 0m);
var monthTotalCost = RoundAmount(foodAmount + laborAmount + fixedAmount + packagingAmount);
trends.Add(new FinanceCostTrendSnapshot
{
MonthStartUtc = month,
TotalCost = monthTotalCost,
Revenue = monthRevenue
});
detailRows.Add(new FinanceCostMonthlyDetailSnapshot
{
MonthStartUtc = month,
FoodAmount = foodAmount,
LaborAmount = laborAmount,
FixedAmount = fixedAmount,
PackagingAmount = packagingAmount,
TotalCost = monthTotalCost,
Revenue = monthRevenue
});
}
return new FinanceCostAnalysisSnapshot
{
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
CurrentTotalCost = currentTotalCost,
CurrentFoodAmount = currentFoodAmount,
CurrentRevenue = currentRevenue,
CurrentPaidOrderCount = currentPaidOrderCount,
MonthOnMonthChangeRate = monthOnMonthChangeRate,
CurrentCategories = currentCategories,
Trends = trends,
DetailRows = detailRows
};
}
private async Task<IReadOnlyList<FinanceCostCategorySnapshot>> GetCategorySnapshotsAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime month,
CancellationToken cancellationToken)
{
// 1. 读取当月汇总与明细。
var entryQuery = context.FinanceCostEntries
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.Dimension == dimension &&
item.CostMonth == month &&
((dimension == FinanceCostDimension.Store && item.StoreId == storeId) ||
(dimension == FinanceCostDimension.Tenant && item.StoreId == null)));
var entries = await entryQuery.ToListAsync(cancellationToken);
var entryIds = entries.Select(item => item.Id).ToList();
var items = entryIds.Count == 0
? []
: await context.FinanceCostEntryItems
.AsNoTracking()
.Where(item => entryIds.Contains(item.EntryId))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.ToListAsync(cancellationToken);
// 2. 按分类聚合,补齐默认分类顺序。
var entryMap = entries.ToDictionary(item => item.Category, item => item);
var itemGroupMap = items
.GroupBy(item => item.Category)
.ToDictionary(group => group.Key, group => group.ToList());
return CategoryOrder.Select(category =>
{
var totalAmount = entryMap.TryGetValue(category, out var entry)
? RoundAmount(entry.TotalAmount)
: 0m;
var details = itemGroupMap.TryGetValue(category, out var group)
? group.Select(detail => new FinanceCostDetailItemSnapshot
{
ItemId = detail.Id,
ItemName = detail.ItemName,
Amount = RoundAmount(detail.Amount),
Quantity = detail.Quantity.HasValue ? RoundAmount(detail.Quantity.Value) : null,
UnitPrice = detail.UnitPrice.HasValue ? RoundAmount(detail.UnitPrice.Value) : null,
SortOrder = detail.SortOrder
}).ToList()
: [];
return new FinanceCostCategorySnapshot
{
Category = category,
TotalAmount = totalAmount,
Items = details
};
}).ToList();
}
private async Task<decimal> GetRevenueByMonthAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime monthStartUtc,
CancellationToken cancellationToken)
{
var monthEnd = monthStartUtc.AddMonths(1);
// 1. 聚合支付成功金额。
var paidQuery =
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
&& (payment.PaidAt ?? payment.CreatedAt) >= monthStartUtc
&& (payment.PaidAt ?? payment.CreatedAt) < monthEnd
select new
{
order.StoreId,
payment.Amount
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
paidQuery = paidQuery.Where(item => item.StoreId == storeId.Value);
}
var totalPaidAmount = await paidQuery
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
// 2. 聚合退款成功金额。
var refundQuery =
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& refund.Status == PaymentRefundStatus.Succeeded
&& (refund.CompletedAt ?? refund.RequestedAt) >= monthStartUtc
&& (refund.CompletedAt ?? refund.RequestedAt) < monthEnd
select new
{
order.StoreId,
refund.Amount
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
refundQuery = refundQuery.Where(item => item.StoreId == storeId.Value);
}
var totalRefundAmount = await refundQuery
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
return RoundAmount(totalPaidAmount - totalRefundAmount);
}
private async Task<int> GetPaidOrderCountByMonthAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime monthStartUtc,
CancellationToken cancellationToken)
{
var monthEnd = monthStartUtc.AddMonths(1);
var paidOrderQuery =
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
&& (payment.PaidAt ?? payment.CreatedAt) >= monthStartUtc
&& (payment.PaidAt ?? payment.CreatedAt) < monthEnd
select new
{
order.StoreId,
payment.OrderId
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
paidOrderQuery = paidOrderQuery.Where(item => item.StoreId == storeId.Value);
}
return await paidOrderQuery
.Select(item => item.OrderId)
.Distinct()
.CountAsync(cancellationToken);
}
private static IReadOnlyList<FinanceCostCategorySnapshot> NormalizeCategoriesForSave(
IReadOnlyList<FinanceCostCategorySnapshot> categories)
{
var source = categories ?? [];
var map = source
.GroupBy(item => item.Category)
.ToDictionary(group => group.Key, group => group.First());
return CategoryOrder.Select((category, index) =>
{
if (!map.TryGetValue(category, out var current))
{
return new FinanceCostCategorySnapshot
{
Category = category,
TotalAmount = 0m,
Items = []
};
}
var normalizedItems = (current.Items ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.ItemName))
.Select((item, itemIndex) => new FinanceCostDetailItemSnapshot
{
ItemId = item.ItemId,
ItemName = item.ItemName.Trim(),
Amount = RoundAmount(item.Amount),
Quantity = item.Quantity.HasValue ? RoundAmount(item.Quantity.Value) : null,
UnitPrice = item.UnitPrice.HasValue ? RoundAmount(item.UnitPrice.Value) : null,
SortOrder = item.SortOrder <= 0 ? itemIndex + 1 : item.SortOrder
})
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.ItemName)
.ToList();
var totalAmount = current.TotalAmount > 0
? RoundAmount(current.TotalAmount)
: RoundAmount(normalizedItems.Sum(item => item.Amount));
return new FinanceCostCategorySnapshot
{
Category = category,
TotalAmount = totalAmount,
Items = normalizedItems
};
}).ToList();
}
private static DateTime NormalizeMonthStart(DateTime value)
{
var utcValue = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return new DateTime(utcValue.Year, utcValue.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
private static long? NormalizeStoreId(FinanceCostDimension dimension, long? storeId)
{
if (dimension == FinanceCostDimension.Tenant)
{
return null;
}
return storeId.HasValue && storeId.Value > 0
? storeId.Value
: null;
}
private static decimal RoundAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal CalculateMonthOnMonthChangeRate(decimal currentValue, decimal previousValue)
{
if (previousValue <= 0)
{
return currentValue <= 0 ? 0m : 100m;
}
var rate = (currentValue - previousValue) / previousValue * 100m;
return RoundAmount(rate);
}
private static List<DateTime> BuildTrendMonths(DateTime currentMonth, int trendMonthCount)
{
var startMonth = currentMonth.AddMonths(0 - Math.Max(1, trendMonthCount) + 1);
var result = new List<DateTime>(trendMonthCount);
for (var index = 0; index < trendMonthCount; index++)
{
result.Add(startMonth.AddMonths(index));
}
return result;
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.App.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations;
/// <summary>
/// 新增财务中心成本管理表结构。
/// </summary>
[DbContext(typeof(TakeoutAppDbContext))]
[Migration("20260305010000_AddFinanceCostModule")]
public sealed class AddFinanceCostModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_cost_entries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份UTC 每月第一天)。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
TotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "分类总金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_entries", x => x.Id);
},
comment: "财务成本录入月度汇总。");
migrationBuilder.CreateTable(
name: "finance_cost_entry_items",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EntryId = table.Column<long>(type: "bigint", nullable: false, comment: "关联汇总标识。"),
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份UTC 每月第一天)。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
ItemName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "明细名称。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "明细金额。"),
Quantity = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "数量(人工类可用)。"),
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "单价(人工类可用)。"),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_entry_items", x => x.Id);
table.ForeignKey(
name: "FK_finance_cost_entry_items_finance_cost_entries_EntryId",
column: x => x.EntryId,
principalTable: "finance_cost_entries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
},
comment: "财务成本录入明细项。");
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth",
table: "finance_cost_entries",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth" });
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth_C~",
table: "finance_cost_entries",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entry_items_EntryId",
table: "finance_cost_entry_items",
column: "EntryId");
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entry_items_TenantId_Dimension_StoreId_CostMon~",
table: "finance_cost_entry_items",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_cost_entry_items");
migrationBuilder.DropTable(
name: "finance_cost_entries");
}
}

View File

@@ -0,0 +1,243 @@
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("20260305013000_SeedFinanceCostMenuAndPermissions")]
public sealed class SeedFinanceCostMenuAndPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
DECLARE
v_parent_permission_id bigint;
v_view_permission_id bigint;
v_manage_permission_id bigint;
v_parent_menu_id bigint;
v_cost_menu_id bigint;
v_permission_seed_base bigint := 840100000000000000;
v_menu_seed_base bigint := 850100000000000000;
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:cost:view', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5110, '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:cost:manage', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5120, '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. ID
SELECT "Id" INTO v_view_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:cost:view' LIMIT 1;
SELECT "Id" INTO v_manage_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:cost:manage' LIMIT 1;
-- 5.
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;
-- 6. Upsert
SELECT "Id"
INTO v_cost_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1
AND ("Path" = '/finance/cost' OR ("Path" = 'cost' AND "Component" = '/finance/cost/index'))
ORDER BY "DeletedAt" NULLS FIRST, "Id"
LIMIT 1;
IF v_cost_menu_id IS NULL THEN
v_cost_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_cost_menu_id, v_parent_menu_id, 'CostManagement', '/finance/cost', '/finance/cost/index', '', 'lucide:circle-dollar-sign',
FALSE, NULL, TRUE, 520,
'tenant:finance:cost:view', 'tenant:finance:cost:view,tenant:finance:cost:manage', '', 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" = 'CostManagement',
"Path" = '/finance/cost',
"Component" = '/finance/cost/index',
"Title" = '',
"Icon" = 'lucide:circle-dollar-sign',
"IsIframe" = FALSE,
"Link" = NULL,
"KeepAlive" = TRUE,
"SortOrder" = 520,
"RequiredPermissions" = 'tenant:finance:cost:view',
"MetaPermissions" = 'tenant:finance:cost:view,tenant:finance:cost:manage',
"MetaRoles" = '',
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1
WHERE "Id" = v_cost_menu_id;
END IF;
-- 7. tenant-admin
INSERT INTO public.role_permissions (
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
SELECT
ABS(HASHTEXTEXTENDED('tenant-admin:cost:' || 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_manage_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;
-- 8. tenant-admin
INSERT INTO public.role_template_permissions (
"Id", "RoleTemplateId", "PermissionCode",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy")
SELECT
ABS(HASHTEXTEXTENDED('template-cost:' || template."Id"::text || ':' || item.permission_code, 0)),
template."Id",
item.permission_code,
NOW(), NULL, NULL,
NULL, NULL, NULL
FROM public.role_templates template
CROSS JOIN LATERAL (
SELECT UNNEST(ARRAY['tenant:finance:cost:view', 'tenant:finance:cost:manage']) AS permission_code
) item
WHERE template."TemplateCode" = 'tenant-admin'
AND template."DeletedAt" IS NULL
ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
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:cost:view', 'tenant:finance:cost:manage'));
DELETE FROM public.role_template_permissions
WHERE "PermissionCode" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage');
DELETE FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance/cost';
DELETE FROM public.permissions
WHERE "Code" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage');
END $$;
""");
}
}