From f7eba5503983b45bb0b0416484cbc733abb20887 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Thu, 5 Mar 2026 10:47:15 +0800
Subject: [PATCH] feat(finance): add overview dashboard and platform fee rate
---
.../Finance/FinanceOverviewContracts.cs | 329 ++++++++++
.../Contracts/Store/StoreFeesContracts.cs | 4 +
.../Controllers/FinanceOverviewController.cs | 171 +++++
.../Controllers/StoreFeesController.cs | 3 +
.../Overview/Dto/FinanceOverviewDtos.cs | 313 +++++++++
.../Handlers/FinanceOverviewMapping.cs | 245 +++++++
...GetFinanceOverviewDashboardQueryHandler.cs | 34 +
.../GetFinanceOverviewDashboardQuery.cs | 26 +
...tFinanceOverviewDashboardQueryValidator.cs | 31 +
.../Stores/Commands/UpdateStoreFeeCommand.cs | 5 +
.../App/Stores/Dto/StoreFeeDto.cs | 5 +
.../Handlers/UpdateStoreFeeCommandHandler.cs | 1 +
.../App/Stores/StoreMapping.cs | 1 +
.../UpdateStoreFeeCommandValidator.cs | 1 +
.../Finance/Models/FinanceOverviewModels.cs | 211 ++++++
.../IFinanceOverviewRepository.cs | 25 +
.../Stores/Entities/StoreFee.cs | 5 +
.../AppServiceCollectionExtensions.cs | 1 +
.../App/Persistence/TakeoutAppDbContext.cs | 1 +
.../EfFinanceOverviewRepository.cs | 615 ++++++++++++++++++
...05015955_AddStoreFeePlatformServiceRate.cs | 32 +
.../20260305103000_AddFinanceInvoiceModule.cs | 131 ----
...0_SeedFinanceOverviewMenuAndPermissions.cs | 215 ++++++
.../TakeoutAppDbContextModelSnapshot.cs | 5 +
24 files changed, 2279 insertions(+), 131 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceOverviewContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceOverviewController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Overview/Dto/FinanceOverviewDtos.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/FinanceOverviewMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/GetFinanceOverviewDashboardQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Overview/Queries/GetFinanceOverviewDashboardQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Overview/Validators/GetFinanceOverviewDashboardQueryValidator.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceOverviewModels.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceOverviewRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceOverviewRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305015955_AddStoreFeePlatformServiceRate.cs
delete mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305110000_SeedFinanceOverviewMenuAndPermissions.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceOverviewContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceOverviewContracts.cs
new file mode 100644
index 0000000..955e817
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceOverviewContracts.cs
@@ -0,0 +1,329 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Finance;
+
+///
+/// 财务概览查询请求。
+///
+public sealed class FinanceOverviewDashboardRequest
+{
+ ///
+ /// 维度(tenant/store)。
+ ///
+ public string? Dimension { get; set; }
+
+ ///
+ /// 门店 ID(门店维度必填)。
+ ///
+ public string? StoreId { get; set; }
+}
+
+///
+/// 财务概览指标卡响应。
+///
+public sealed class FinanceOverviewKpiCardResponse
+{
+ ///
+ /// 指标值。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 对比值。
+ ///
+ public decimal CompareAmount { get; set; }
+
+ ///
+ /// 变化率(%)。
+ ///
+ public decimal ChangeRate { get; set; }
+
+ ///
+ /// 趋势(up/down/flat)。
+ ///
+ public string Trend { get; set; } = "flat";
+
+ ///
+ /// 对比文案。
+ ///
+ public string CompareLabel { get; set; } = "较昨日";
+}
+
+///
+/// 收入趋势点响应。
+///
+public sealed class FinanceOverviewIncomeTrendPointResponse
+{
+ ///
+ /// 日期(yyyy-MM-dd)。
+ ///
+ public string Date { get; set; } = string.Empty;
+
+ ///
+ /// 轴标签(MM/dd)。
+ ///
+ public string DateLabel { get; set; } = string.Empty;
+
+ ///
+ /// 实收金额。
+ ///
+ public decimal Amount { get; set; }
+}
+
+///
+/// 收入趋势响应。
+///
+public sealed class FinanceOverviewIncomeTrendResponse
+{
+ ///
+ /// 近 7 天。
+ ///
+ public List Last7Days { get; set; } = [];
+
+ ///
+ /// 近 30 天。
+ ///
+ public List Last30Days { get; set; } = [];
+}
+
+///
+/// 利润趋势点响应。
+///
+public sealed class FinanceOverviewProfitTrendPointResponse
+{
+ ///
+ /// 日期(yyyy-MM-dd)。
+ ///
+ public string Date { get; set; } = string.Empty;
+
+ ///
+ /// 轴标签(MM/dd)。
+ ///
+ public string DateLabel { get; set; } = string.Empty;
+
+ ///
+ /// 营收。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 成本。
+ ///
+ public decimal CostAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+}
+
+///
+/// 利润趋势响应。
+///
+public sealed class FinanceOverviewProfitTrendResponse
+{
+ ///
+ /// 近 7 天。
+ ///
+ public List Last7Days { get; set; } = [];
+
+ ///
+ /// 近 30 天。
+ ///
+ public List Last30Days { get; set; } = [];
+}
+
+///
+/// 收入构成项响应。
+///
+public sealed class FinanceOverviewIncomeCompositionItemResponse
+{
+ ///
+ /// 渠道编码。
+ ///
+ public string Channel { get; set; } = string.Empty;
+
+ ///
+ /// 渠道文案。
+ ///
+ public string ChannelText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+}
+
+///
+/// 收入构成响应。
+///
+public sealed class FinanceOverviewIncomeCompositionResponse
+{
+ ///
+ /// 总实收。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 构成项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 成本构成项响应。
+///
+public sealed class FinanceOverviewCostCompositionItemResponse
+{
+ ///
+ /// 分类编码。
+ ///
+ public string Category { get; set; } = string.Empty;
+
+ ///
+ /// 分类文案。
+ ///
+ public string CategoryText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+}
+
+///
+/// 成本构成响应。
+///
+public sealed class FinanceOverviewCostCompositionResponse
+{
+ ///
+ /// 总成本。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 构成项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// TOP 商品项响应。
+///
+public sealed class FinanceOverviewTopProductItemResponse
+{
+ ///
+ /// 排名。
+ ///
+ public int Rank { get; set; }
+
+ ///
+ /// 商品名称。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 销量。
+ ///
+ public int SalesQuantity { get; set; }
+
+ ///
+ /// 营收金额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+}
+
+///
+/// TOP 商品响应。
+///
+public sealed class FinanceOverviewTopProductResponse
+{
+ ///
+ /// 周期天数。
+ ///
+ public int PeriodDays { get; set; } = 30;
+
+ ///
+ /// 排行项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 财务概览响应。
+///
+public sealed class FinanceOverviewDashboardResponse
+{
+ ///
+ /// 维度编码。
+ ///
+ public string Dimension { get; set; } = "tenant";
+
+ ///
+ /// 门店标识。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 今日营业额卡片。
+ ///
+ public FinanceOverviewKpiCardResponse TodayRevenue { get; set; } = new();
+
+ ///
+ /// 实收卡片。
+ ///
+ public FinanceOverviewKpiCardResponse ActualReceived { get; set; } = new();
+
+ ///
+ /// 退款卡片。
+ ///
+ public FinanceOverviewKpiCardResponse RefundAmount { get; set; } = new();
+
+ ///
+ /// 净收入卡片。
+ ///
+ public FinanceOverviewKpiCardResponse NetIncome { get; set; } = new();
+
+ ///
+ /// 可提现余额卡片。
+ ///
+ public FinanceOverviewKpiCardResponse WithdrawableBalance { get; set; } = new();
+
+ ///
+ /// 收入趋势。
+ ///
+ public FinanceOverviewIncomeTrendResponse IncomeTrend { get; set; } = new();
+
+ ///
+ /// 利润趋势。
+ ///
+ public FinanceOverviewProfitTrendResponse ProfitTrend { get; set; } = new();
+
+ ///
+ /// 收入构成。
+ ///
+ public FinanceOverviewIncomeCompositionResponse IncomeComposition { get; set; } = new();
+
+ ///
+ /// 成本构成。
+ ///
+ public FinanceOverviewCostCompositionResponse CostComposition { get; set; } = new();
+
+ ///
+ /// TOP 商品排行。
+ ///
+ public FinanceOverviewTopProductResponse TopProducts { get; set; } = new();
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreFeesContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreFeesContracts.cs
index 94f6a34..c7bbdd0 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreFeesContracts.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Store/StoreFeesContracts.cs
@@ -79,6 +79,10 @@ public sealed class StoreFeesSettingsDto
///
public decimal BaseDeliveryFee { get; set; }
///
+ /// PlatformServiceRate。
+ ///
+ public decimal PlatformServiceRate { get; set; }
+ ///
/// FreeDeliveryThreshold。
///
public decimal? FreeDeliveryThreshold { get; set; }
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceOverviewController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceOverviewController.cs
new file mode 100644
index 0000000..3a12bb6
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceOverviewController.cs
@@ -0,0 +1,171 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Finance.Overview.Dto;
+using TakeoutSaaS.Application.App.Finance.Overview.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Finance;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 财务中心概览驾驶舱。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/finance/overview")]
+public sealed class FinanceOverviewController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService) : BaseApiController
+{
+ private const string ViewPermission = "tenant:finance:overview:view";
+
+ ///
+ /// 查询财务概览驾驶舱数据。
+ ///
+ [HttpGet("dashboard")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Dashboard(
+ [FromQuery] FinanceOverviewDashboardRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析维度与作用域。
+ var dimension = ParseDimension(request.Dimension);
+ long? storeId = null;
+ if (dimension == FinanceCostDimension.Store)
+ {
+ storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId.Value, cancellationToken);
+ }
+
+ // 2. 查询概览数据。
+ var dashboard = await mediator.Send(new GetFinanceOverviewDashboardQuery
+ {
+ Dimension = dimension,
+ StoreId = storeId,
+ CurrentUtc = DateTime.UtcNow
+ }, cancellationToken);
+
+ // 3. 映射响应并返回。
+ return ApiResponse.Ok(MapDashboard(dashboard));
+ }
+
+ private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
+ {
+ var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
+ await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
+ }
+
+ private static FinanceCostDimension ParseDimension(string? value)
+ {
+ return (value ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "" or "tenant" => FinanceCostDimension.Tenant,
+ "store" => FinanceCostDimension.Store,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
+ };
+ }
+
+ private static FinanceOverviewDashboardResponse MapDashboard(FinanceOverviewDashboardDto source)
+ {
+ return new FinanceOverviewDashboardResponse
+ {
+ Dimension = source.Dimension,
+ StoreId = source.StoreId,
+ TodayRevenue = MapKpi(source.TodayRevenue),
+ ActualReceived = MapKpi(source.ActualReceived),
+ RefundAmount = MapKpi(source.RefundAmount),
+ NetIncome = MapKpi(source.NetIncome),
+ WithdrawableBalance = MapKpi(source.WithdrawableBalance),
+ IncomeTrend = new FinanceOverviewIncomeTrendResponse
+ {
+ Last7Days = source.IncomeTrend.Last7Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
+ {
+ Date = item.Date,
+ DateLabel = item.DateLabel,
+ Amount = item.Amount
+ }).ToList(),
+ Last30Days = source.IncomeTrend.Last30Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
+ {
+ Date = item.Date,
+ DateLabel = item.DateLabel,
+ Amount = item.Amount
+ }).ToList()
+ },
+ ProfitTrend = new FinanceOverviewProfitTrendResponse
+ {
+ Last7Days = source.ProfitTrend.Last7Days.Select(item => new FinanceOverviewProfitTrendPointResponse
+ {
+ Date = item.Date,
+ DateLabel = item.DateLabel,
+ RevenueAmount = item.RevenueAmount,
+ CostAmount = item.CostAmount,
+ NetProfitAmount = item.NetProfitAmount
+ }).ToList(),
+ Last30Days = source.ProfitTrend.Last30Days.Select(item => new FinanceOverviewProfitTrendPointResponse
+ {
+ Date = item.Date,
+ DateLabel = item.DateLabel,
+ RevenueAmount = item.RevenueAmount,
+ CostAmount = item.CostAmount,
+ NetProfitAmount = item.NetProfitAmount
+ }).ToList()
+ },
+ IncomeComposition = new FinanceOverviewIncomeCompositionResponse
+ {
+ TotalAmount = source.IncomeComposition.TotalAmount,
+ Items = source.IncomeComposition.Items.Select(item => new FinanceOverviewIncomeCompositionItemResponse
+ {
+ Channel = item.Channel,
+ ChannelText = item.ChannelText,
+ Amount = item.Amount,
+ Percentage = item.Percentage
+ }).ToList()
+ },
+ CostComposition = new FinanceOverviewCostCompositionResponse
+ {
+ TotalAmount = source.CostComposition.TotalAmount,
+ Items = source.CostComposition.Items.Select(item => new FinanceOverviewCostCompositionItemResponse
+ {
+ Category = item.Category,
+ CategoryText = item.CategoryText,
+ Amount = item.Amount,
+ Percentage = item.Percentage
+ }).ToList()
+ },
+ TopProducts = new FinanceOverviewTopProductResponse
+ {
+ PeriodDays = source.TopProducts.PeriodDays,
+ Items = source.TopProducts.Items.Select(item => new FinanceOverviewTopProductItemResponse
+ {
+ Rank = item.Rank,
+ ProductName = item.ProductName,
+ SalesQuantity = item.SalesQuantity,
+ RevenueAmount = item.RevenueAmount,
+ Percentage = item.Percentage
+ }).ToList()
+ }
+ };
+ }
+
+ private static FinanceOverviewKpiCardResponse MapKpi(FinanceOverviewKpiCardDto source)
+ {
+ return new FinanceOverviewKpiCardResponse
+ {
+ Amount = source.Amount,
+ CompareAmount = source.CompareAmount,
+ ChangeRate = source.ChangeRate,
+ Trend = source.Trend,
+ CompareLabel = source.CompareLabel
+ };
+ }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
index 3ee5f09..82f30cf 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/StoreFeesController.cs
@@ -64,6 +64,7 @@ public sealed class StoreFeesController(
StoreId = parsedStoreId,
MinimumOrderAmount = request.MinimumOrderAmount,
DeliveryFee = request.BaseDeliveryFee,
+ PlatformServiceRate = request.PlatformServiceRate,
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
@@ -175,6 +176,7 @@ public sealed class StoreFeesController(
targetFee.MinimumOrderAmount = sourceFee.MinimumOrderAmount;
targetFee.BaseDeliveryFee = sourceFee.BaseDeliveryFee;
+ targetFee.PlatformServiceRate = sourceFee.PlatformServiceRate;
targetFee.FreeDeliveryThreshold = sourceFee.FreeDeliveryThreshold;
targetFee.PackagingFeeMode = sourceFee.PackagingFeeMode;
targetFee.OrderPackagingFeeMode = sourceFee.OrderPackagingFeeMode;
@@ -214,6 +216,7 @@ public sealed class StoreFeesController(
IsConfigured = source is not null,
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
+ PlatformServiceRate = source?.PlatformServiceRate ?? 0m,
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Dto/FinanceOverviewDtos.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Dto/FinanceOverviewDtos.cs
new file mode 100644
index 0000000..22f924d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Dto/FinanceOverviewDtos.cs
@@ -0,0 +1,313 @@
+namespace TakeoutSaaS.Application.App.Finance.Overview.Dto;
+
+///
+/// 财务概览指标卡 DTO。
+///
+public sealed class FinanceOverviewKpiCardDto
+{
+ ///
+ /// 指标值。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 对比基准值。
+ ///
+ public decimal CompareAmount { get; set; }
+
+ ///
+ /// 变化率(%)。
+ ///
+ public decimal ChangeRate { get; set; }
+
+ ///
+ /// 趋势(up/down/flat)。
+ ///
+ public string Trend { get; set; } = "flat";
+
+ ///
+ /// 对比文案(较昨日/较上周)。
+ ///
+ public string CompareLabel { get; set; } = "较昨日";
+}
+
+///
+/// 收入趋势点 DTO。
+///
+public sealed class FinanceOverviewIncomeTrendPointDto
+{
+ ///
+ /// 日期(yyyy-MM-dd)。
+ ///
+ public string Date { get; set; } = string.Empty;
+
+ ///
+ /// 轴标签(MM/dd)。
+ ///
+ public string DateLabel { get; set; } = string.Empty;
+
+ ///
+ /// 实收金额。
+ ///
+ public decimal Amount { get; set; }
+}
+
+///
+/// 收入趋势 DTO。
+///
+public sealed class FinanceOverviewIncomeTrendDto
+{
+ ///
+ /// 近 7 天。
+ ///
+ public List Last7Days { get; set; } = [];
+
+ ///
+ /// 近 30 天。
+ ///
+ public List Last30Days { get; set; } = [];
+}
+
+///
+/// 利润趋势点 DTO。
+///
+public sealed class FinanceOverviewProfitTrendPointDto
+{
+ ///
+ /// 日期(yyyy-MM-dd)。
+ ///
+ public string Date { get; set; } = string.Empty;
+
+ ///
+ /// 轴标签(MM/dd)。
+ ///
+ public string DateLabel { get; set; } = string.Empty;
+
+ ///
+ /// 营收。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 成本。
+ ///
+ public decimal CostAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+}
+
+///
+/// 利润趋势 DTO。
+///
+public sealed class FinanceOverviewProfitTrendDto
+{
+ ///
+ /// 近 7 天。
+ ///
+ public List Last7Days { get; set; } = [];
+
+ ///
+ /// 近 30 天。
+ ///
+ public List Last30Days { get; set; } = [];
+}
+
+///
+/// 收入构成项 DTO。
+///
+public sealed class FinanceOverviewIncomeCompositionItemDto
+{
+ ///
+ /// 渠道编码(delivery/pickup/dine_in)。
+ ///
+ public string Channel { get; set; } = string.Empty;
+
+ ///
+ /// 渠道文案。
+ ///
+ public string ChannelText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+}
+
+///
+/// 收入构成 DTO。
+///
+public sealed class FinanceOverviewIncomeCompositionDto
+{
+ ///
+ /// 总实收。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 构成项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 成本构成项 DTO。
+///
+public sealed class FinanceOverviewCostCompositionItemDto
+{
+ ///
+ /// 分类编码(food/labor/fixed/packaging/platform)。
+ ///
+ public string Category { get; set; } = string.Empty;
+
+ ///
+ /// 分类文案。
+ ///
+ public string CategoryText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+}
+
+///
+/// 成本构成 DTO。
+///
+public sealed class FinanceOverviewCostCompositionDto
+{
+ ///
+ /// 总成本。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 构成项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// TOP 商品 DTO。
+///
+public sealed class FinanceOverviewTopProductItemDto
+{
+ ///
+ /// 排名。
+ ///
+ public int Rank { get; set; }
+
+ ///
+ /// 商品名称。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 销量。
+ ///
+ public int SalesQuantity { get; set; }
+
+ ///
+ /// 营收金额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 营收占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+}
+
+///
+/// TOP 商品区块 DTO。
+///
+public sealed class FinanceOverviewTopProductDto
+{
+ ///
+ /// 统计周期(天)。
+ ///
+ public int PeriodDays { get; set; } = 30;
+
+ ///
+ /// 排行数据。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 财务概览页面 DTO。
+///
+public sealed class FinanceOverviewDashboardDto
+{
+ ///
+ /// 维度编码(tenant/store)。
+ ///
+ public string Dimension { get; set; } = "tenant";
+
+ ///
+ /// 门店标识(门店维度时有值)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 今日营业额卡片。
+ ///
+ public FinanceOverviewKpiCardDto TodayRevenue { get; set; } = new();
+
+ ///
+ /// 实收卡片。
+ ///
+ public FinanceOverviewKpiCardDto ActualReceived { get; set; } = new();
+
+ ///
+ /// 退款卡片。
+ ///
+ public FinanceOverviewKpiCardDto RefundAmount { get; set; } = new();
+
+ ///
+ /// 净收入卡片。
+ ///
+ public FinanceOverviewKpiCardDto NetIncome { get; set; } = new();
+
+ ///
+ /// 可提现余额卡片。
+ ///
+ public FinanceOverviewKpiCardDto WithdrawableBalance { get; set; } = new();
+
+ ///
+ /// 收入趋势。
+ ///
+ public FinanceOverviewIncomeTrendDto IncomeTrend { get; set; } = new();
+
+ ///
+ /// 利润趋势。
+ ///
+ public FinanceOverviewProfitTrendDto ProfitTrend { get; set; } = new();
+
+ ///
+ /// 收入构成。
+ ///
+ public FinanceOverviewIncomeCompositionDto IncomeComposition { get; set; } = new();
+
+ ///
+ /// 成本构成。
+ ///
+ public FinanceOverviewCostCompositionDto CostComposition { get; set; } = new();
+
+ ///
+ /// TOP 商品排行。
+ ///
+ public FinanceOverviewTopProductDto TopProducts { get; set; } = new();
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/FinanceOverviewMapping.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/FinanceOverviewMapping.cs
new file mode 100644
index 0000000..5d74c3e
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/FinanceOverviewMapping.cs
@@ -0,0 +1,245 @@
+using System.Globalization;
+using TakeoutSaaS.Application.App.Finance.Overview.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Orders.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Overview.Handlers;
+
+///
+/// 财务概览映射与格式化。
+///
+internal static class FinanceOverviewMapping
+{
+ ///
+ /// 构建财务概览页面 DTO。
+ ///
+ public static FinanceOverviewDashboardDto ToDashboardDto(FinanceOverviewDashboardSnapshot snapshot)
+ {
+ // 1. 指标卡映射。
+ var todayNetIncome = snapshot.Summary.TodayNetReceived - snapshot.Summary.TodayTotalCost;
+ var yesterdayNetIncome = snapshot.Summary.YesterdayNetReceived - snapshot.Summary.YesterdayTotalCost;
+
+ // 2. 近 30/7 天趋势映射。
+ var incomeLast30 = snapshot.IncomeTrend
+ .OrderBy(item => item.BusinessDate)
+ .Select(item => new FinanceOverviewIncomeTrendPointDto
+ {
+ Date = item.BusinessDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
+ DateLabel = item.BusinessDate.ToString("MM/dd", CultureInfo.InvariantCulture),
+ Amount = RoundAmount(item.NetReceivedAmount)
+ })
+ .ToList();
+ var incomeLast7 = incomeLast30.Skip(Math.Max(0, incomeLast30.Count - 7)).ToList();
+
+ var profitLast30 = snapshot.ProfitTrend
+ .OrderBy(item => item.BusinessDate)
+ .Select(item => new FinanceOverviewProfitTrendPointDto
+ {
+ Date = item.BusinessDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
+ DateLabel = item.BusinessDate.ToString("MM/dd", CultureInfo.InvariantCulture),
+ RevenueAmount = RoundAmount(item.RevenueAmount),
+ CostAmount = RoundAmount(item.CostAmount),
+ NetProfitAmount = RoundAmount(item.NetProfitAmount)
+ })
+ .ToList();
+ var profitLast7 = profitLast30.Skip(Math.Max(0, profitLast30.Count - 7)).ToList();
+
+ // 3. 收入构成映射。
+ var incomeTotal = RoundAmount(snapshot.IncomeComposition.Sum(item => item.Amount));
+ var incomeItems = snapshot.IncomeComposition
+ .OrderBy(item => GetChannelSort(item.Channel))
+ .Select(item => new FinanceOverviewIncomeCompositionItemDto
+ {
+ Channel = ToChannelCode(item.Channel),
+ ChannelText = ToChannelText(item.Channel),
+ Amount = RoundAmount(item.Amount),
+ Percentage = incomeTotal > 0
+ ? RoundAmount(item.Amount / incomeTotal * 100m)
+ : 0m
+ })
+ .ToList();
+
+ // 4. 成本构成映射。
+ var costTotal = RoundAmount(snapshot.CostComposition.Sum(item => item.Amount));
+ var costItems = snapshot.CostComposition
+ .OrderBy(item => GetCostCategorySort(item.CategoryCode))
+ .Select(item => new FinanceOverviewCostCompositionItemDto
+ {
+ Category = item.CategoryCode,
+ CategoryText = ToCostCategoryText(item.CategoryCode),
+ Amount = RoundAmount(item.Amount),
+ Percentage = costTotal > 0
+ ? RoundAmount(item.Amount / costTotal * 100m)
+ : 0m
+ })
+ .ToList();
+
+ // 5. TOP10 映射。
+ var topTotalRevenue = snapshot.TopProductTotalRevenue > 0
+ ? snapshot.TopProductTotalRevenue
+ : snapshot.TopProducts.Sum(item => item.RevenueAmount);
+ var topItems = snapshot.TopProducts
+ .OrderByDescending(item => item.RevenueAmount)
+ .ThenByDescending(item => item.SalesQuantity)
+ .Select((item, index) => new FinanceOverviewTopProductItemDto
+ {
+ Rank = index + 1,
+ ProductName = item.ProductName,
+ SalesQuantity = item.SalesQuantity,
+ RevenueAmount = RoundAmount(item.RevenueAmount),
+ Percentage = topTotalRevenue > 0
+ ? RoundAmount(item.RevenueAmount / topTotalRevenue * 100m)
+ : 0m
+ })
+ .ToList();
+
+ return new FinanceOverviewDashboardDto
+ {
+ Dimension = ToDimensionCode(snapshot.Dimension),
+ StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
+ TodayRevenue = BuildKpi(snapshot.Summary.TodayGrossRevenue, snapshot.Summary.YesterdayGrossRevenue, "较昨日"),
+ ActualReceived = BuildKpi(snapshot.Summary.TodayNetReceived, snapshot.Summary.YesterdayNetReceived, "较昨日"),
+ RefundAmount = BuildKpi(snapshot.Summary.TodayRefundAmount, snapshot.Summary.YesterdayRefundAmount, "较昨日"),
+ NetIncome = BuildKpi(todayNetIncome, yesterdayNetIncome, "较昨日"),
+ WithdrawableBalance = BuildKpi(
+ snapshot.Summary.WithdrawableBalance,
+ snapshot.Summary.WithdrawableBalanceLastWeek,
+ "较上周"),
+ IncomeTrend = new FinanceOverviewIncomeTrendDto
+ {
+ Last7Days = incomeLast7,
+ Last30Days = incomeLast30
+ },
+ ProfitTrend = new FinanceOverviewProfitTrendDto
+ {
+ Last7Days = profitLast7,
+ Last30Days = profitLast30
+ },
+ IncomeComposition = new FinanceOverviewIncomeCompositionDto
+ {
+ TotalAmount = incomeTotal,
+ Items = incomeItems
+ },
+ CostComposition = new FinanceOverviewCostCompositionDto
+ {
+ TotalAmount = costTotal,
+ Items = costItems
+ },
+ TopProducts = new FinanceOverviewTopProductDto
+ {
+ PeriodDays = 30,
+ Items = topItems
+ }
+ };
+ }
+
+ private static FinanceOverviewKpiCardDto BuildKpi(decimal current, decimal previous, string compareLabel)
+ {
+ var normalizedCurrent = RoundAmount(current);
+ var normalizedPrevious = RoundAmount(previous);
+ return new FinanceOverviewKpiCardDto
+ {
+ Amount = normalizedCurrent,
+ CompareAmount = normalizedPrevious,
+ ChangeRate = CalculateChangeRate(normalizedCurrent, normalizedPrevious),
+ Trend = ResolveTrend(normalizedCurrent, normalizedPrevious),
+ CompareLabel = compareLabel
+ };
+ }
+
+ private static decimal CalculateChangeRate(decimal current, decimal previous)
+ {
+ if (previous == 0m)
+ {
+ return current == 0m ? 0m : 100m;
+ }
+
+ var rate = (current - previous) / previous * 100m;
+ return RoundAmount(rate);
+ }
+
+ private static string ResolveTrend(decimal current, decimal previous)
+ {
+ if (current > previous)
+ {
+ return "up";
+ }
+
+ if (current < previous)
+ {
+ return "down";
+ }
+
+ return "flat";
+ }
+
+ private static string ToDimensionCode(FinanceCostDimension value)
+ {
+ return value == FinanceCostDimension.Store ? "store" : "tenant";
+ }
+
+ private static string ToChannelCode(DeliveryType value)
+ {
+ return value switch
+ {
+ DeliveryType.Delivery => "delivery",
+ DeliveryType.Pickup => "pickup",
+ DeliveryType.DineIn => "dine_in",
+ _ => "delivery"
+ };
+ }
+
+ private static string ToChannelText(DeliveryType value)
+ {
+ return value switch
+ {
+ DeliveryType.Delivery => "外卖",
+ DeliveryType.Pickup => "自提",
+ DeliveryType.DineIn => "堂食",
+ _ => "外卖"
+ };
+ }
+
+ private static int GetChannelSort(DeliveryType value)
+ {
+ return value switch
+ {
+ DeliveryType.Delivery => 0,
+ DeliveryType.Pickup => 1,
+ DeliveryType.DineIn => 2,
+ _ => 9
+ };
+ }
+
+ private static int GetCostCategorySort(string? value)
+ {
+ return (value ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "food" => 0,
+ "labor" => 1,
+ "fixed" => 2,
+ "packaging" => 3,
+ "platform" => 4,
+ _ => 9
+ };
+ }
+
+ private static string ToCostCategoryText(string? value)
+ {
+ return (value ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "food" => "食材",
+ "labor" => "人工",
+ "fixed" => "固定",
+ "packaging" => "包装",
+ "platform" => "平台",
+ _ => "其他"
+ };
+ }
+
+ private static decimal RoundAmount(decimal value)
+ {
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/GetFinanceOverviewDashboardQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/GetFinanceOverviewDashboardQueryHandler.cs
new file mode 100644
index 0000000..eb8816b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Handlers/GetFinanceOverviewDashboardQueryHandler.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Overview.Dto;
+using TakeoutSaaS.Application.App.Finance.Overview.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Overview.Handlers;
+
+///
+/// 财务概览查询处理器。
+///
+public sealed class GetFinanceOverviewDashboardQueryHandler(
+ IFinanceOverviewRepository financeOverviewRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceOverviewDashboardQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 拉取租户上下文并读取快照。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var snapshot = await financeOverviewRepository.GetDashboardSnapshotAsync(
+ tenantId,
+ request.Dimension,
+ request.StoreId,
+ request.CurrentUtc,
+ cancellationToken);
+
+ // 2. 映射页面 DTO。
+ return FinanceOverviewMapping.ToDashboardDto(snapshot);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Queries/GetFinanceOverviewDashboardQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Queries/GetFinanceOverviewDashboardQuery.cs
new file mode 100644
index 0000000..5a363c1
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Queries/GetFinanceOverviewDashboardQuery.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Overview.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Overview.Queries;
+
+///
+/// 查询财务概览驾驶舱数据。
+///
+public sealed class GetFinanceOverviewDashboardQuery : IRequest
+{
+ ///
+ /// 统计维度。
+ ///
+ public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
+
+ ///
+ /// 门店标识(门店维度必填)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 当前 UTC 时间。
+ ///
+ public DateTime CurrentUtc { get; init; } = DateTime.UtcNow;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Validators/GetFinanceOverviewDashboardQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Validators/GetFinanceOverviewDashboardQueryValidator.cs
new file mode 100644
index 0000000..fdbc30f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Overview/Validators/GetFinanceOverviewDashboardQueryValidator.cs
@@ -0,0 +1,31 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Overview.Queries;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Overview.Validators;
+
+///
+/// 财务概览查询验证器。
+///
+public sealed class GetFinanceOverviewDashboardQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceOverviewDashboardQueryValidator()
+ {
+ RuleFor(x => x.Dimension)
+ .Must(value => value is FinanceCostDimension.Tenant or FinanceCostDimension.Store)
+ .WithMessage("dimension 非法");
+
+ RuleFor(x => x)
+ .Must(query =>
+ query.Dimension != FinanceCostDimension.Store ||
+ (query.StoreId.HasValue && query.StoreId.Value > 0))
+ .WithMessage("storeId 非法");
+
+ RuleFor(x => x.CurrentUtc)
+ .Must(value => value.Year is >= 2000 and <= 2100)
+ .WithMessage("currentUtc 非法");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs
index 69c7dab..cb3d673 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs
@@ -24,6 +24,11 @@ public sealed record UpdateStoreFeeCommand : IRequest
///
public decimal DeliveryFee { get; init; }
+ ///
+ /// 平台服务费率(%)。
+ ///
+ public decimal PlatformServiceRate { get; init; }
+
///
/// 打包费模式。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs
index 1de2c70..9ddcc18 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs
@@ -31,6 +31,11 @@ public sealed record StoreFeeDto
///
public decimal DeliveryFee { get; init; }
+ ///
+ /// 平台服务费率(%)。
+ ///
+ public decimal PlatformServiceRate { get; init; }
+
///
/// 餐具费是否启用。
///
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs
index dd62d88..8505e1c 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs
@@ -53,6 +53,7 @@ public sealed class UpdateStoreFeeCommandHandler(
// 3. (空行后) 应用更新字段
fee.MinimumOrderAmount = request.MinimumOrderAmount;
fee.BaseDeliveryFee = request.DeliveryFee;
+ fee.PlatformServiceRate = request.PlatformServiceRate;
fee.PackagingFeeMode = request.PackagingFeeMode;
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
? request.OrderPackagingFeeMode
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs
index e68d481..9c9e9ec 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs
@@ -67,6 +67,7 @@ public static class StoreMapping
StoreId = fee.StoreId,
MinimumOrderAmount = fee.MinimumOrderAmount,
DeliveryFee = fee.BaseDeliveryFee,
+ PlatformServiceRate = fee.PlatformServiceRate,
CutleryFeeEnabled = fee.CutleryFeeEnabled,
CutleryFeeAmount = fee.CutleryFeeAmount,
RushFeeEnabled = fee.RushFeeEnabled,
diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs
index 0b38a71..ba64921 100644
--- a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs
@@ -17,6 +17,7 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator x.StoreId).GreaterThan(0);
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).LessThanOrEqualTo(9999.99m);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).LessThanOrEqualTo(999.99m);
+ RuleFor(x => x.PlatformServiceRate).GreaterThanOrEqualTo(0).LessThanOrEqualTo(100m);
RuleFor(x => x.FreeDeliveryThreshold).GreaterThanOrEqualTo(0).When(x => x.FreeDeliveryThreshold.HasValue);
RuleFor(x => x.FixedPackagingFee)
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceOverviewModels.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceOverviewModels.cs
new file mode 100644
index 0000000..b9c3e6a
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceOverviewModels.cs
@@ -0,0 +1,211 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Orders.Enums;
+
+namespace TakeoutSaaS.Domain.Finance.Models;
+
+///
+/// 财务概览核心指标快照。
+///
+public sealed record FinanceOverviewSummarySnapshot
+{
+ ///
+ /// 今日营业额(支付成功总额)。
+ ///
+ public decimal TodayGrossRevenue { get; init; }
+
+ ///
+ /// 昨日营业额(支付成功总额)。
+ ///
+ public decimal YesterdayGrossRevenue { get; init; }
+
+ ///
+ /// 今日实收(营业额 - 退款)。
+ ///
+ public decimal TodayNetReceived { get; init; }
+
+ ///
+ /// 昨日实收(营业额 - 退款)。
+ ///
+ public decimal YesterdayNetReceived { get; init; }
+
+ ///
+ /// 今日退款。
+ ///
+ public decimal TodayRefundAmount { get; init; }
+
+ ///
+ /// 昨日退款。
+ ///
+ public decimal YesterdayRefundAmount { get; init; }
+
+ ///
+ /// 今日总成本。
+ ///
+ public decimal TodayTotalCost { get; init; }
+
+ ///
+ /// 昨日总成本。
+ ///
+ public decimal YesterdayTotalCost { get; init; }
+
+ ///
+ /// 当前可提现余额(累计净收入口径)。
+ ///
+ public decimal WithdrawableBalance { get; init; }
+
+ ///
+ /// 上周同日可提现余额(用于环比)。
+ ///
+ public decimal WithdrawableBalanceLastWeek { get; init; }
+}
+
+///
+/// 收入趋势点快照。
+///
+public sealed record FinanceOverviewIncomeTrendPointSnapshot
+{
+ ///
+ /// 业务日期(UTC 日期)。
+ ///
+ public required DateTime BusinessDate { get; init; }
+
+ ///
+ /// 实收金额。
+ ///
+ public decimal NetReceivedAmount { get; init; }
+}
+
+///
+/// 利润趋势点快照。
+///
+public sealed record FinanceOverviewProfitTrendPointSnapshot
+{
+ ///
+ /// 业务日期(UTC 日期)。
+ ///
+ public required DateTime BusinessDate { get; init; }
+
+ ///
+ /// 营收金额(支付成功总额)。
+ ///
+ public decimal RevenueAmount { get; init; }
+
+ ///
+ /// 成本金额。
+ ///
+ public decimal CostAmount { get; init; }
+
+ ///
+ /// 净利润金额。
+ ///
+ public decimal NetProfitAmount { get; init; }
+}
+
+///
+/// 收入构成项快照。
+///
+public sealed record FinanceOverviewIncomeCompositionSnapshot
+{
+ ///
+ /// 渠道。
+ ///
+ public required DeliveryType Channel { get; init; }
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; init; }
+}
+
+///
+/// 成本构成项快照。
+///
+public sealed record FinanceOverviewCostCompositionSnapshot
+{
+ ///
+ /// 分类编码(food/labor/fixed/packaging/platform)。
+ ///
+ public required string CategoryCode { get; init; }
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; init; }
+}
+
+///
+/// TOP 商品快照。
+///
+public sealed record FinanceOverviewTopProductSnapshot
+{
+ ///
+ /// 商品标识。
+ ///
+ public long ProductId { get; init; }
+
+ ///
+ /// 商品名称。
+ ///
+ public required string ProductName { get; init; }
+
+ ///
+ /// 销量。
+ ///
+ public int SalesQuantity { get; init; }
+
+ ///
+ /// 营收金额。
+ ///
+ public decimal RevenueAmount { get; init; }
+}
+
+///
+/// 财务概览页面快照。
+///
+public sealed record FinanceOverviewDashboardSnapshot
+{
+ ///
+ /// 统计维度。
+ ///
+ public required FinanceCostDimension Dimension { get; init; }
+
+ ///
+ /// 门店标识(租户维度为空)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 核心指标汇总。
+ ///
+ public required FinanceOverviewSummarySnapshot Summary { get; init; }
+
+ ///
+ /// 近 30 天收入趋势。
+ ///
+ public IReadOnlyList IncomeTrend { get; init; } = [];
+
+ ///
+ /// 近 30 天利润趋势。
+ ///
+ public IReadOnlyList ProfitTrend { get; init; } = [];
+
+ ///
+ /// 收入构成。
+ ///
+ public IReadOnlyList IncomeComposition { get; init; } = [];
+
+ ///
+ /// 成本构成。
+ ///
+ public IReadOnlyList CostComposition { get; init; } = [];
+
+ ///
+ /// TOP10 商品营收排行。
+ ///
+ public IReadOnlyList TopProducts { get; init; } = [];
+
+ ///
+ /// TOP 榜单统计周期内商品总营收。
+ ///
+ public decimal TopProductTotalRevenue { get; init; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceOverviewRepository.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceOverviewRepository.cs
new file mode 100644
index 0000000..e33b30e
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceOverviewRepository.cs
@@ -0,0 +1,25 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Domain.Finance.Repositories;
+
+///
+/// 财务概览仓储契约。
+///
+public interface IFinanceOverviewRepository
+{
+ ///
+ /// 获取财务概览页快照。
+ ///
+ /// 租户标识。
+ /// 统计维度。
+ /// 门店标识(门店维度必填)。
+ /// 当前 UTC 时间。
+ /// 取消标记。
+ Task GetDashboardSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime currentUtc,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs
index 0dfe9c8..6aab8ab 100644
--- a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs
@@ -23,6 +23,11 @@ public sealed class StoreFee : MultiTenantEntityBase
///
public decimal BaseDeliveryFee { get; set; } = 0m;
+ ///
+ /// 平台服务费率(%)。
+ ///
+ public decimal PlatformServiceRate { get; set; } = 0m;
+
///
/// 打包费模式。
///
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 60b714b..be5cbda 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -57,6 +57,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index a7d50b6..9141e8b 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -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();
builder.Property(x => x.OrderPackagingFeeMode).HasConversion();
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceOverviewRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceOverviewRepository.cs
new file mode 100644
index 0000000..abd4766
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceOverviewRepository.cs
@@ -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;
+
+///
+/// 财务概览 EF Core 仓储实现。
+///
+public sealed class EfFinanceOverviewRepository(TakeoutAppDbContext context) : IFinanceOverviewRepository
+{
+ private const int TrendDays = 30;
+ private const int TopProductDays = 30;
+
+ ///
+ public async Task 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()
+ : 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();
+ var refundByDate = new Dictionary();
+ var platformCostByDate = new Dictionary();
+
+ 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(TrendDays);
+ var profitTrend = new List(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(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
+ {
+ 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 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 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 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 BuildMonthlyCostQuery(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ IReadOnlyCollection 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> BuildMonthlyCostMap(
+ IReadOnlyCollection rows)
+ {
+ var result = new Dictionary>();
+ foreach (var row in rows)
+ {
+ var monthStart = NormalizeMonthStart(row.CostMonth);
+ if (!result.TryGetValue(monthStart, out var categoryMap))
+ {
+ categoryMap = new Dictionary();
+ 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> monthlyCostMap)
+ {
+ var monthStart = NormalizeMonthStart(businessDate);
+ var daysInMonth = DateTime.DaysInMonth(monthStart.Year, monthStart.Month);
+
+ monthlyCostMap.TryGetValue(monthStart, out var categoryMap);
+ categoryMap ??= new Dictionary();
+
+ 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 categoryMap,
+ FinanceCostCategory category,
+ int daysInMonth)
+ {
+ categoryMap.TryGetValue(category, out var monthTotal);
+ if (daysInMonth <= 0)
+ {
+ return 0m;
+ }
+
+ return RoundAmount(monthTotal / daysInMonth);
+ }
+
+ private static List BuildMonthStartRange(DateTime startDate, DateTime endDate)
+ {
+ var result = new List();
+ 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 map, DateTime key, decimal amount)
+ {
+ if (!map.ContainsKey(key))
+ {
+ map[key] = 0m;
+ }
+ map[key] += amount;
+ }
+
+ private static decimal GetAmount(IReadOnlyDictionary map, DateTime key)
+ {
+ return map.TryGetValue(key, out var value) ? value : 0m;
+ }
+
+ private static decimal ResolvePlatformRate(IReadOnlyDictionary 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);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305015955_AddStoreFeePlatformServiceRate.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305015955_AddStoreFeePlatformServiceRate.cs
new file mode 100644
index 0000000..4b56884
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305015955_AddStoreFeePlatformServiceRate.cs
@@ -0,0 +1,32 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ ///
+ public partial class AddStoreFeePlatformServiceRate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "PlatformServiceRate",
+ table: "store_fees",
+ type: "numeric(5,2)",
+ precision: 5,
+ scale: 2,
+ nullable: false,
+ defaultValue: 0m,
+ comment: "平台服务费率(%)。");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "PlatformServiceRate",
+ table: "store_fees");
+ }
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs
deleted file mode 100644
index 75560ab..0000000
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs
+++ /dev/null
@@ -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;
-
-///
-/// 新增财务中心发票管理表结构。
-///
-[DbContext(typeof(TakeoutAppDbContext))]
-[Migration("20260305103000_AddFinanceInvoiceModule")]
-public sealed class AddFinanceInvoiceModule : Migration
-{
- ///
- protected override void Up(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.CreateTable(
- name: "finance_invoice_records",
- columns: table => new
- {
- Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。")
- .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
- InvoiceNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
- ApplicantName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
- CompanyName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
- TaxpayerNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
- InvoiceType = table.Column(type: "integer", nullable: false, comment: "发票类型。"),
- Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
- OrderNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
- ContactEmail = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
- ContactPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
- ApplyRemark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
- Status = table.Column(type: "integer", nullable: false, comment: "发票状态。"),
- AppliedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "申请时间(UTC)。"),
- IssuedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "开票时间(UTC)。"),
- IssuedByUserId = table.Column(type: "bigint", nullable: true, comment: "开票人 ID。"),
- IssueRemark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
- VoidedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "作废时间(UTC)。"),
- VoidedByUserId = table.Column(type: "bigint", nullable: true, comment: "作废人 ID。"),
- VoidReason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
- CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
- UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
- DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
- CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
- UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
- DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
- TenantId = table.Column(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(type: "bigint", nullable: false, comment: "实体唯一标识。")
- .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
- CompanyName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
- TaxpayerNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
- RegisteredAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
- RegisteredPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
- BankName = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
- BankAccount = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
- EnableElectronicNormalInvoice = table.Column(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
- EnableElectronicSpecialInvoice = table.Column(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
- EnableAutoIssue = table.Column(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
- AutoIssueMaxAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
- CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
- UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
- DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
- CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
- UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
- DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
- TenantId = table.Column(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);
- }
-
- ///
- protected override void Down(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.DropTable(
- name: "finance_invoice_records");
-
- migrationBuilder.DropTable(
- name: "finance_invoice_settings");
- }
-}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305110000_SeedFinanceOverviewMenuAndPermissions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305110000_SeedFinanceOverviewMenuAndPermissions.cs
new file mode 100644
index 0000000..9c6aea5
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305110000_SeedFinanceOverviewMenuAndPermissions.cs
@@ -0,0 +1,215 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
+
+///
+/// 写入财务概览菜单与权限定义。
+///
+[DbContext(typeof(IdentityDbContext))]
+[Migration("20260305110000_SeedFinanceOverviewMenuAndPermissions")]
+public sealed class SeedFinanceOverviewMenuAndPermissions : Migration
+{
+ ///
+ 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 $$;
+ """);
+ }
+
+ ///
+ 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 $$;
+ """);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs
index fbb1653..0f6dd20 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs
@@ -7666,6 +7666,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("numeric(10,2)")
.HasComment("基础配送费(元)。");
+ b.Property("PlatformServiceRate")
+ .HasPrecision(5, 2)
+ .HasColumnType("numeric(5,2)")
+ .HasComment("平台服务费率(%)。");
+
b.Property("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间(UTC)。");