feat(finance): add overview dashboard and platform fee rate

This commit is contained in:
2026-03-05 10:47:15 +08:00
parent fdbefca650
commit f7eba55039
24 changed files with 2279 additions and 131 deletions

View File

@@ -57,6 +57,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
services.AddScoped<IFinanceOverviewRepository, EfFinanceOverviewRepository>();
services.AddScoped<IFinanceBusinessReportRepository, EfFinanceBusinessReportRepository>();
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();

View File

@@ -810,6 +810,7 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
builder.Property(x => x.PlatformServiceRate).HasPrecision(5, 2);
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);

View File

@@ -0,0 +1,615 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
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 EfFinanceOverviewRepository(TakeoutAppDbContext context) : IFinanceOverviewRepository
{
private const int TrendDays = 30;
private const int TopProductDays = 30;
/// <inheritdoc />
public async Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(currentUtc);
var todayStart = NormalizeDate(utcNow);
var tomorrowStart = todayStart.AddDays(1);
var yesterdayStart = todayStart.AddDays(-1);
var trendStart = todayStart.AddDays(0 - TrendDays + 1);
var weekStart = todayStart.AddDays(-7);
// 1. 查询支付与退款基础数据。
var paidQuery = BuildPaidRecordQuery(tenantId, dimension, storeId);
var refundQuery = BuildRefundRecordQuery(tenantId, dimension, storeId);
// 2. 读取近 30 天按日按门店支付与退款汇总。
var paidDailyRows = await paidQuery
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
.Select(group => new DailyStoreAmountRow
{
BusinessDate = group.Key.Date,
StoreId = group.Key.StoreId,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
var refundDailyRows = await refundQuery
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
.Select(group => new DailyStoreAmountRow
{
BusinessDate = group.Key.Date,
StoreId = group.Key.StoreId,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
// 3. 读取今日收入构成(渠道维度)。
var paidTodayByChannel = await paidQuery
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => item.DeliveryType)
.Select(group => new DailyChannelAmountRow
{
Channel = group.Key,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
var refundTodayByChannel = await refundQuery
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => item.DeliveryType)
.Select(group => new DailyChannelAmountRow
{
Channel = group.Key,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
// 4. 读取作用域门店平台费率。
var scopedStoreIds = paidDailyRows
.Select(item => item.StoreId)
.Concat(refundDailyRows.Select(item => item.StoreId))
.Distinct()
.ToList();
if (dimension == FinanceCostDimension.Store && storeId.HasValue && !scopedStoreIds.Contains(storeId.Value))
{
scopedStoreIds.Add(storeId.Value);
}
var platformRateMap = scopedStoreIds.Count == 0
? new Dictionary<long, decimal>()
: await context.StoreFees
.AsNoTracking()
.Where(item => item.TenantId == tenantId && scopedStoreIds.Contains(item.StoreId))
.Select(item => new { item.StoreId, item.PlatformServiceRate })
.ToDictionaryAsync(item => item.StoreId, item => item.PlatformServiceRate, cancellationToken);
// 5. 近 30 天按日收入/退款/平台成本汇总。
var grossByDate = new Dictionary<DateTime, decimal>();
var refundByDate = new Dictionary<DateTime, decimal>();
var platformCostByDate = new Dictionary<DateTime, decimal>();
foreach (var row in paidDailyRows)
{
var businessDate = NormalizeDate(row.BusinessDate);
AddAmount(grossByDate, businessDate, row.Amount);
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
AddAmount(platformCostByDate, businessDate, row.Amount * platformRate / 100m);
}
foreach (var row in refundDailyRows)
{
var businessDate = NormalizeDate(row.BusinessDate);
AddAmount(refundByDate, businessDate, row.Amount);
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
AddAmount(platformCostByDate, businessDate, 0m - row.Amount * platformRate / 100m);
}
// 6. 读取近 30 天覆盖月份的月度成本录入,并折算为日成本。
var monthStarts = BuildMonthStartRange(trendStart, todayStart);
var monthlyCostRows = await BuildMonthlyCostQuery(tenantId, dimension, storeId, monthStarts)
.Select(item => new MonthlyCostRow
{
CostMonth = item.CostMonth,
Category = item.Category,
Amount = item.TotalAmount
})
.ToListAsync(cancellationToken);
var monthlyCostMap = BuildMonthlyCostMap(monthlyCostRows);
// 7. 组装趋势数据与今日/昨日成本构成。
var incomeTrend = new List<FinanceOverviewIncomeTrendPointSnapshot>(TrendDays);
var profitTrend = new List<FinanceOverviewProfitTrendPointSnapshot>(TrendDays);
var todayCostComposition = CreateEmptyCostComposition();
var yesterdayCostComposition = CreateEmptyCostComposition();
for (var currentDate = trendStart; currentDate <= todayStart; currentDate = currentDate.AddDays(1))
{
var grossAmount = RoundAmount(GetAmount(grossByDate, currentDate));
var refundAmount = RoundAmount(GetAmount(refundByDate, currentDate));
var netReceived = RoundAmount(grossAmount - refundAmount);
var platformCost = RoundAmount(GetAmount(platformCostByDate, currentDate));
var baseCost = GetDailyBaseCost(currentDate, monthlyCostMap);
var totalCost = RoundAmount(baseCost.TotalCost + platformCost);
var netProfit = RoundAmount(netReceived - totalCost);
incomeTrend.Add(new FinanceOverviewIncomeTrendPointSnapshot
{
BusinessDate = currentDate,
NetReceivedAmount = netReceived
});
profitTrend.Add(new FinanceOverviewProfitTrendPointSnapshot
{
BusinessDate = currentDate,
RevenueAmount = grossAmount,
CostAmount = totalCost,
NetProfitAmount = netProfit
});
if (currentDate == todayStart)
{
todayCostComposition = new DailyCostAmounts
{
Food = baseCost.Food,
Labor = baseCost.Labor,
Fixed = baseCost.Fixed,
Packaging = baseCost.Packaging,
Platform = platformCost
};
}
if (currentDate == yesterdayStart)
{
yesterdayCostComposition = new DailyCostAmounts
{
Food = baseCost.Food,
Labor = baseCost.Labor,
Fixed = baseCost.Fixed,
Packaging = baseCost.Packaging,
Platform = platformCost
};
}
}
// 8. 汇总核心指标。
var todayGrossRevenue = RoundAmount(GetAmount(grossByDate, todayStart));
var yesterdayGrossRevenue = RoundAmount(GetAmount(grossByDate, yesterdayStart));
var todayRefundAmount = RoundAmount(GetAmount(refundByDate, todayStart));
var yesterdayRefundAmount = RoundAmount(GetAmount(refundByDate, yesterdayStart));
var todayNetReceived = RoundAmount(todayGrossRevenue - todayRefundAmount);
var yesterdayNetReceived = RoundAmount(yesterdayGrossRevenue - yesterdayRefundAmount);
var todayTotalCost = RoundAmount(todayCostComposition.TotalCost);
var yesterdayTotalCost = RoundAmount(yesterdayCostComposition.TotalCost);
var paidTotalAmount = await paidQuery
.Where(item => item.OccurredAt < tomorrowStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var refundTotalAmount = await refundQuery
.Where(item => item.OccurredAt < tomorrowStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var paidBeforeWeekAmount = await paidQuery
.Where(item => item.OccurredAt < weekStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var refundBeforeWeekAmount = await refundQuery
.Where(item => item.OccurredAt < weekStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var withdrawableBalance = RoundAmount(paidTotalAmount - refundTotalAmount);
var withdrawableBalanceLastWeek = RoundAmount(paidBeforeWeekAmount - refundBeforeWeekAmount);
// 9. 收入构成映射(今日)。
var paidChannelMap = paidTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
var refundChannelMap = refundTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
var incomeComposition = new List<FinanceOverviewIncomeCompositionSnapshot>(3);
foreach (var channel in new[] { DeliveryType.Delivery, DeliveryType.Pickup, DeliveryType.DineIn })
{
paidChannelMap.TryGetValue(channel, out var paidAmount);
refundChannelMap.TryGetValue(channel, out var refundAmount);
incomeComposition.Add(new FinanceOverviewIncomeCompositionSnapshot
{
Channel = channel,
Amount = RoundAmount(Math.Max(0m, paidAmount - refundAmount))
});
}
// 10. 成本构成映射(今日)。
var costComposition = new List<FinanceOverviewCostCompositionSnapshot>
{
new() { CategoryCode = "food", Amount = RoundAmount(todayCostComposition.Food) },
new() { CategoryCode = "labor", Amount = RoundAmount(todayCostComposition.Labor) },
new() { CategoryCode = "fixed", Amount = RoundAmount(todayCostComposition.Fixed) },
new() { CategoryCode = "packaging", Amount = RoundAmount(todayCostComposition.Packaging) },
new() { CategoryCode = "platform", Amount = RoundAmount(todayCostComposition.Platform) }
};
// 11. 查询 TOP10 商品营收排行(固定近 30 天)。
var topRangeStart = todayStart.AddDays(0 - TopProductDays + 1);
var paidOrderIdsQuery = BuildPaidOrderIdsQuery(
tenantId,
dimension,
storeId,
topRangeStart,
tomorrowStart);
var topProductQuery = context.OrderItems
.AsNoTracking()
.Where(item => item.TenantId == tenantId && paidOrderIdsQuery.Contains(item.OrderId))
.GroupBy(item => new { item.ProductId, item.ProductName })
.Select(group => new FinanceOverviewTopProductSnapshot
{
ProductId = group.Key.ProductId,
ProductName = group.Key.ProductName,
SalesQuantity = group.Sum(item => item.Quantity),
RevenueAmount = group.Sum(item => item.SubTotal)
});
var topProductTotalRevenue = await topProductQuery
.Select(item => item.RevenueAmount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var topProducts = await topProductQuery
.OrderByDescending(item => item.RevenueAmount)
.ThenByDescending(item => item.SalesQuantity)
.Take(10)
.ToListAsync(cancellationToken);
return new FinanceOverviewDashboardSnapshot
{
Dimension = dimension,
StoreId = dimension == FinanceCostDimension.Store ? storeId : null,
Summary = new FinanceOverviewSummarySnapshot
{
TodayGrossRevenue = todayGrossRevenue,
YesterdayGrossRevenue = yesterdayGrossRevenue,
TodayNetReceived = todayNetReceived,
YesterdayNetReceived = yesterdayNetReceived,
TodayRefundAmount = todayRefundAmount,
YesterdayRefundAmount = yesterdayRefundAmount,
TodayTotalCost = todayTotalCost,
YesterdayTotalCost = yesterdayTotalCost,
WithdrawableBalance = withdrawableBalance,
WithdrawableBalanceLastWeek = withdrawableBalanceLastWeek
},
IncomeTrend = incomeTrend,
ProfitTrend = profitTrend,
IncomeComposition = incomeComposition,
CostComposition = costComposition,
TopProducts = topProducts,
TopProductTotalRevenue = RoundAmount(topProductTotalRevenue)
};
}
private IQueryable<PaidRecordProjection> BuildPaidRecordQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId)
{
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
&& payment.Status == PaymentStatus.Paid
&& payment.PaidAt.HasValue
select new PaidRecordProjection
{
StoreId = order.StoreId,
DeliveryType = order.DeliveryType,
Amount = payment.Amount,
OccurredAt = payment.PaidAt!.Value
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
query = query.Where(item => item.StoreId == storeId.Value);
}
return query;
}
private IQueryable<RefundRecordProjection> BuildRefundRecordQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId)
{
var query =
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
select new RefundRecordProjection
{
StoreId = order.StoreId,
DeliveryType = order.DeliveryType,
Amount = refund.Amount,
OccurredAt = refund.CompletedAt ?? refund.RequestedAt
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
query = query.Where(item => item.StoreId == storeId.Value);
}
return query;
}
private IQueryable<long> BuildPaidOrderIdsQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime startAt,
DateTime endAt)
{
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
&& payment.Status == PaymentStatus.Paid
&& payment.PaidAt.HasValue
&& payment.PaidAt.Value >= startAt
&& payment.PaidAt.Value < endAt
select new
{
order.Id,
order.StoreId
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
query = query.Where(item => item.StoreId == storeId.Value);
}
return query.Select(item => item.Id).Distinct();
}
private IQueryable<TakeoutSaaS.Domain.Finance.Entities.FinanceCostEntry> BuildMonthlyCostQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
IReadOnlyCollection<DateTime> monthStarts)
{
var query = context.FinanceCostEntries
.AsNoTracking()
.Where(item => item.TenantId == tenantId && monthStarts.Contains(item.CostMonth));
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
return query.Where(item =>
item.Dimension == FinanceCostDimension.Store &&
item.StoreId == storeId.Value);
}
return query.Where(item =>
item.Dimension == FinanceCostDimension.Tenant &&
item.StoreId == null);
}
private static Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> BuildMonthlyCostMap(
IReadOnlyCollection<MonthlyCostRow> rows)
{
var result = new Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>>();
foreach (var row in rows)
{
var monthStart = NormalizeMonthStart(row.CostMonth);
if (!result.TryGetValue(monthStart, out var categoryMap))
{
categoryMap = new Dictionary<FinanceCostCategory, decimal>();
result[monthStart] = categoryMap;
}
if (!categoryMap.ContainsKey(row.Category))
{
categoryMap[row.Category] = 0m;
}
categoryMap[row.Category] += row.Amount;
}
return result;
}
private static DailyCostAmounts GetDailyBaseCost(
DateTime businessDate,
IReadOnlyDictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> monthlyCostMap)
{
var monthStart = NormalizeMonthStart(businessDate);
var daysInMonth = DateTime.DaysInMonth(monthStart.Year, monthStart.Month);
monthlyCostMap.TryGetValue(monthStart, out var categoryMap);
categoryMap ??= new Dictionary<FinanceCostCategory, decimal>();
var food = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FoodMaterial, daysInMonth);
var labor = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.Labor, daysInMonth);
var fixedCost = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FixedExpense, daysInMonth);
var packaging = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.PackagingConsumable, daysInMonth);
return new DailyCostAmounts
{
Food = food,
Labor = labor,
Fixed = fixedCost,
Packaging = packaging,
Platform = 0m
};
}
private static decimal ResolveDailyCategoryAmount(
IReadOnlyDictionary<FinanceCostCategory, decimal> categoryMap,
FinanceCostCategory category,
int daysInMonth)
{
categoryMap.TryGetValue(category, out var monthTotal);
if (daysInMonth <= 0)
{
return 0m;
}
return RoundAmount(monthTotal / daysInMonth);
}
private static List<DateTime> BuildMonthStartRange(DateTime startDate, DateTime endDate)
{
var result = new List<DateTime>();
var monthStart = NormalizeMonthStart(startDate);
var endMonth = NormalizeMonthStart(endDate);
while (monthStart <= endMonth)
{
result.Add(monthStart);
monthStart = monthStart.AddMonths(1);
}
return result;
}
private static DailyCostAmounts CreateEmptyCostComposition()
{
return new DailyCostAmounts
{
Food = 0m,
Labor = 0m,
Fixed = 0m,
Packaging = 0m,
Platform = 0m
};
}
private static void AddAmount(IDictionary<DateTime, decimal> map, DateTime key, decimal amount)
{
if (!map.ContainsKey(key))
{
map[key] = 0m;
}
map[key] += amount;
}
private static decimal GetAmount(IReadOnlyDictionary<DateTime, decimal> map, DateTime key)
{
return map.TryGetValue(key, out var value) ? value : 0m;
}
private static decimal ResolvePlatformRate(IReadOnlyDictionary<long, decimal> map, long storeId)
{
if (!map.TryGetValue(storeId, out var rate))
{
return 0m;
}
return Math.Clamp(rate, 0m, 100m);
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
private static DateTime NormalizeDate(DateTime value)
{
var utc = NormalizeUtc(value);
return new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc);
}
private static DateTime NormalizeMonthStart(DateTime value)
{
var utc = NormalizeUtc(value);
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
private static decimal RoundAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private sealed class PaidRecordProjection
{
public required long StoreId { get; init; }
public required DeliveryType DeliveryType { get; init; }
public required decimal Amount { get; init; }
public required DateTime OccurredAt { get; init; }
}
private sealed class RefundRecordProjection
{
public required long StoreId { get; init; }
public required DeliveryType DeliveryType { get; init; }
public required decimal Amount { get; init; }
public required DateTime OccurredAt { get; init; }
}
private sealed class DailyStoreAmountRow
{
public required DateTime BusinessDate { get; init; }
public required long StoreId { get; init; }
public required decimal Amount { get; init; }
}
private sealed class DailyChannelAmountRow
{
public required DeliveryType Channel { get; init; }
public required decimal Amount { get; init; }
}
private sealed class MonthlyCostRow
{
public required DateTime CostMonth { get; init; }
public required FinanceCostCategory Category { get; init; }
public required decimal Amount { get; init; }
}
private sealed class DailyCostAmounts
{
public decimal Food { get; init; }
public decimal Labor { get; init; }
public decimal Fixed { get; init; }
public decimal Packaging { get; init; }
public decimal Platform { get; init; }
public decimal TotalCost => RoundAmount(Food + Labor + Fixed + Packaging + Platform);
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddStoreFeePlatformServiceRate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "PlatformServiceRate",
table: "store_fees",
type: "numeric(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m,
comment: "平台服务费率(%)。");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PlatformServiceRate",
table: "store_fees");
}
}
}

