feat(finance): add overview dashboard and platform fee rate
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度(tenant/store)。
|
||||
/// </summary>
|
||||
public string? Dimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID(门店维度必填)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览指标卡响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewKpiCardResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标值。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对比值。
|
||||
/// </summary>
|
||||
public decimal CompareAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变化率(%)。
|
||||
/// </summary>
|
||||
public decimal ChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 趋势(up/down/flat)。
|
||||
/// </summary>
|
||||
public string Trend { get; set; } = "flat";
|
||||
|
||||
/// <summary>
|
||||
/// 对比文案。
|
||||
/// </summary>
|
||||
public string CompareLabel { get; set; } = "较昨日";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointResponse> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointResponse> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营收。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本。
|
||||
/// </summary>
|
||||
public decimal CostAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointResponse> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointResponse> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总实收。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeCompositionItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewCostCompositionItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品项响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 销量。
|
||||
/// </summary>
|
||||
public int SalesQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收金额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 周期天数。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 排行项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewTopProductItemResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日营业额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse TodayRevenue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 实收卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse ActualReceived { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 退款卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse RefundAmount { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 净收入卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse NetIncome { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 可提现余额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardResponse WithdrawableBalance { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeTrendResponse IncomeTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewProfitTrendResponse ProfitTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeCompositionResponse IncomeComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewCostCompositionResponse CostComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品排行。
|
||||
/// </summary>
|
||||
public FinanceOverviewTopProductResponse TopProducts { get; set; } = new();
|
||||
}
|
||||
@@ -79,6 +79,10 @@ public sealed class StoreFeesSettingsDto
|
||||
/// </summary>
|
||||
public decimal BaseDeliveryFee { get; set; }
|
||||
/// <summary>
|
||||
/// PlatformServiceRate。
|
||||
/// </summary>
|
||||
public decimal PlatformServiceRate { get; set; }
|
||||
/// <summary>
|
||||
/// FreeDeliveryThreshold。
|
||||
/// </summary>
|
||||
public decimal? FreeDeliveryThreshold { get; set; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心概览驾驶舱。
|
||||
/// </summary>
|
||||
[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";
|
||||
|
||||
/// <summary>
|
||||
/// 查询财务概览驾驶舱数据。
|
||||
/// </summary>
|
||||
[HttpGet("dashboard")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceOverviewDashboardResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceOverviewDashboardResponse>> 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<FinanceOverviewDashboardResponse>.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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览指标卡 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewKpiCardDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标值。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对比基准值。
|
||||
/// </summary>
|
||||
public decimal CompareAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变化率(%)。
|
||||
/// </summary>
|
||||
public decimal ChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 趋势(up/down/flat)。
|
||||
/// </summary>
|
||||
public string Trend { get; set; } = "flat";
|
||||
|
||||
/// <summary>
|
||||
/// 对比文案(较昨日/较上周)。
|
||||
/// </summary>
|
||||
public string CompareLabel { get; set; } = "较昨日";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势点 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendPointDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeTrendDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointDto> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeTrendPointDto> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势点 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendPointDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string Date { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 轴标签(MM/dd)。
|
||||
/// </summary>
|
||||
public string DateLabel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营收。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本。
|
||||
/// </summary>
|
||||
public decimal CostAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewProfitTrendDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 近 7 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointDto> Last7Days { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewProfitTrendPointDto> Last30Days { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道编码(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道文案。
|
||||
/// </summary>
|
||||
public string ChannelText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewIncomeCompositionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 总实收。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewIncomeCompositionItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码(food/labor/fixed/packaging/platform)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewCostCompositionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 构成项。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewCostCompositionItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 排名。
|
||||
/// </summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 销量。
|
||||
/// </summary>
|
||||
public int SalesQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收金额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品区块 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewTopProductDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期(天)。
|
||||
/// </summary>
|
||||
public int PeriodDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 排行数据。
|
||||
/// </summary>
|
||||
public List<FinanceOverviewTopProductItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览页面 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceOverviewDashboardDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码(tenant/store)。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日营业额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardDto TodayRevenue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 实收卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardDto ActualReceived { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 退款卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardDto RefundAmount { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 净收入卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardDto NetIncome { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 可提现余额卡片。
|
||||
/// </summary>
|
||||
public FinanceOverviewKpiCardDto WithdrawableBalance { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeTrendDto IncomeTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势。
|
||||
/// </summary>
|
||||
public FinanceOverviewProfitTrendDto ProfitTrend { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewIncomeCompositionDto IncomeComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成。
|
||||
/// </summary>
|
||||
public FinanceOverviewCostCompositionDto CostComposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品排行。
|
||||
/// </summary>
|
||||
public FinanceOverviewTopProductDto TopProducts { get; set; } = new();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览映射与格式化。
|
||||
/// </summary>
|
||||
internal static class FinanceOverviewMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建财务概览页面 DTO。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceOverviewDashboardQueryHandler(
|
||||
IFinanceOverviewRepository financeOverviewRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceOverviewDashboardQuery, FinanceOverviewDashboardDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceOverviewDashboardDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询财务概览驾驶舱数据。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceOverviewDashboardQuery : IRequest<FinanceOverviewDashboardDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前 UTC 时间。
|
||||
/// </summary>
|
||||
public DateTime CurrentUtc { get; init; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceOverviewDashboardQueryValidator : AbstractValidator<GetFinanceOverviewDashboardQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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 非法");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台服务费率(%)。
|
||||
/// </summary>
|
||||
public decimal PlatformServiceRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费模式。
|
||||
/// </summary>
|
||||
|
||||
@@ -31,6 +31,11 @@ public sealed record StoreFeeDto
|
||||
/// </summary>
|
||||
public decimal DeliveryFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台服务费率(%)。
|
||||
/// </summary>
|
||||
public decimal PlatformServiceRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 餐具费是否启用。
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
||||
RuleFor(x => 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)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览核心指标快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewSummarySnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日营业额(支付成功总额)。
|
||||
/// </summary>
|
||||
public decimal TodayGrossRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日营业额(支付成功总额)。
|
||||
/// </summary>
|
||||
public decimal YesterdayGrossRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日实收(营业额 - 退款)。
|
||||
/// </summary>
|
||||
public decimal TodayNetReceived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日实收(营业额 - 退款)。
|
||||
/// </summary>
|
||||
public decimal YesterdayNetReceived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日退款。
|
||||
/// </summary>
|
||||
public decimal TodayRefundAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日退款。
|
||||
/// </summary>
|
||||
public decimal YesterdayRefundAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 今日总成本。
|
||||
/// </summary>
|
||||
public decimal TodayTotalCost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 昨日总成本。
|
||||
/// </summary>
|
||||
public decimal YesterdayTotalCost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前可提现余额(累计净收入口径)。
|
||||
/// </summary>
|
||||
public decimal WithdrawableBalance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 上周同日可提现余额(用于环比)。
|
||||
/// </summary>
|
||||
public decimal WithdrawableBalanceLastWeek { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入趋势点快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewIncomeTrendPointSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 业务日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public required DateTime BusinessDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实收金额。
|
||||
/// </summary>
|
||||
public decimal NetReceivedAmount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 利润趋势点快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewProfitTrendPointSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 业务日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public required DateTime BusinessDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收金额(支付成功总额)。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本金额。
|
||||
/// </summary>
|
||||
public decimal CostAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润金额。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成项快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewIncomeCompositionSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
public required DeliveryType Channel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成项快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewCostCompositionSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码(food/labor/fixed/packaging/platform)。
|
||||
/// </summary>
|
||||
public required string CategoryCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TOP 商品快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewTopProductSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品标识。
|
||||
/// </summary>
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public required string ProductName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 销量。
|
||||
/// </summary>
|
||||
public int SalesQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 营收金额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览页面快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceOverviewDashboardSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public required FinanceCostDimension Dimension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(租户维度为空)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 核心指标汇总。
|
||||
/// </summary>
|
||||
public required FinanceOverviewSummarySnapshot Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天收入趋势。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceOverviewIncomeTrendPointSnapshot> IncomeTrend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 30 天利润趋势。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceOverviewProfitTrendPointSnapshot> ProfitTrend { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 收入构成。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceOverviewIncomeCompositionSnapshot> IncomeComposition { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceOverviewCostCompositionSnapshot> CostComposition { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// TOP10 商品营收排行。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceOverviewTopProductSnapshot> TopProducts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// TOP 榜单统计周期内商品总营收。
|
||||
/// </summary>
|
||||
public decimal TopProductTotalRevenue { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览仓储契约。
|
||||
/// </summary>
|
||||
public interface IFinanceOverviewRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取财务概览页快照。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户标识。</param>
|
||||
/// <param name="dimension">统计维度。</param>
|
||||
/// <param name="storeId">门店标识(门店维度必填)。</param>
|
||||
/// <param name="currentUtc">当前 UTC 时间。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime currentUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -23,6 +23,11 @@ public sealed class StoreFee : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public decimal BaseDeliveryFee { get; set; } = 0m;
|
||||
|
||||
/// <summary>
|
||||
/// 平台服务费率(%)。
|
||||
/// </summary>
|
||||
public decimal PlatformServiceRate { get; set; } = 0m;
|
||||
|
||||
/// <summary>
|
||||
/// 打包费模式。
|
||||
/// </summary>
|
||||
|
||||
@@ -57,6 +57,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
|
||||
services.AddScoped<IFinanceOverviewRepository, EfFinanceOverviewRepository>();
|
||||
services.AddScoped<IFinanceBusinessReportRepository, EfFinanceBusinessReportRepository>();
|
||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
|
||||
@@ -810,6 +810,7 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
|
||||
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
|
||||
builder.Property(x => x.PlatformServiceRate).HasPrecision(5, 2);
|
||||
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
|
||||
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
|
||||
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 财务概览 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfFinanceOverviewRepository(TakeoutAppDbContext context) : IFinanceOverviewRepository
|
||||
{
|
||||
private const int TrendDays = 30;
|
||||
private const int TopProductDays = 30;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime currentUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var utcNow = NormalizeUtc(currentUtc);
|
||||
var todayStart = NormalizeDate(utcNow);
|
||||
var tomorrowStart = todayStart.AddDays(1);
|
||||
var yesterdayStart = todayStart.AddDays(-1);
|
||||
var trendStart = todayStart.AddDays(0 - TrendDays + 1);
|
||||
var weekStart = todayStart.AddDays(-7);
|
||||
|
||||
// 1. 查询支付与退款基础数据。
|
||||
var paidQuery = BuildPaidRecordQuery(tenantId, dimension, storeId);
|
||||
var refundQuery = BuildRefundRecordQuery(tenantId, dimension, storeId);
|
||||
|
||||
// 2. 读取近 30 天按日按门店支付与退款汇总。
|
||||
var paidDailyRows = await paidQuery
|
||||
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
|
||||
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
|
||||
.Select(group => new DailyStoreAmountRow
|
||||
{
|
||||
BusinessDate = group.Key.Date,
|
||||
StoreId = group.Key.StoreId,
|
||||
Amount = group.Sum(item => item.Amount)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var refundDailyRows = await refundQuery
|
||||
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
|
||||
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
|
||||
.Select(group => new DailyStoreAmountRow
|
||||
{
|
||||
BusinessDate = group.Key.Date,
|
||||
StoreId = group.Key.StoreId,
|
||||
Amount = group.Sum(item => item.Amount)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. 读取今日收入构成(渠道维度)。
|
||||
var paidTodayByChannel = await paidQuery
|
||||
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
|
||||
.GroupBy(item => item.DeliveryType)
|
||||
.Select(group => new DailyChannelAmountRow
|
||||
{
|
||||
Channel = group.Key,
|
||||
Amount = group.Sum(item => item.Amount)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var refundTodayByChannel = await refundQuery
|
||||
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
|
||||
.GroupBy(item => item.DeliveryType)
|
||||
.Select(group => new DailyChannelAmountRow
|
||||
{
|
||||
Channel = group.Key,
|
||||
Amount = group.Sum(item => item.Amount)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. 读取作用域门店平台费率。
|
||||
var scopedStoreIds = paidDailyRows
|
||||
.Select(item => item.StoreId)
|
||||
.Concat(refundDailyRows.Select(item => item.StoreId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (dimension == FinanceCostDimension.Store && storeId.HasValue && !scopedStoreIds.Contains(storeId.Value))
|
||||
{
|
||||
scopedStoreIds.Add(storeId.Value);
|
||||
}
|
||||
|
||||
var platformRateMap = scopedStoreIds.Count == 0
|
||||
? new Dictionary<long, decimal>()
|
||||
: await context.StoreFees
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && scopedStoreIds.Contains(item.StoreId))
|
||||
.Select(item => new { item.StoreId, item.PlatformServiceRate })
|
||||
.ToDictionaryAsync(item => item.StoreId, item => item.PlatformServiceRate, cancellationToken);
|
||||
|
||||
// 5. 近 30 天按日收入/退款/平台成本汇总。
|
||||
var grossByDate = new Dictionary<DateTime, decimal>();
|
||||
var refundByDate = new Dictionary<DateTime, decimal>();
|
||||
var platformCostByDate = new Dictionary<DateTime, decimal>();
|
||||
|
||||
foreach (var row in paidDailyRows)
|
||||
{
|
||||
var businessDate = NormalizeDate(row.BusinessDate);
|
||||
AddAmount(grossByDate, businessDate, row.Amount);
|
||||
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
|
||||
AddAmount(platformCostByDate, businessDate, row.Amount * platformRate / 100m);
|
||||
}
|
||||
|
||||
foreach (var row in refundDailyRows)
|
||||
{
|
||||
var businessDate = NormalizeDate(row.BusinessDate);
|
||||
AddAmount(refundByDate, businessDate, row.Amount);
|
||||
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
|
||||
AddAmount(platformCostByDate, businessDate, 0m - row.Amount * platformRate / 100m);
|
||||
}
|
||||
|
||||
// 6. 读取近 30 天覆盖月份的月度成本录入,并折算为日成本。
|
||||
var monthStarts = BuildMonthStartRange(trendStart, todayStart);
|
||||
var monthlyCostRows = await BuildMonthlyCostQuery(tenantId, dimension, storeId, monthStarts)
|
||||
.Select(item => new MonthlyCostRow
|
||||
{
|
||||
CostMonth = item.CostMonth,
|
||||
Category = item.Category,
|
||||
Amount = item.TotalAmount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var monthlyCostMap = BuildMonthlyCostMap(monthlyCostRows);
|
||||
|
||||
// 7. 组装趋势数据与今日/昨日成本构成。
|
||||
var incomeTrend = new List<FinanceOverviewIncomeTrendPointSnapshot>(TrendDays);
|
||||
var profitTrend = new List<FinanceOverviewProfitTrendPointSnapshot>(TrendDays);
|
||||
var todayCostComposition = CreateEmptyCostComposition();
|
||||
var yesterdayCostComposition = CreateEmptyCostComposition();
|
||||
|
||||
for (var currentDate = trendStart; currentDate <= todayStart; currentDate = currentDate.AddDays(1))
|
||||
{
|
||||
var grossAmount = RoundAmount(GetAmount(grossByDate, currentDate));
|
||||
var refundAmount = RoundAmount(GetAmount(refundByDate, currentDate));
|
||||
var netReceived = RoundAmount(grossAmount - refundAmount);
|
||||
var platformCost = RoundAmount(GetAmount(platformCostByDate, currentDate));
|
||||
|
||||
var baseCost = GetDailyBaseCost(currentDate, monthlyCostMap);
|
||||
var totalCost = RoundAmount(baseCost.TotalCost + platformCost);
|
||||
var netProfit = RoundAmount(netReceived - totalCost);
|
||||
|
||||
incomeTrend.Add(new FinanceOverviewIncomeTrendPointSnapshot
|
||||
{
|
||||
BusinessDate = currentDate,
|
||||
NetReceivedAmount = netReceived
|
||||
});
|
||||
profitTrend.Add(new FinanceOverviewProfitTrendPointSnapshot
|
||||
{
|
||||
BusinessDate = currentDate,
|
||||
RevenueAmount = grossAmount,
|
||||
CostAmount = totalCost,
|
||||
NetProfitAmount = netProfit
|
||||
});
|
||||
|
||||
if (currentDate == todayStart)
|
||||
{
|
||||
todayCostComposition = new DailyCostAmounts
|
||||
{
|
||||
Food = baseCost.Food,
|
||||
Labor = baseCost.Labor,
|
||||
Fixed = baseCost.Fixed,
|
||||
Packaging = baseCost.Packaging,
|
||||
Platform = platformCost
|
||||
};
|
||||
}
|
||||
|
||||
if (currentDate == yesterdayStart)
|
||||
{
|
||||
yesterdayCostComposition = new DailyCostAmounts
|
||||
{
|
||||
Food = baseCost.Food,
|
||||
Labor = baseCost.Labor,
|
||||
Fixed = baseCost.Fixed,
|
||||
Packaging = baseCost.Packaging,
|
||||
Platform = platformCost
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 汇总核心指标。
|
||||
var todayGrossRevenue = RoundAmount(GetAmount(grossByDate, todayStart));
|
||||
var yesterdayGrossRevenue = RoundAmount(GetAmount(grossByDate, yesterdayStart));
|
||||
var todayRefundAmount = RoundAmount(GetAmount(refundByDate, todayStart));
|
||||
var yesterdayRefundAmount = RoundAmount(GetAmount(refundByDate, yesterdayStart));
|
||||
var todayNetReceived = RoundAmount(todayGrossRevenue - todayRefundAmount);
|
||||
var yesterdayNetReceived = RoundAmount(yesterdayGrossRevenue - yesterdayRefundAmount);
|
||||
var todayTotalCost = RoundAmount(todayCostComposition.TotalCost);
|
||||
var yesterdayTotalCost = RoundAmount(yesterdayCostComposition.TotalCost);
|
||||
|
||||
var paidTotalAmount = await paidQuery
|
||||
.Where(item => item.OccurredAt < tomorrowStart)
|
||||
.Select(item => item.Amount)
|
||||
.DefaultIfEmpty(0m)
|
||||
.SumAsync(cancellationToken);
|
||||
var refundTotalAmount = await refundQuery
|
||||
.Where(item => item.OccurredAt < tomorrowStart)
|
||||
.Select(item => item.Amount)
|
||||
.DefaultIfEmpty(0m)
|
||||
.SumAsync(cancellationToken);
|
||||
var paidBeforeWeekAmount = await paidQuery
|
||||
.Where(item => item.OccurredAt < weekStart)
|
||||
.Select(item => item.Amount)
|
||||
.DefaultIfEmpty(0m)
|
||||
.SumAsync(cancellationToken);
|
||||
var refundBeforeWeekAmount = await refundQuery
|
||||
.Where(item => item.OccurredAt < weekStart)
|
||||
.Select(item => item.Amount)
|
||||
.DefaultIfEmpty(0m)
|
||||
.SumAsync(cancellationToken);
|
||||
|
||||
var withdrawableBalance = RoundAmount(paidTotalAmount - refundTotalAmount);
|
||||
var withdrawableBalanceLastWeek = RoundAmount(paidBeforeWeekAmount - refundBeforeWeekAmount);
|
||||
|
||||
// 9. 收入构成映射(今日)。
|
||||
var paidChannelMap = paidTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
|
||||
var refundChannelMap = refundTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
|
||||
var incomeComposition = new List<FinanceOverviewIncomeCompositionSnapshot>(3);
|
||||
foreach (var channel in new[] { DeliveryType.Delivery, DeliveryType.Pickup, DeliveryType.DineIn })
|
||||
{
|
||||
paidChannelMap.TryGetValue(channel, out var paidAmount);
|
||||
refundChannelMap.TryGetValue(channel, out var refundAmount);
|
||||
incomeComposition.Add(new FinanceOverviewIncomeCompositionSnapshot
|
||||
{
|
||||
Channel = channel,
|
||||
Amount = RoundAmount(Math.Max(0m, paidAmount - refundAmount))
|
||||
});
|
||||
}
|
||||
|
||||
// 10. 成本构成映射(今日)。
|
||||
var costComposition = new List<FinanceOverviewCostCompositionSnapshot>
|
||||
{
|
||||
new() { CategoryCode = "food", Amount = RoundAmount(todayCostComposition.Food) },
|
||||
new() { CategoryCode = "labor", Amount = RoundAmount(todayCostComposition.Labor) },
|
||||
new() { CategoryCode = "fixed", Amount = RoundAmount(todayCostComposition.Fixed) },
|
||||
new() { CategoryCode = "packaging", Amount = RoundAmount(todayCostComposition.Packaging) },
|
||||
new() { CategoryCode = "platform", Amount = RoundAmount(todayCostComposition.Platform) }
|
||||
};
|
||||
|
||||
// 11. 查询 TOP10 商品营收排行(固定近 30 天)。
|
||||
var topRangeStart = todayStart.AddDays(0 - TopProductDays + 1);
|
||||
var paidOrderIdsQuery = BuildPaidOrderIdsQuery(
|
||||
tenantId,
|
||||
dimension,
|
||||
storeId,
|
||||
topRangeStart,
|
||||
tomorrowStart);
|
||||
|
||||
var topProductQuery = context.OrderItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && paidOrderIdsQuery.Contains(item.OrderId))
|
||||
.GroupBy(item => new { item.ProductId, item.ProductName })
|
||||
.Select(group => new FinanceOverviewTopProductSnapshot
|
||||
{
|
||||
ProductId = group.Key.ProductId,
|
||||
ProductName = group.Key.ProductName,
|
||||
SalesQuantity = group.Sum(item => item.Quantity),
|
||||
RevenueAmount = group.Sum(item => item.SubTotal)
|
||||
});
|
||||
|
||||
var topProductTotalRevenue = await topProductQuery
|
||||
.Select(item => item.RevenueAmount)
|
||||
.DefaultIfEmpty(0m)
|
||||
.SumAsync(cancellationToken);
|
||||
var topProducts = await topProductQuery
|
||||
.OrderByDescending(item => item.RevenueAmount)
|
||||
.ThenByDescending(item => item.SalesQuantity)
|
||||
.Take(10)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new FinanceOverviewDashboardSnapshot
|
||||
{
|
||||
Dimension = dimension,
|
||||
StoreId = dimension == FinanceCostDimension.Store ? storeId : null,
|
||||
Summary = new FinanceOverviewSummarySnapshot
|
||||
{
|
||||
TodayGrossRevenue = todayGrossRevenue,
|
||||
YesterdayGrossRevenue = yesterdayGrossRevenue,
|
||||
TodayNetReceived = todayNetReceived,
|
||||
YesterdayNetReceived = yesterdayNetReceived,
|
||||
TodayRefundAmount = todayRefundAmount,
|
||||
YesterdayRefundAmount = yesterdayRefundAmount,
|
||||
TodayTotalCost = todayTotalCost,
|
||||
YesterdayTotalCost = yesterdayTotalCost,
|
||||
WithdrawableBalance = withdrawableBalance,
|
||||
WithdrawableBalanceLastWeek = withdrawableBalanceLastWeek
|
||||
},
|
||||
IncomeTrend = incomeTrend,
|
||||
ProfitTrend = profitTrend,
|
||||
IncomeComposition = incomeComposition,
|
||||
CostComposition = costComposition,
|
||||
TopProducts = topProducts,
|
||||
TopProductTotalRevenue = RoundAmount(topProductTotalRevenue)
|
||||
};
|
||||
}
|
||||
|
||||
private IQueryable<PaidRecordProjection> BuildPaidRecordQuery(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId)
|
||||
{
|
||||
var query =
|
||||
from payment in context.PaymentRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on payment.OrderId equals order.Id
|
||||
where payment.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& payment.Status == PaymentStatus.Paid
|
||||
&& payment.PaidAt.HasValue
|
||||
select new PaidRecordProjection
|
||||
{
|
||||
StoreId = order.StoreId,
|
||||
DeliveryType = order.DeliveryType,
|
||||
Amount = payment.Amount,
|
||||
OccurredAt = payment.PaidAt!.Value
|
||||
};
|
||||
|
||||
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.StoreId == storeId.Value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private IQueryable<RefundRecordProjection> BuildRefundRecordQuery(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId)
|
||||
{
|
||||
var query =
|
||||
from refund in context.PaymentRefundRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on refund.OrderId equals order.Id
|
||||
where refund.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||
select new RefundRecordProjection
|
||||
{
|
||||
StoreId = order.StoreId,
|
||||
DeliveryType = order.DeliveryType,
|
||||
Amount = refund.Amount,
|
||||
OccurredAt = refund.CompletedAt ?? refund.RequestedAt
|
||||
};
|
||||
|
||||
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.StoreId == storeId.Value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private IQueryable<long> BuildPaidOrderIdsQuery(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime startAt,
|
||||
DateTime endAt)
|
||||
{
|
||||
var query =
|
||||
from payment in context.PaymentRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on payment.OrderId equals order.Id
|
||||
where payment.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& payment.Status == PaymentStatus.Paid
|
||||
&& payment.PaidAt.HasValue
|
||||
&& payment.PaidAt.Value >= startAt
|
||||
&& payment.PaidAt.Value < endAt
|
||||
select new
|
||||
{
|
||||
order.Id,
|
||||
order.StoreId
|
||||
};
|
||||
|
||||
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.StoreId == storeId.Value);
|
||||
}
|
||||
|
||||
return query.Select(item => item.Id).Distinct();
|
||||
}
|
||||
|
||||
private IQueryable<TakeoutSaaS.Domain.Finance.Entities.FinanceCostEntry> BuildMonthlyCostQuery(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
IReadOnlyCollection<DateTime> monthStarts)
|
||||
{
|
||||
var query = context.FinanceCostEntries
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && monthStarts.Contains(item.CostMonth));
|
||||
|
||||
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||
{
|
||||
return query.Where(item =>
|
||||
item.Dimension == FinanceCostDimension.Store &&
|
||||
item.StoreId == storeId.Value);
|
||||
}
|
||||
|
||||
return query.Where(item =>
|
||||
item.Dimension == FinanceCostDimension.Tenant &&
|
||||
item.StoreId == null);
|
||||
}
|
||||
|
||||
private static Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> BuildMonthlyCostMap(
|
||||
IReadOnlyCollection<MonthlyCostRow> rows)
|
||||
{
|
||||
var result = new Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var monthStart = NormalizeMonthStart(row.CostMonth);
|
||||
if (!result.TryGetValue(monthStart, out var categoryMap))
|
||||
{
|
||||
categoryMap = new Dictionary<FinanceCostCategory, decimal>();
|
||||
result[monthStart] = categoryMap;
|
||||
}
|
||||
|
||||
if (!categoryMap.ContainsKey(row.Category))
|
||||
{
|
||||
categoryMap[row.Category] = 0m;
|
||||
}
|
||||
categoryMap[row.Category] += row.Amount;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DailyCostAmounts GetDailyBaseCost(
|
||||
DateTime businessDate,
|
||||
IReadOnlyDictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> monthlyCostMap)
|
||||
{
|
||||
var monthStart = NormalizeMonthStart(businessDate);
|
||||
var daysInMonth = DateTime.DaysInMonth(monthStart.Year, monthStart.Month);
|
||||
|
||||
monthlyCostMap.TryGetValue(monthStart, out var categoryMap);
|
||||
categoryMap ??= new Dictionary<FinanceCostCategory, decimal>();
|
||||
|
||||
var food = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FoodMaterial, daysInMonth);
|
||||
var labor = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.Labor, daysInMonth);
|
||||
var fixedCost = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FixedExpense, daysInMonth);
|
||||
var packaging = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.PackagingConsumable, daysInMonth);
|
||||
|
||||
return new DailyCostAmounts
|
||||
{
|
||||
Food = food,
|
||||
Labor = labor,
|
||||
Fixed = fixedCost,
|
||||
Packaging = packaging,
|
||||
Platform = 0m
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ResolveDailyCategoryAmount(
|
||||
IReadOnlyDictionary<FinanceCostCategory, decimal> categoryMap,
|
||||
FinanceCostCategory category,
|
||||
int daysInMonth)
|
||||
{
|
||||
categoryMap.TryGetValue(category, out var monthTotal);
|
||||
if (daysInMonth <= 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return RoundAmount(monthTotal / daysInMonth);
|
||||
}
|
||||
|
||||
private static List<DateTime> BuildMonthStartRange(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var result = new List<DateTime>();
|
||||
var monthStart = NormalizeMonthStart(startDate);
|
||||
var endMonth = NormalizeMonthStart(endDate);
|
||||
while (monthStart <= endMonth)
|
||||
{
|
||||
result.Add(monthStart);
|
||||
monthStart = monthStart.AddMonths(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DailyCostAmounts CreateEmptyCostComposition()
|
||||
{
|
||||
return new DailyCostAmounts
|
||||
{
|
||||
Food = 0m,
|
||||
Labor = 0m,
|
||||
Fixed = 0m,
|
||||
Packaging = 0m,
|
||||
Platform = 0m
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddAmount(IDictionary<DateTime, decimal> map, DateTime key, decimal amount)
|
||||
{
|
||||
if (!map.ContainsKey(key))
|
||||
{
|
||||
map[key] = 0m;
|
||||
}
|
||||
map[key] += amount;
|
||||
}
|
||||
|
||||
private static decimal GetAmount(IReadOnlyDictionary<DateTime, decimal> map, DateTime key)
|
||||
{
|
||||
return map.TryGetValue(key, out var value) ? value : 0m;
|
||||
}
|
||||
|
||||
private static decimal ResolvePlatformRate(IReadOnlyDictionary<long, decimal> map, long storeId)
|
||||
{
|
||||
if (!map.TryGetValue(storeId, out var rate))
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return Math.Clamp(rate, 0m, 100m);
|
||||
}
|
||||
|
||||
private static DateTime NormalizeUtc(DateTime value)
|
||||
{
|
||||
return value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime NormalizeDate(DateTime value)
|
||||
{
|
||||
var utc = NormalizeUtc(value);
|
||||
return new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static DateTime NormalizeMonthStart(DateTime value)
|
||||
{
|
||||
var utc = NormalizeUtc(value);
|
||||
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static decimal RoundAmount(decimal value)
|
||||
{
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private sealed class PaidRecordProjection
|
||||
{
|
||||
public required long StoreId { get; init; }
|
||||
|
||||
public required DeliveryType DeliveryType { get; init; }
|
||||
|
||||
public required decimal Amount { get; init; }
|
||||
|
||||
public required DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RefundRecordProjection
|
||||
{
|
||||
public required long StoreId { get; init; }
|
||||
|
||||
public required DeliveryType DeliveryType { get; init; }
|
||||
|
||||
public required decimal Amount { get; init; }
|
||||
|
||||
public required DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class DailyStoreAmountRow
|
||||
{
|
||||
public required DateTime BusinessDate { get; init; }
|
||||
|
||||
public required long StoreId { get; init; }
|
||||
|
||||
public required decimal Amount { get; init; }
|
||||
}
|
||||
|
||||
private sealed class DailyChannelAmountRow
|
||||
{
|
||||
public required DeliveryType Channel { get; init; }
|
||||
|
||||
public required decimal Amount { get; init; }
|
||||
}
|
||||
|
||||
private sealed class MonthlyCostRow
|
||||
{
|
||||
public required DateTime CostMonth { get; init; }
|
||||
|
||||
public required FinanceCostCategory Category { get; init; }
|
||||
|
||||
public required decimal Amount { get; init; }
|
||||
}
|
||||
|
||||
private sealed class DailyCostAmounts
|
||||
{
|
||||
public decimal Food { get; init; }
|
||||
|
||||
public decimal Labor { get; init; }
|
||||
|
||||
public decimal Fixed { get; init; }
|
||||
|
||||
public decimal Packaging { get; init; }
|
||||
|
||||
public decimal Platform { get; init; }
|
||||
|
||||
public decimal TotalCost => RoundAmount(Food + Labor + Fixed + Packaging + Platform);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStoreFeePlatformServiceRate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PlatformServiceRate",
|
||||
table: "store_fees",
|
||||
type: "numeric(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m,
|
||||
comment: "平台服务费率(%)。");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PlatformServiceRate",
|
||||
table: "store_fees");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// 新增财务中心发票管理表结构。
|
||||
/// </summary>
|
||||
[DbContext(typeof(TakeoutAppDbContext))]
|
||||
[Migration("20260305103000_AddFinanceInvoiceModule")]
|
||||
public sealed class AddFinanceInvoiceModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_invoice_records",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
|
||||
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
|
||||
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
|
||||
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
|
||||
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
|
||||
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
|
||||
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
|
||||
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
|
||||
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
|
||||
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间(UTC)。"),
|
||||
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间(UTC)。"),
|
||||
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
|
||||
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
|
||||
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间(UTC)。"),
|
||||
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
|
||||
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
|
||||
},
|
||||
comment: "租户发票记录。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_invoice_settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
|
||||
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
|
||||
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
|
||||
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
|
||||
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
|
||||
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
|
||||
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
|
||||
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
|
||||
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
|
||||
AutoIssueMaxAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
|
||||
},
|
||||
comment: "租户发票开票基础设置。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "InvoiceNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_OrderNo",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "OrderNo" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "Status", "AppliedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "Status", "IssuedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_settings_TenantId",
|
||||
table: "finance_invoice_settings",
|
||||
column: "TenantId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_invoice_records");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_invoice_settings");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
|
||||
|
||||
/// <summary>
|
||||
/// 写入财务概览菜单与权限定义。
|
||||
/// </summary>
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260305110000_SeedFinanceOverviewMenuAndPermissions")]
|
||||
public sealed class SeedFinanceOverviewMenuAndPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
v_parent_permission_id bigint;
|
||||
v_view_permission_id bigint;
|
||||
v_parent_menu_id bigint;
|
||||
v_overview_menu_id bigint;
|
||||
v_permission_seed_base bigint := 840300000000000000;
|
||||
v_menu_seed_base bigint := 850300000000000000;
|
||||
BEGIN
|
||||
-- 1. 确保财务权限分组存在。
|
||||
SELECT "Id"
|
||||
INTO v_parent_permission_id
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'group:tenant:finance'
|
||||
ORDER BY "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_parent_permission_id IS NULL THEN
|
||||
v_parent_permission_id := v_permission_seed_base + 1;
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_parent_permission_id, '财务中心', 'group:tenant:finance', '财务中心权限分组',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
0, 5000, 'group', 1)
|
||||
ON CONFLICT ("Code") DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- 2. Upsert 财务概览查看权限。
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_permission_seed_base + 11, '财务概览查看', 'tenant:finance:overview:view', '查看财务概览驾驶舱',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
v_parent_permission_id, 5050, 'leaf', 1)
|
||||
ON CONFLICT ("Code") DO UPDATE
|
||||
SET "Name" = EXCLUDED."Name",
|
||||
"Description" = EXCLUDED."Description",
|
||||
"ParentId" = EXCLUDED."ParentId",
|
||||
"SortOrder" = EXCLUDED."SortOrder",
|
||||
"Type" = EXCLUDED."Type",
|
||||
"Portal" = EXCLUDED."Portal",
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
|
||||
SELECT "Id" INTO v_view_permission_id
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'tenant:finance:overview:view'
|
||||
LIMIT 1;
|
||||
|
||||
-- 3. 确保租户端财务父菜单存在。
|
||||
SELECT "Id"
|
||||
INTO v_parent_menu_id
|
||||
FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
|
||||
ORDER BY "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_parent_menu_id IS NULL THEN
|
||||
v_parent_menu_id := v_menu_seed_base + 1;
|
||||
INSERT INTO public.menu_definitions (
|
||||
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||
VALUES (
|
||||
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '财务中心', 'lucide:wallet',
|
||||
FALSE, NULL, FALSE, 500,
|
||||
'', '', '', NULL,
|
||||
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- 4. Upsert 财务概览菜单。
|
||||
SELECT "Id"
|
||||
INTO v_overview_menu_id
|
||||
FROM public.menu_definitions
|
||||
WHERE "Portal" = 1
|
||||
AND ("Path" = '/finance/overview' OR ("Path" = 'overview' AND "Component" = '/finance/overview/index'))
|
||||
ORDER BY "DeletedAt" NULLS FIRST, "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_overview_menu_id IS NULL THEN
|
||||
v_overview_menu_id := v_menu_seed_base + 11;
|
||||
INSERT INTO public.menu_definitions (
|
||||
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||
VALUES (
|
||||
v_overview_menu_id, v_parent_menu_id, 'FinanceOverview', '/finance/overview', '/finance/overview/index', '财务概览', 'lucide:layout-dashboard',
|
||||
FALSE, NULL, TRUE, 505,
|
||||
'tenant:finance:overview:view', 'tenant:finance:overview:view', '', NULL,
|
||||
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
ELSE
|
||||
UPDATE public.menu_definitions
|
||||
SET "ParentId" = v_parent_menu_id,
|
||||
"Name" = 'FinanceOverview',
|
||||
"Path" = '/finance/overview',
|
||||
"Component" = '/finance/overview/index',
|
||||
"Title" = '财务概览',
|
||||
"Icon" = 'lucide:layout-dashboard',
|
||||
"IsIframe" = FALSE,
|
||||
"Link" = NULL,
|
||||
"KeepAlive" = TRUE,
|
||||
"SortOrder" = 505,
|
||||
"RequiredPermissions" = 'tenant:finance:overview:view',
|
||||
"MetaPermissions" = 'tenant:finance:overview:view',
|
||||
"MetaRoles" = '',
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1
|
||||
WHERE "Id" = v_overview_menu_id;
|
||||
END IF;
|
||||
|
||||
-- 5. 为 tenant-admin 角色授予权限。
|
||||
INSERT INTO public.role_permissions (
|
||||
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('tenant-admin:overview:' || role."Id"::text || ':' || v_view_permission_id::text, 0)),
|
||||
role."Id",
|
||||
v_view_permission_id,
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
role."TenantId",
|
||||
1
|
||||
FROM public.roles role
|
||||
WHERE role."Code" = 'tenant-admin'
|
||||
AND role."DeletedAt" IS NULL
|
||||
AND v_view_permission_id IS NOT NULL
|
||||
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1;
|
||||
|
||||
-- 6. 为 tenant-admin 角色模板授予权限。
|
||||
INSERT INTO public.role_template_permissions (
|
||||
"Id", "RoleTemplateId", "PermissionCode",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('template-overview:' || template."Id"::text || ':tenant:finance:overview:view', 0)),
|
||||
template."Id",
|
||||
'tenant:finance:overview:view',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL
|
||||
FROM public.role_templates template
|
||||
WHERE template."TemplateCode" = 'tenant-admin'
|
||||
AND template."DeletedAt" IS NULL
|
||||
ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
DELETE FROM public.role_permissions
|
||||
WHERE "PermissionId" IN (
|
||||
SELECT "Id"
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'tenant:finance:overview:view');
|
||||
|
||||
DELETE FROM public.role_template_permissions
|
||||
WHERE "PermissionCode" = 'tenant:finance:overview:view';
|
||||
|
||||
DELETE FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance/overview';
|
||||
|
||||
DELETE FROM public.permissions
|
||||
WHERE "Code" = 'tenant:finance:overview:view';
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -7666,6 +7666,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasComment("基础配送费(元)。");
|
||||
|
||||
b.Property<decimal>("PlatformServiceRate")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("numeric(5,2)")
|
||||
.HasComment("平台服务费率(%)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
Reference in New Issue
Block a user