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)。");