View File

@@ -1,131 +0,0 @@
using System;
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("20260305103000_AddFinanceInvoiceModule")]
public sealed class AddFinanceInvoiceModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_invoice_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间UTC。"),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间UTC。"),
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间UTC。"),
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, 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_invoice_records", x => x.Id);
},
comment: "租户发票记录。");
migrationBuilder.CreateTable(
name: "finance_invoice_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
AutoIssueMaxAmount = 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_invoice_settings", x => x.Id);
},
comment: "租户发票开票基础设置。");
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_OrderNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "OrderNo" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "IssuedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_settings_TenantId",
table: "finance_invoice_settings",
column: "TenantId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_invoice_records");
migrationBuilder.DropTable(
name: "finance_invoice_settings");
}
}

View File

@@ -0,0 +1,215 @@
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("20260305110000_SeedFinanceOverviewMenuAndPermissions")]
public sealed class SeedFinanceOverviewMenuAndPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
DECLARE
v_parent_permission_id bigint;
v_view_permission_id bigint;
v_parent_menu_id bigint;
v_overview_menu_id bigint;
v_permission_seed_base bigint := 840300000000000000;
v_menu_seed_base bigint := 850300000000000000;
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:overview:view', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5050, '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();
SELECT "Id" INTO v_view_permission_id
FROM public.permissions
WHERE "Code" = 'tenant:finance:overview:view'
LIMIT 1;
-- 3.
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;
-- 4. Upsert
SELECT "Id"
INTO v_overview_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1
AND ("Path" = '/finance/overview' OR ("Path" = 'overview' AND "Component" = '/finance/overview/index'))
ORDER BY "DeletedAt" NULLS FIRST, "Id"
LIMIT 1;
IF v_overview_menu_id IS NULL THEN
v_overview_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_overview_menu_id, v_parent_menu_id, 'FinanceOverview', '/finance/overview', '/finance/overview/index', '', 'lucide:layout-dashboard',
FALSE, NULL, TRUE, 505,
'tenant:finance:overview:view', 'tenant:finance:overview: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" = 'FinanceOverview',
"Path" = '/finance/overview',
"Component" = '/finance/overview/index',
"Title" = '',
"Icon" = 'lucide:layout-dashboard',
"IsIframe" = FALSE,
"Link" = NULL,
"KeepAlive" = TRUE,
"SortOrder" = 505,
"RequiredPermissions" = 'tenant:finance:overview:view',
"MetaPermissions" = 'tenant:finance:overview:view',
"MetaRoles" = '',
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1
WHERE "Id" = v_overview_menu_id;
END IF;
-- 5. tenant-admin
INSERT INTO public.role_permissions (
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
SELECT
ABS(HASHTEXTEXTENDED('tenant-admin:overview:' || role."Id"::text || ':' || v_view_permission_id::text, 0)),
role."Id",
v_view_permission_id,
NOW(), NULL, NULL,
NULL, NULL, NULL,
role."TenantId",
1
FROM public.roles role
WHERE role."Code" = 'tenant-admin'
AND role."DeletedAt" IS NULL
AND v_view_permission_id IS NOT NULL
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1;
-- 6. tenant-admin
INSERT INTO public.role_template_permissions (
"Id", "RoleTemplateId", "PermissionCode",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy")
SELECT
ABS(HASHTEXTEXTENDED('template-overview:' || template."Id"::text || ':tenant:finance:overview:view', 0)),
template."Id",
'tenant:finance:overview:view',
NOW(), NULL, NULL,
NULL, NULL, NULL
FROM public.role_templates template
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" = 'tenant:finance:overview:view');
DELETE FROM public.role_template_permissions
WHERE "PermissionCode" = 'tenant:finance:overview:view';
DELETE FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance/overview';
DELETE FROM public.permissions
WHERE "Code" = 'tenant:finance:overview:view';
END $$;
""");
}
}

View File

@@ -7666,6 +7666,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("numeric(10,2)")
.HasComment("基础配送费(元)。");
b.Property<decimal>("PlatformServiceRate")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)")
.HasComment("平台服务费率(%)。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");