From fa6e376b864431102233fa0a4a42a1f411bd860d Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 4 Mar 2026 16:07:16 +0800
Subject: [PATCH] feat(finance): add cost management backend module
---
.../Contracts/Finance/FinanceCostContracts.cs | 384 +++++++++++++
.../Controllers/FinanceCostController.cs | 270 +++++++++
.../Commands/SaveFinanceCostEntryCommand.cs | 88 +++
.../App/Finance/Cost/Dto/FinanceCostDtos.cs | 279 ++++++++++
.../Cost/Handlers/FinanceCostMapping.cs | 248 +++++++++
.../GetFinanceCostAnalysisQueryHandler.cs | 36 ++
.../GetFinanceCostEntryQueryHandler.cs | 35 ++
.../SaveFinanceCostEntryCommandHandler.cs | 66 +++
.../Queries/GetFinanceCostAnalysisQuery.cs | 31 ++
.../Cost/Queries/GetFinanceCostEntryQuery.cs | 26 +
.../GetFinanceCostAnalysisQueryValidator.cs | 46 ++
.../GetFinanceCostEntryQueryValidator.cs | 43 ++
.../SaveFinanceCostEntryCommandValidator.cs | 120 ++++
.../Finance/Entities/FinanceCostEntry.cs | 35 ++
.../Finance/Entities/FinanceCostEntryItem.cs | 60 ++
.../Finance/Enums/FinanceCostCategory.cs | 27 +
.../Finance/Enums/FinanceCostDimension.cs | 17 +
.../Finance/Models/FinanceCostModels.cs | 214 +++++++
.../Repositories/IFinanceCostRepository.cs | 42 ++
.../AppServiceCollectionExtensions.cs | 1 +
.../App/Persistence/TakeoutAppDbContext.cs | 51 ++
.../Repositories/EfFinanceCostRepository.cs | 527 ++++++++++++++++++
.../20260305010000_AddFinanceCostModule.cs | 112 ++++
...13000_SeedFinanceCostMenuAndPermissions.cs | 243 ++++++++
24 files changed, 3001 insertions(+)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceCostContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceCostController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Commands/SaveFinanceCostEntryCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Dto/FinanceCostDtos.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/FinanceCostMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostAnalysisQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostEntryQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/SaveFinanceCostEntryCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostAnalysisQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostEntryQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostAnalysisQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostEntryQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/SaveFinanceCostEntryCommandValidator.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntry.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntryItem.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCategory.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostDimension.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceCostModels.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceCostRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceCostRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305010000_AddFinanceCostModule.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305013000_SeedFinanceCostMenuAndPermissions.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceCostContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceCostContracts.cs
new file mode 100644
index 0000000..2669cb8
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceCostContracts.cs
@@ -0,0 +1,384 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Finance;
+
+///
+/// 成本模块通用作用域请求。
+///
+public class FinanceCostScopeRequest
+{
+ ///
+ /// 维度(tenant/store)。
+ ///
+ public string? Dimension { get; set; }
+
+ ///
+ /// 门店标识(门店维度必填)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string? Month { get; set; }
+}
+
+///
+/// 成本录入查询请求。
+///
+public sealed class FinanceCostEntryRequest : FinanceCostScopeRequest;
+
+///
+/// 成本分析查询请求。
+///
+public sealed class FinanceCostAnalysisRequest : FinanceCostScopeRequest
+{
+ ///
+ /// 趋势月份数量。
+ ///
+ public int TrendMonthCount { get; set; } = 6;
+}
+
+///
+/// 成本录入保存请求。
+///
+public sealed class SaveFinanceCostEntryRequest : FinanceCostScopeRequest
+{
+ ///
+ /// 分类列表。
+ ///
+ public List Categories { get; set; } = [];
+}
+
+///
+/// 成本分类保存项请求。
+///
+public sealed class SaveFinanceCostCategoryRequest
+{
+ ///
+ /// 分类编码(food/labor/fixed/packaging)。
+ ///
+ public string Category { get; set; } = string.Empty;
+
+ ///
+ /// 分类总金额。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 分类明细。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 成本明细保存项请求。
+///
+public sealed class SaveFinanceCostDetailRequest
+{
+ ///
+ /// 明细标识(可空)。
+ ///
+ public string? ItemId { get; set; }
+
+ ///
+ /// 明细名称。
+ ///
+ public string ItemName { get; set; } = string.Empty;
+
+ ///
+ /// 明细金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 数量(人工类可用)。
+ ///
+ public decimal? Quantity { get; set; }
+
+ ///
+ /// 单价(人工类可用)。
+ ///
+ public decimal? UnitPrice { get; set; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; }
+}
+
+///
+/// 成本录入响应。
+///
+public sealed class FinanceCostEntryResponse
+{
+ ///
+ /// 维度编码。
+ ///
+ public string Dimension { get; set; } = "tenant";
+
+ ///
+ /// 门店标识(门店维度时有值)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 本月营业额。
+ ///
+ public decimal MonthRevenue { get; set; }
+
+ ///
+ /// 本月总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 本月成本率(%)。
+ ///
+ public decimal CostRate { get; set; }
+
+ ///
+ /// 分类数据。
+ ///
+ public List Categories { get; set; } = [];
+}
+
+///
+/// 成本分类响应。
+///
+public sealed class FinanceCostEntryCategoryResponse
+{
+ ///
+ /// 分类编码。
+ ///
+ public string Category { get; set; } = string.Empty;
+
+ ///
+ /// 分类文案。
+ ///
+ public string CategoryText { get; set; } = string.Empty;
+
+ ///
+ /// 分类总金额。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 分类占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+
+ ///
+ /// 明细数据。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 成本明细响应。
+///
+public sealed class FinanceCostEntryDetailResponse
+{
+ ///
+ /// 明细标识。
+ ///
+ public string? ItemId { get; set; }
+
+ ///
+ /// 明细名称。
+ ///
+ public string ItemName { get; set; } = string.Empty;
+
+ ///
+ /// 明细金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 数量(人工类可用)。
+ ///
+ public decimal? Quantity { get; set; }
+
+ ///
+ /// 单价(人工类可用)。
+ ///
+ public decimal? UnitPrice { get; set; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; }
+}
+
+///
+/// 成本分析响应。
+///
+public sealed class FinanceCostAnalysisResponse
+{
+ ///
+ /// 维度编码。
+ ///
+ public string Dimension { get; set; } = "tenant";
+
+ ///
+ /// 门店标识(门店维度时有值)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 统计卡。
+ ///
+ public FinanceCostAnalysisStatsResponse Stats { get; set; } = new();
+
+ ///
+ /// 趋势数据。
+ ///
+ public List Trend { get; set; } = [];
+
+ ///
+ /// 构成数据。
+ ///
+ public List Composition { get; set; } = [];
+
+ ///
+ /// 明细表数据。
+ ///
+ public List DetailRows { get; set; } = [];
+}
+
+///
+/// 成本分析统计卡响应。
+///
+public sealed class FinanceCostAnalysisStatsResponse
+{
+ ///
+ /// 本月总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 食材成本率(%)。
+ ///
+ public decimal FoodCostRate { get; set; }
+
+ ///
+ /// 单均成本。
+ ///
+ public decimal AverageCostPerPaidOrder { get; set; }
+
+ ///
+ /// 环比变化(%)。
+ ///
+ public decimal MonthOnMonthChangeRate { get; set; }
+
+ ///
+ /// 本月营业额。
+ ///
+ public decimal Revenue { get; set; }
+
+ ///
+ /// 本月支付成功订单数。
+ ///
+ public int PaidOrderCount { get; set; }
+}
+
+///
+/// 成本趋势点响应。
+///
+public sealed class FinanceCostTrendPointResponse
+{
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 月度总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 月度营业额。
+ ///
+ public decimal Revenue { get; set; }
+
+ ///
+ /// 月度成本率(%)。
+ ///
+ public decimal CostRate { get; set; }
+}
+
+///
+/// 成本构成响应。
+///
+public sealed class FinanceCostCompositionResponse
+{
+ ///
+ /// 分类编码。
+ ///
+ 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 FinanceCostMonthlyDetailResponse
+{
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 食材成本。
+ ///
+ public decimal FoodAmount { get; set; }
+
+ ///
+ /// 人工成本。
+ ///
+ public decimal LaborAmount { get; set; }
+
+ ///
+ /// 固定费用。
+ ///
+ public decimal FixedAmount { get; set; }
+
+ ///
+ /// 包装耗材。
+ ///
+ public decimal PackagingAmount { get; set; }
+
+ ///
+ /// 总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 成本率(%)。
+ ///
+ public decimal CostRate { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceCostController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceCostController.cs
new file mode 100644
index 0000000..fc80bef
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceCostController.cs
@@ -0,0 +1,270 @@
+using System.Globalization;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Finance.Cost.Commands;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Application.App.Finance.Cost.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/cost")]
+public sealed class FinanceCostController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService)
+ : BaseApiController
+{
+ private const string ViewPermission = "tenant:finance:cost:view";
+ private const string ManagePermission = "tenant:finance:cost:manage";
+
+ ///
+ /// 查询成本录入数据。
+ ///
+ [HttpGet("entry")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Entry(
+ [FromQuery] FinanceCostEntryRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析维度与作用域。
+ var scope = await ParseScopeAsync(request, cancellationToken);
+
+ // 2. 查询录入数据并映射响应。
+ var result = await mediator.Send(new GetFinanceCostEntryQuery
+ {
+ Dimension = scope.Dimension,
+ StoreId = scope.StoreId,
+ CostMonth = scope.CostMonth
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapEntry(result));
+ }
+
+ ///
+ /// 保存成本录入数据。
+ ///
+ [HttpPost("entry/save")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> SaveEntry(
+ [FromBody] SaveFinanceCostEntryRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析维度与作用域。
+ var scope = await ParseScopeAsync(request, cancellationToken);
+
+ // 2. 发起保存命令并映射响应。
+ var result = await mediator.Send(new SaveFinanceCostEntryCommand
+ {
+ Dimension = scope.Dimension,
+ StoreId = scope.StoreId,
+ CostMonth = scope.CostMonth,
+ Categories = (request.Categories ?? [])
+ .Select(MapSaveCategory)
+ .ToList()
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapEntry(result));
+ }
+
+ ///
+ /// 查询成本分析数据。
+ ///
+ [HttpGet("analysis")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Analysis(
+ [FromQuery] FinanceCostAnalysisRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 解析维度与作用域。
+ var scope = await ParseScopeAsync(request, cancellationToken);
+
+ // 2. 查询分析数据并映射响应。
+ var result = await mediator.Send(new GetFinanceCostAnalysisQuery
+ {
+ Dimension = scope.Dimension,
+ StoreId = scope.StoreId,
+ CostMonth = scope.CostMonth,
+ TrendMonthCount = Math.Clamp(request.TrendMonthCount, 3, 12)
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapAnalysis(result));
+ }
+
+ private async Task<(FinanceCostDimension Dimension, long? StoreId, DateTime CostMonth)> ParseScopeAsync(
+ FinanceCostScopeRequest request,
+ CancellationToken cancellationToken)
+ {
+ var dimension = ParseDimension(request.Dimension);
+ var costMonth = ParseMonthOrDefault(request.Month);
+
+ if (dimension == FinanceCostDimension.Tenant)
+ {
+ return (dimension, null, costMonth);
+ }
+
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+ return (dimension, storeId, costMonth);
+ }
+
+ 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)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "" or "tenant" => FinanceCostDimension.Tenant,
+ "store" => FinanceCostDimension.Store,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
+ };
+ }
+
+ private static DateTime ParseMonthOrDefault(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ var utcNow = DateTime.UtcNow;
+ return new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+
+ if (DateTime.TryParseExact(
+ value.Trim(),
+ "yyyy-MM",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var parsed))
+ {
+ return new DateTime(parsed.Year, parsed.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+
+ throw new BusinessException(ErrorCodes.BadRequest, "month 格式必须为 yyyy-MM");
+ }
+
+ private static FinanceCostCategory ParseCategory(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "food" => FinanceCostCategory.FoodMaterial,
+ "labor" => FinanceCostCategory.Labor,
+ "fixed" => FinanceCostCategory.FixedExpense,
+ "packaging" => FinanceCostCategory.PackagingConsumable,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
+ };
+ }
+
+ private static SaveFinanceCostCategoryCommandItem MapSaveCategory(SaveFinanceCostCategoryRequest source)
+ {
+ return new SaveFinanceCostCategoryCommandItem
+ {
+ Category = ParseCategory(source.Category),
+ TotalAmount = source.TotalAmount,
+ Items = (source.Items ?? [])
+ .Select(item => new SaveFinanceCostDetailCommandItem
+ {
+ ItemId = StoreApiHelpers.ParseSnowflakeOrNull(item.ItemId),
+ ItemName = item.ItemName,
+ Amount = item.Amount,
+ Quantity = item.Quantity,
+ UnitPrice = item.UnitPrice,
+ SortOrder = item.SortOrder
+ })
+ .ToList()
+ };
+ }
+
+ private static FinanceCostEntryResponse MapEntry(FinanceCostEntryDto source)
+ {
+ return new FinanceCostEntryResponse
+ {
+ Dimension = source.Dimension,
+ StoreId = source.StoreId,
+ Month = source.Month,
+ MonthRevenue = source.MonthRevenue,
+ TotalCost = source.TotalCost,
+ CostRate = source.CostRate,
+ Categories = source.Categories.Select(category => new FinanceCostEntryCategoryResponse
+ {
+ Category = category.Category,
+ CategoryText = category.CategoryText,
+ TotalAmount = category.TotalAmount,
+ Percentage = category.Percentage,
+ Items = category.Items.Select(item => new FinanceCostEntryDetailResponse
+ {
+ ItemId = item.ItemId,
+ ItemName = item.ItemName,
+ Amount = item.Amount,
+ Quantity = item.Quantity,
+ UnitPrice = item.UnitPrice,
+ SortOrder = item.SortOrder
+ }).ToList()
+ }).ToList()
+ };
+ }
+
+ private static FinanceCostAnalysisResponse MapAnalysis(FinanceCostAnalysisDto source)
+ {
+ return new FinanceCostAnalysisResponse
+ {
+ Dimension = source.Dimension,
+ StoreId = source.StoreId,
+ Month = source.Month,
+ Stats = new FinanceCostAnalysisStatsResponse
+ {
+ TotalCost = source.Stats.TotalCost,
+ FoodCostRate = source.Stats.FoodCostRate,
+ AverageCostPerPaidOrder = source.Stats.AverageCostPerPaidOrder,
+ MonthOnMonthChangeRate = source.Stats.MonthOnMonthChangeRate,
+ Revenue = source.Stats.Revenue,
+ PaidOrderCount = source.Stats.PaidOrderCount
+ },
+ Trend = source.Trend.Select(item => new FinanceCostTrendPointResponse
+ {
+ Month = item.Month,
+ TotalCost = item.TotalCost,
+ Revenue = item.Revenue,
+ CostRate = item.CostRate
+ }).ToList(),
+ Composition = source.Composition.Select(item => new FinanceCostCompositionResponse
+ {
+ Category = item.Category,
+ CategoryText = item.CategoryText,
+ Amount = item.Amount,
+ Percentage = item.Percentage
+ }).ToList(),
+ DetailRows = source.DetailRows.Select(item => new FinanceCostMonthlyDetailResponse
+ {
+ Month = item.Month,
+ FoodAmount = item.FoodAmount,
+ LaborAmount = item.LaborAmount,
+ FixedAmount = item.FixedAmount,
+ PackagingAmount = item.PackagingAmount,
+ TotalCost = item.TotalCost,
+ CostRate = item.CostRate
+ }).ToList()
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Commands/SaveFinanceCostEntryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Commands/SaveFinanceCostEntryCommand.cs
new file mode 100644
index 0000000..adb67ee
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Commands/SaveFinanceCostEntryCommand.cs
@@ -0,0 +1,88 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Commands;
+
+///
+/// 保存成本录入数据。
+///
+public sealed class SaveFinanceCostEntryCommand : IRequest
+{
+ ///
+ /// 统计维度。
+ ///
+ public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
+
+ ///
+ /// 门店标识(门店维度必填)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 目标月份(UTC 每月第一天)。
+ ///
+ public DateTime CostMonth { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// 成本分类列表。
+ ///
+ public IReadOnlyList Categories { get; init; } = [];
+}
+
+///
+/// 成本分类保存项。
+///
+public sealed class SaveFinanceCostCategoryCommandItem
+{
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; init; }
+
+ ///
+ /// 分类总金额。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 分类明细项。
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+}
+
+///
+/// 成本明细保存项。
+///
+public sealed class SaveFinanceCostDetailCommandItem
+{
+ ///
+ /// 明细标识(编辑时透传,可为空)。
+ ///
+ public long? ItemId { get; init; }
+
+ ///
+ /// 明细名称。
+ ///
+ public string ItemName { get; init; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 数量(人工类可用)。
+ ///
+ public decimal? Quantity { get; init; }
+
+ ///
+ /// 单价(人工类可用)。
+ ///
+ public decimal? UnitPrice { get; init; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Dto/FinanceCostDtos.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Dto/FinanceCostDtos.cs
new file mode 100644
index 0000000..f97bfd3
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Dto/FinanceCostDtos.cs
@@ -0,0 +1,279 @@
+namespace TakeoutSaaS.Application.App.Finance.Cost.Dto;
+
+///
+/// 成本录入明细项 DTO。
+///
+public sealed class FinanceCostEntryDetailDto
+{
+ ///
+ /// 明细标识。
+ ///
+ public string? ItemId { get; set; }
+
+ ///
+ /// 明细名称。
+ ///
+ public string ItemName { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 数量(人工类可用)。
+ ///
+ public decimal? Quantity { get; set; }
+
+ ///
+ /// 单价(人工类可用)。
+ ///
+ public decimal? UnitPrice { get; set; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; }
+}
+
+///
+/// 成本录入分类 DTO。
+///
+public sealed class FinanceCostEntryCategoryDto
+{
+ ///
+ /// 分类编码。
+ ///
+ public string Category { get; set; } = string.Empty;
+
+ ///
+ /// 分类文案。
+ ///
+ public string CategoryText { get; set; } = string.Empty;
+
+ ///
+ /// 分类总金额。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 分类占比(%)。
+ ///
+ public decimal Percentage { get; set; }
+
+ ///
+ /// 分类明细项。
+ ///
+ public List Items { get; set; } = [];
+}
+
+///
+/// 成本录入页 DTO。
+///
+public sealed class FinanceCostEntryDto
+{
+ ///
+ /// 维度编码。
+ ///
+ public string Dimension { get; set; } = "tenant";
+
+ ///
+ /// 门店标识(门店维度时有值)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 本月营业额。
+ ///
+ public decimal MonthRevenue { get; set; }
+
+ ///
+ /// 本月总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 本月成本率(%)。
+ ///
+ public decimal CostRate { get; set; }
+
+ ///
+ /// 分类集合。
+ ///
+ public List Categories { get; set; } = [];
+}
+
+///
+/// 成本分析统计卡 DTO。
+///
+public sealed class FinanceCostAnalysisStatsDto
+{
+ ///
+ /// 本月总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 食材成本率(%)。
+ ///
+ public decimal FoodCostRate { get; set; }
+
+ ///
+ /// 单均成本。
+ ///
+ public decimal AverageCostPerPaidOrder { get; set; }
+
+ ///
+ /// 环比变化(%)。
+ ///
+ public decimal MonthOnMonthChangeRate { get; set; }
+
+ ///
+ /// 本月营业额。
+ ///
+ public decimal Revenue { get; set; }
+
+ ///
+ /// 本月支付成功订单数。
+ ///
+ public int PaidOrderCount { get; set; }
+}
+
+///
+/// 成本趋势点 DTO。
+///
+public sealed class FinanceCostTrendPointDto
+{
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 月度总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 月度营业额。
+ ///
+ public decimal Revenue { get; set; }
+
+ ///
+ /// 月度成本率(%)。
+ ///
+ public decimal CostRate { get; set; }
+}
+
+///
+/// 成本构成项 DTO。
+///
+public sealed class FinanceCostCompositionItemDto
+{
+ ///
+ /// 分类编码。
+ ///
+ 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 FinanceCostMonthlyDetailRowDto
+{
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 食材成本。
+ ///
+ public decimal FoodAmount { get; set; }
+
+ ///
+ /// 人工成本。
+ ///
+ public decimal LaborAmount { get; set; }
+
+ ///
+ /// 固定费用。
+ ///
+ public decimal FixedAmount { get; set; }
+
+ ///
+ /// 包装耗材。
+ ///
+ public decimal PackagingAmount { get; set; }
+
+ ///
+ /// 总成本。
+ ///
+ public decimal TotalCost { get; set; }
+
+ ///
+ /// 成本率(%)。
+ ///
+ public decimal CostRate { get; set; }
+}
+
+///
+/// 成本分析页 DTO。
+///
+public sealed class FinanceCostAnalysisDto
+{
+ ///
+ /// 维度编码。
+ ///
+ public string Dimension { get; set; } = "tenant";
+
+ ///
+ /// 门店标识(门店维度时有值)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 月份(yyyy-MM)。
+ ///
+ public string Month { get; set; } = string.Empty;
+
+ ///
+ /// 统计卡。
+ ///
+ public FinanceCostAnalysisStatsDto Stats { get; set; } = new();
+
+ ///
+ /// 趋势数据。
+ ///
+ public List Trend { get; set; } = [];
+
+ ///
+ /// 构成数据。
+ ///
+ public List Composition { get; set; } = [];
+
+ ///
+ /// 明细表数据。
+ ///
+ public List DetailRows { get; set; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/FinanceCostMapping.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/FinanceCostMapping.cs
new file mode 100644
index 0000000..6a1c256
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/FinanceCostMapping.cs
@@ -0,0 +1,248 @@
+using System.Globalization;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
+
+///
+/// 成本模块映射与文案转换。
+///
+internal static class FinanceCostMapping
+{
+ ///
+ /// 维度编码转枚举。
+ ///
+ public static FinanceCostDimension ParseDimensionCode(string? value)
+ {
+ return (value ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "store" => FinanceCostDimension.Store,
+ _ => FinanceCostDimension.Tenant
+ };
+ }
+
+ ///
+ /// 维度枚举转编码。
+ ///
+ public static string ToDimensionCode(FinanceCostDimension value)
+ {
+ return value == FinanceCostDimension.Store ? "store" : "tenant";
+ }
+
+ ///
+ /// 分类编码转枚举。
+ ///
+ public static FinanceCostCategory ParseCategoryCode(string? value)
+ {
+ return (value ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "food" => FinanceCostCategory.FoodMaterial,
+ "labor" => FinanceCostCategory.Labor,
+ "fixed" => FinanceCostCategory.FixedExpense,
+ "packaging" => FinanceCostCategory.PackagingConsumable,
+ _ => FinanceCostCategory.FoodMaterial
+ };
+ }
+
+ ///
+ /// 分类枚举转编码。
+ ///
+ public static string ToCategoryCode(FinanceCostCategory value)
+ {
+ return value switch
+ {
+ FinanceCostCategory.FoodMaterial => "food",
+ FinanceCostCategory.Labor => "labor",
+ FinanceCostCategory.FixedExpense => "fixed",
+ FinanceCostCategory.PackagingConsumable => "packaging",
+ _ => "food"
+ };
+ }
+
+ ///
+ /// 分类文案。
+ ///
+ public static string ToCategoryText(FinanceCostCategory value)
+ {
+ return value switch
+ {
+ FinanceCostCategory.FoodMaterial => "食材原料",
+ FinanceCostCategory.Labor => "人工成本",
+ FinanceCostCategory.FixedExpense => "固定费用",
+ FinanceCostCategory.PackagingConsumable => "包装耗材",
+ _ => "食材原料"
+ };
+ }
+
+ ///
+ /// 格式化月份字符串(yyyy-MM)。
+ ///
+ public static string ToMonthText(DateTime month)
+ {
+ return month.ToString("yyyy-MM", CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// 归一化金额精度。
+ ///
+ public static decimal RoundAmount(decimal value)
+ {
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ ///
+ /// 构建录入页 DTO。
+ ///
+ public static FinanceCostEntryDto ToEntryDto(FinanceCostMonthSnapshot snapshot)
+ {
+ // 1. 计算总成本与成本率。
+ var totalCost = RoundAmount(snapshot.Categories.Sum(item => item.TotalAmount));
+ var costRate = snapshot.MonthRevenue > 0
+ ? RoundAmount(totalCost / snapshot.MonthRevenue * 100m)
+ : 0m;
+
+ // 2. 映射分类与明细。
+ var categories = snapshot.Categories.Select(category =>
+ {
+ var percentage = totalCost > 0
+ ? RoundAmount(category.TotalAmount / totalCost * 100m)
+ : 0m;
+
+ return new FinanceCostEntryCategoryDto
+ {
+ Category = ToCategoryCode(category.Category),
+ CategoryText = ToCategoryText(category.Category),
+ TotalAmount = RoundAmount(category.TotalAmount),
+ Percentage = percentage,
+ Items = category.Items
+ .OrderBy(item => item.SortOrder)
+ .ThenBy(item => item.ItemName)
+ .Select(item => new FinanceCostEntryDetailDto
+ {
+ ItemId = item.ItemId?.ToString(CultureInfo.InvariantCulture),
+ ItemName = item.ItemName,
+ Amount = RoundAmount(item.Amount),
+ Quantity = item.Quantity.HasValue ? RoundAmount(item.Quantity.Value) : null,
+ UnitPrice = item.UnitPrice.HasValue ? RoundAmount(item.UnitPrice.Value) : null,
+ SortOrder = item.SortOrder
+ })
+ .ToList()
+ };
+ }).ToList();
+
+ return new FinanceCostEntryDto
+ {
+ Dimension = ToDimensionCode(snapshot.Dimension),
+ StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
+ Month = ToMonthText(snapshot.CostMonth),
+ MonthRevenue = RoundAmount(snapshot.MonthRevenue),
+ TotalCost = totalCost,
+ CostRate = costRate,
+ Categories = categories
+ };
+ }
+
+ ///
+ /// 构建分析页 DTO。
+ ///
+ public static FinanceCostAnalysisDto ToAnalysisDto(FinanceCostAnalysisSnapshot snapshot)
+ {
+ // 1. 计算统计指标。
+ var averageCostPerPaidOrder = snapshot.CurrentPaidOrderCount > 0
+ ? RoundAmount(snapshot.CurrentTotalCost / snapshot.CurrentPaidOrderCount)
+ : 0m;
+ var foodCostRate = snapshot.CurrentRevenue > 0
+ ? RoundAmount(snapshot.CurrentFoodAmount / snapshot.CurrentRevenue * 100m)
+ : 0m;
+
+ // 2. 映射趋势与明细表。
+ var trend = snapshot.Trends
+ .OrderBy(item => item.MonthStartUtc)
+ .Select(item =>
+ {
+ var costRate = item.Revenue > 0
+ ? RoundAmount(item.TotalCost / item.Revenue * 100m)
+ : 0m;
+
+ return new FinanceCostTrendPointDto
+ {
+ Month = ToMonthText(item.MonthStartUtc),
+ TotalCost = RoundAmount(item.TotalCost),
+ Revenue = RoundAmount(item.Revenue),
+ CostRate = costRate
+ };
+ })
+ .ToList();
+
+ var detailRows = snapshot.DetailRows
+ .OrderByDescending(item => item.MonthStartUtc)
+ .Select(item =>
+ {
+ var costRate = item.Revenue > 0
+ ? RoundAmount(item.TotalCost / item.Revenue * 100m)
+ : 0m;
+
+ return new FinanceCostMonthlyDetailRowDto
+ {
+ Month = ToMonthText(item.MonthStartUtc),
+ FoodAmount = RoundAmount(item.FoodAmount),
+ LaborAmount = RoundAmount(item.LaborAmount),
+ FixedAmount = RoundAmount(item.FixedAmount),
+ PackagingAmount = RoundAmount(item.PackagingAmount),
+ TotalCost = RoundAmount(item.TotalCost),
+ CostRate = costRate
+ };
+ })
+ .ToList();
+
+ // 3. 构建成本构成。
+ var totalCost = RoundAmount(snapshot.CurrentTotalCost);
+ var composition = snapshot.CurrentCategories
+ .OrderBy(item => item.Category)
+ .Select(item => new FinanceCostCompositionItemDto
+ {
+ Category = ToCategoryCode(item.Category),
+ CategoryText = ToCategoryText(item.Category),
+ Amount = RoundAmount(item.TotalAmount),
+ Percentage = totalCost > 0
+ ? RoundAmount(item.TotalAmount / totalCost * 100m)
+ : 0m
+ })
+ .ToList();
+
+ return new FinanceCostAnalysisDto
+ {
+ Dimension = ToDimensionCode(snapshot.Dimension),
+ StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
+ Month = ToMonthText(snapshot.CostMonth),
+ Stats = new FinanceCostAnalysisStatsDto
+ {
+ TotalCost = totalCost,
+ FoodCostRate = foodCostRate,
+ AverageCostPerPaidOrder = averageCostPerPaidOrder,
+ MonthOnMonthChangeRate = RoundAmount(snapshot.MonthOnMonthChangeRate),
+ Revenue = RoundAmount(snapshot.CurrentRevenue),
+ PaidOrderCount = snapshot.CurrentPaidOrderCount
+ },
+ Trend = trend,
+ Composition = composition,
+ DetailRows = detailRows
+ };
+ }
+
+ ///
+ /// 归一化为月份起始 UTC 时间。
+ ///
+ public static DateTime NormalizeMonthStart(DateTime value)
+ {
+ var utcValue = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+
+ return new DateTime(utcValue.Year, utcValue.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostAnalysisQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostAnalysisQueryHandler.cs
new file mode 100644
index 0000000..0520130
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostAnalysisQueryHandler.cs
@@ -0,0 +1,36 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Application.App.Finance.Cost.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
+
+///
+/// 成本分析查询处理器。
+///
+public sealed class GetFinanceCostAnalysisQueryHandler(
+ IFinanceCostRepository financeCostRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceCostAnalysisQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并查询分析快照。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedMonth = FinanceCostMapping.NormalizeMonthStart(request.CostMonth);
+ var snapshot = await financeCostRepository.GetAnalysisSnapshotAsync(
+ tenantId,
+ request.Dimension,
+ request.StoreId,
+ normalizedMonth,
+ request.TrendMonthCount,
+ cancellationToken);
+
+ // 2. 映射 DTO 返回。
+ return FinanceCostMapping.ToAnalysisDto(snapshot);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostEntryQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostEntryQueryHandler.cs
new file mode 100644
index 0000000..1dcd17d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/GetFinanceCostEntryQueryHandler.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Application.App.Finance.Cost.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
+
+///
+/// 成本录入查询处理器。
+///
+public sealed class GetFinanceCostEntryQueryHandler(
+ IFinanceCostRepository financeCostRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceCostEntryQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并查询月度快照。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedMonth = FinanceCostMapping.NormalizeMonthStart(request.CostMonth);
+ var snapshot = await financeCostRepository.GetMonthSnapshotAsync(
+ tenantId,
+ request.Dimension,
+ request.StoreId,
+ normalizedMonth,
+ cancellationToken);
+
+ // 2. 映射 DTO 返回。
+ return FinanceCostMapping.ToEntryDto(snapshot);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/SaveFinanceCostEntryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/SaveFinanceCostEntryCommandHandler.cs
new file mode 100644
index 0000000..a1be885
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Handlers/SaveFinanceCostEntryCommandHandler.cs
@@ -0,0 +1,66 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Cost.Commands;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
+
+///
+/// 成本录入保存处理器。
+///
+public sealed class SaveFinanceCostEntryCommandHandler(
+ IFinanceCostRepository financeCostRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ SaveFinanceCostEntryCommand request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 归一化入参并组装仓储快照模型。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedMonth = FinanceCostMapping.NormalizeMonthStart(request.CostMonth);
+ var categories = request.Categories.Select(category => new FinanceCostCategorySnapshot
+ {
+ Category = category.Category,
+ TotalAmount = FinanceCostMapping.RoundAmount(category.TotalAmount),
+ Items = (category.Items ?? [])
+ .Select(item => new FinanceCostDetailItemSnapshot
+ {
+ ItemId = item.ItemId,
+ ItemName = item.ItemName,
+ Amount = FinanceCostMapping.RoundAmount(item.Amount),
+ Quantity = item.Quantity.HasValue
+ ? FinanceCostMapping.RoundAmount(item.Quantity.Value)
+ : null,
+ UnitPrice = item.UnitPrice.HasValue
+ ? FinanceCostMapping.RoundAmount(item.UnitPrice.Value)
+ : null,
+ SortOrder = item.SortOrder
+ })
+ .ToList()
+ }).ToList();
+
+ // 2. 持久化保存并重新查询最新快照。
+ await financeCostRepository.SaveMonthSnapshotAsync(
+ tenantId,
+ request.Dimension,
+ request.StoreId,
+ normalizedMonth,
+ categories,
+ cancellationToken);
+
+ var snapshot = await financeCostRepository.GetMonthSnapshotAsync(
+ tenantId,
+ request.Dimension,
+ request.StoreId,
+ normalizedMonth,
+ cancellationToken);
+
+ // 3. 映射为页面 DTO 返回。
+ return FinanceCostMapping.ToEntryDto(snapshot);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostAnalysisQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostAnalysisQuery.cs
new file mode 100644
index 0000000..ae3dce6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostAnalysisQuery.cs
@@ -0,0 +1,31 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Queries;
+
+///
+/// 查询成本分析页数据。
+///
+public sealed class GetFinanceCostAnalysisQuery : IRequest
+{
+ ///
+ /// 统计维度。
+ ///
+ public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
+
+ ///
+ /// 门店标识(门店维度必填)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 目标月份(UTC 每月第一天)。
+ ///
+ public DateTime CostMonth { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// 趋势月份数量。
+ ///
+ public int TrendMonthCount { get; init; } = 6;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostEntryQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostEntryQuery.cs
new file mode 100644
index 0000000..54147fb
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Queries/GetFinanceCostEntryQuery.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Cost.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Queries;
+
+///
+/// 查询成本录入页数据。
+///
+public sealed class GetFinanceCostEntryQuery : IRequest
+{
+ ///
+ /// 统计维度。
+ ///
+ public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
+
+ ///
+ /// 门店标识(门店维度必填)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 目标月份(UTC 每月第一天)。
+ ///
+ public DateTime CostMonth { get; init; } = DateTime.UtcNow;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostAnalysisQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostAnalysisQueryValidator.cs
new file mode 100644
index 0000000..12a0d95
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostAnalysisQueryValidator.cs
@@ -0,0 +1,46 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Cost.Queries;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Validators;
+
+///
+/// 成本分析查询验证器。
+///
+public sealed class GetFinanceCostAnalysisQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceCostAnalysisQueryValidator()
+ {
+ 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.CostMonth)
+ .Must(IsMonthInExpectedRange)
+ .WithMessage("month 非法");
+
+ RuleFor(x => x.TrendMonthCount)
+ .InclusiveBetween(3, 12);
+ }
+
+ private static bool IsMonthInExpectedRange(DateTime value)
+ {
+ var month = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+
+ return month.Year is >= 2000 and <= 2100;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostEntryQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostEntryQueryValidator.cs
new file mode 100644
index 0000000..c18ec01
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/GetFinanceCostEntryQueryValidator.cs
@@ -0,0 +1,43 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Cost.Queries;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Validators;
+
+///
+/// 成本录入查询验证器。
+///
+public sealed class GetFinanceCostEntryQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceCostEntryQueryValidator()
+ {
+ 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.CostMonth)
+ .Must(IsMonthInExpectedRange)
+ .WithMessage("month 非法");
+ }
+
+ private static bool IsMonthInExpectedRange(DateTime value)
+ {
+ var month = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+
+ return month.Year is >= 2000 and <= 2100;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/SaveFinanceCostEntryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/SaveFinanceCostEntryCommandValidator.cs
new file mode 100644
index 0000000..55d06b2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Cost/Validators/SaveFinanceCostEntryCommandValidator.cs
@@ -0,0 +1,120 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Cost.Commands;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Cost.Validators;
+
+///
+/// 成本录入保存验证器。
+///
+public sealed class SaveFinanceCostEntryCommandValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public SaveFinanceCostEntryCommandValidator()
+ {
+ RuleFor(x => x.Dimension)
+ .Must(value => value is FinanceCostDimension.Tenant or FinanceCostDimension.Store)
+ .WithMessage("dimension 非法");
+
+ RuleFor(x => x)
+ .Must(command =>
+ command.Dimension != FinanceCostDimension.Store ||
+ (command.StoreId.HasValue && command.StoreId.Value > 0))
+ .WithMessage("storeId 非法");
+
+ RuleFor(x => x.CostMonth)
+ .Must(IsMonthInExpectedRange)
+ .WithMessage("month 非法");
+
+ RuleFor(x => x.Categories)
+ .NotNull()
+ .Must(categories => categories.Count > 0)
+ .WithMessage("categories 不能为空");
+
+ RuleFor(x => x.Categories)
+ .Must(HaveDistinctCategories)
+ .WithMessage("分类重复");
+
+ RuleForEach(x => x.Categories)
+ .SetValidator(new SaveFinanceCostCategoryCommandItemValidator());
+ }
+
+ private static bool HaveDistinctCategories(IReadOnlyList categories)
+ {
+ var normalized = (categories ?? [])
+ .Select(item => item.Category)
+ .Where(value => value != default)
+ .ToList();
+
+ return normalized.Count == normalized.Distinct().Count();
+ }
+
+ private static bool IsMonthInExpectedRange(DateTime value)
+ {
+ var month = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+
+ return month.Year is >= 2000 and <= 2100;
+ }
+}
+
+///
+/// 成本分类保存项验证器。
+///
+public sealed class SaveFinanceCostCategoryCommandItemValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public SaveFinanceCostCategoryCommandItemValidator()
+ {
+ RuleFor(x => x.Category)
+ .Must(value => value is FinanceCostCategory.FoodMaterial
+ or FinanceCostCategory.Labor
+ or FinanceCostCategory.FixedExpense
+ or FinanceCostCategory.PackagingConsumable)
+ .WithMessage("category 非法");
+
+ RuleFor(x => x.TotalAmount)
+ .GreaterThanOrEqualTo(0);
+
+ RuleForEach(x => x.Items)
+ .SetValidator(new SaveFinanceCostDetailCommandItemValidator());
+ }
+}
+
+///
+/// 成本明细保存项验证器。
+///
+public sealed class SaveFinanceCostDetailCommandItemValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public SaveFinanceCostDetailCommandItemValidator()
+ {
+ RuleFor(x => x.ItemName)
+ .NotEmpty()
+ .MaximumLength(64);
+
+ RuleFor(x => x.Amount)
+ .GreaterThanOrEqualTo(0);
+
+ RuleFor(x => x.Quantity)
+ .GreaterThanOrEqualTo(0)
+ .When(x => x.Quantity.HasValue);
+
+ RuleFor(x => x.UnitPrice)
+ .GreaterThanOrEqualTo(0)
+ .When(x => x.UnitPrice.HasValue);
+
+ RuleFor(x => x.SortOrder)
+ .GreaterThanOrEqualTo(0);
+ }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntry.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntry.cs
new file mode 100644
index 0000000..2dec5a8
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntry.cs
@@ -0,0 +1,35 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 成本录入月度汇总实体(按维度 + 分类)。
+///
+public sealed class FinanceCostEntry : MultiTenantEntityBase
+{
+ ///
+ /// 统计维度。
+ ///
+ public FinanceCostDimension Dimension { get; set; } = FinanceCostDimension.Tenant;
+
+ ///
+ /// 门店标识(租户汇总维度为空)。
+ ///
+ public long? StoreId { get; set; }
+
+ ///
+ /// 成本月份(统一存储为 UTC 每月第一天 00:00:00)。
+ ///
+ public DateTime CostMonth { get; set; }
+
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; set; }
+
+ ///
+ /// 分类总金额。
+ ///
+ public decimal TotalAmount { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntryItem.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntryItem.cs
new file mode 100644
index 0000000..3d2379e
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostEntryItem.cs
@@ -0,0 +1,60 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 成本录入明细项实体。
+///
+public sealed class FinanceCostEntryItem : MultiTenantEntityBase
+{
+ ///
+ /// 关联汇总行标识。
+ ///
+ public long EntryId { get; set; }
+
+ ///
+ /// 统计维度。
+ ///
+ public FinanceCostDimension Dimension { get; set; } = FinanceCostDimension.Tenant;
+
+ ///
+ /// 门店标识(租户汇总维度为空)。
+ ///
+ public long? StoreId { get; set; }
+
+ ///
+ /// 成本月份(统一存储为 UTC 每月第一天 00:00:00)。
+ ///
+ public DateTime CostMonth { get; set; }
+
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; set; }
+
+ ///
+ /// 明细名称。
+ ///
+ public string ItemName { get; set; } = string.Empty;
+
+ ///
+ /// 明细金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 数量(人工类可用)。
+ ///
+ public decimal? Quantity { get; set; }
+
+ ///
+ /// 单价(人工类可用)。
+ ///
+ public decimal? UnitPrice { get; set; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; } = 100;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCategory.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCategory.cs
new file mode 100644
index 0000000..9d2d665
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCategory.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 成本分类。
+///
+public enum FinanceCostCategory
+{
+ ///
+ /// 食材原料。
+ ///
+ FoodMaterial = 1,
+
+ ///
+ /// 人工成本。
+ ///
+ Labor = 2,
+
+ ///
+ /// 固定费用。
+ ///
+ FixedExpense = 3,
+
+ ///
+ /// 包装耗材。
+ ///
+ PackagingConsumable = 4
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostDimension.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostDimension.cs
new file mode 100644
index 0000000..99e7b78
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostDimension.cs
@@ -0,0 +1,17 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 成本统计维度。
+///
+public enum FinanceCostDimension
+{
+ ///
+ /// 租户汇总维度。
+ ///
+ Tenant = 1,
+
+ ///
+ /// 门店维度。
+ ///
+ Store = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceCostModels.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceCostModels.cs
new file mode 100644
index 0000000..2280816
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceCostModels.cs
@@ -0,0 +1,214 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Domain.Finance.Models;
+
+///
+/// 成本明细项快照。
+///
+public sealed record FinanceCostDetailItemSnapshot
+{
+ ///
+ /// 明细标识。
+ ///
+ public long? ItemId { get; init; }
+
+ ///
+ /// 明细名称。
+ ///
+ public required string ItemName { get; init; }
+
+ ///
+ /// 明细金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 数量(人工类可用)。
+ ///
+ public decimal? Quantity { get; init; }
+
+ ///
+ /// 单价(人工类可用)。
+ ///
+ public decimal? UnitPrice { get; init; }
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; init; }
+}
+
+///
+/// 成本分类快照。
+///
+public sealed record FinanceCostCategorySnapshot
+{
+ ///
+ /// 成本分类。
+ ///
+ public required FinanceCostCategory Category { get; init; }
+
+ ///
+ /// 分类总金额。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 分类明细。
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+}
+
+///
+/// 成本录入页快照。
+///
+public sealed record FinanceCostMonthSnapshot
+{
+ ///
+ /// 统计维度。
+ ///
+ public required FinanceCostDimension Dimension { get; init; }
+
+ ///
+ /// 门店标识(租户维度为空)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 成本月份。
+ ///
+ public required DateTime CostMonth { get; init; }
+
+ ///
+ /// 本月营业额。
+ ///
+ public decimal MonthRevenue { get; init; }
+
+ ///
+ /// 成本分类集合。
+ ///
+ public IReadOnlyList Categories { get; init; } = [];
+}
+
+///
+/// 月度趋势行。
+///
+public sealed record FinanceCostTrendSnapshot
+{
+ ///
+ /// 月份起始时间(UTC)。
+ ///
+ public required DateTime MonthStartUtc { get; init; }
+
+ ///
+ /// 月度总成本。
+ ///
+ public decimal TotalCost { get; init; }
+
+ ///
+ /// 月度营业额。
+ ///
+ public decimal Revenue { get; init; }
+}
+
+///
+/// 月度成本明细表行。
+///
+public sealed record FinanceCostMonthlyDetailSnapshot
+{
+ ///
+ /// 月份起始时间(UTC)。
+ ///
+ public required DateTime MonthStartUtc { get; init; }
+
+ ///
+ /// 食材成本。
+ ///
+ public decimal FoodAmount { get; init; }
+
+ ///
+ /// 人工成本。
+ ///
+ public decimal LaborAmount { get; init; }
+
+ ///
+ /// 固定费用。
+ ///
+ public decimal FixedAmount { get; init; }
+
+ ///
+ /// 包装耗材。
+ ///
+ public decimal PackagingAmount { get; init; }
+
+ ///
+ /// 月度总成本。
+ ///
+ public decimal TotalCost { get; init; }
+
+ ///
+ /// 月度营业额。
+ ///
+ public decimal Revenue { get; init; }
+}
+
+///
+/// 成本分析快照。
+///
+public sealed record FinanceCostAnalysisSnapshot
+{
+ ///
+ /// 统计维度。
+ ///
+ public required FinanceCostDimension Dimension { get; init; }
+
+ ///
+ /// 门店标识(租户维度为空)。
+ ///
+ public long? StoreId { get; init; }
+
+ ///
+ /// 当前月份。
+ ///
+ public required DateTime CostMonth { get; init; }
+
+ ///
+ /// 当前月总成本。
+ ///
+ public decimal CurrentTotalCost { get; init; }
+
+ ///
+ /// 当前月食材成本。
+ ///
+ public decimal CurrentFoodAmount { get; init; }
+
+ ///
+ /// 当前月营业额。
+ ///
+ public decimal CurrentRevenue { get; init; }
+
+ ///
+ /// 当前月支付成功订单数。
+ ///
+ public int CurrentPaidOrderCount { get; init; }
+
+ ///
+ /// 环比变化率(%)。
+ ///
+ public decimal MonthOnMonthChangeRate { get; init; }
+
+ ///
+ /// 分类构成。
+ ///
+ public IReadOnlyList CurrentCategories { get; init; } = [];
+
+ ///
+ /// 近 N 月趋势。
+ ///
+ public IReadOnlyList Trends { get; init; } = [];
+
+ ///
+ /// 明细表数据。
+ ///
+ public IReadOnlyList DetailRows { get; init; } = [];
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceCostRepository.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceCostRepository.cs
new file mode 100644
index 0000000..225a3a3
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceCostRepository.cs
@@ -0,0 +1,42 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Domain.Finance.Repositories;
+
+///
+/// 成本管理仓储契约。
+///
+public interface IFinanceCostRepository
+{
+ ///
+ /// 获取成本录入页月度快照。
+ ///
+ Task GetMonthSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime costMonth,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 保存月度成本录入快照。
+ ///
+ Task SaveMonthSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime costMonth,
+ IReadOnlyList categories,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取成本分析页快照。
+ ///
+ Task GetAnalysisSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime costMonth,
+ int trendMonthCount,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 0915823..0318eab 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -55,6 +55,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 a02a0c2..ac4bbc9 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -8,6 +8,7 @@ using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Distribution.Entities;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Engagement.Entities;
+using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.GroupBuying.Entities;
using TakeoutSaaS.Domain.Inventory.Entities;
using TakeoutSaaS.Domain.Membership.Entities;
@@ -94,6 +95,14 @@ public sealed class TakeoutAppDbContext(
///
public DbSet TenantVisibilityRoleRules => Set();
///
+ /// 成本录入汇总。
+ ///
+ public DbSet FinanceCostEntries => Set();
+ ///
+ /// 成本录入明细。
+ ///
+ public DbSet FinanceCostEntryItems => Set();
+ ///
/// 配额包定义。
///
public DbSet QuotaPackages => Set();
@@ -525,6 +534,8 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncementRead(modelBuilder.Entity());
ConfigureTenantVerificationProfile(modelBuilder.Entity());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity());
+ ConfigureFinanceCostEntry(modelBuilder.Entity());
+ ConfigureFinanceCostEntryItem(modelBuilder.Entity());
ConfigureQuotaPackage(modelBuilder.Entity());
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity());
ConfigureMerchantDocument(modelBuilder.Entity());
@@ -1042,6 +1053,46 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.TenantId).IsUnique();
}
+ private static void ConfigureFinanceCostEntry(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_cost_entries");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.Dimension).HasConversion().IsRequired();
+ builder.Property(x => x.StoreId);
+ builder.Property(x => x.CostMonth).IsRequired();
+ builder.Property(x => x.Category).HasConversion().IsRequired();
+ builder.Property(x => x.TotalAmount).HasPrecision(18, 2);
+
+ builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth, x.Category }).IsUnique();
+ builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth });
+ }
+
+ private static void ConfigureFinanceCostEntryItem(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_cost_entry_items");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.EntryId).IsRequired();
+ builder.Property(x => x.Dimension).HasConversion().IsRequired();
+ builder.Property(x => x.StoreId);
+ builder.Property(x => x.CostMonth).IsRequired();
+ builder.Property(x => x.Category).HasConversion().IsRequired();
+ builder.Property(x => x.ItemName).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.Amount).HasPrecision(18, 2);
+ builder.Property(x => x.Quantity).HasPrecision(18, 2);
+ builder.Property(x => x.UnitPrice).HasPrecision(18, 2);
+ builder.Property(x => x.SortOrder).HasDefaultValue(100);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(x => x.EntryId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasIndex(x => x.EntryId);
+ builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth, x.Category, x.SortOrder });
+ }
+
private static void ConfigureTenantAnnouncement(EntityTypeBuilder builder)
{
builder.ToTable("tenant_announcements");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceCostRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceCostRepository.cs
new file mode 100644
index 0000000..77770a5
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceCostRepository.cs
@@ -0,0 +1,527 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Finance.Entities;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 成本管理 EF Core 仓储实现。
+///
+public sealed class EfFinanceCostRepository(TakeoutAppDbContext context) : IFinanceCostRepository
+{
+ private static readonly FinanceCostCategory[] CategoryOrder =
+ [
+ FinanceCostCategory.FoodMaterial,
+ FinanceCostCategory.Labor,
+ FinanceCostCategory.FixedExpense,
+ FinanceCostCategory.PackagingConsumable
+ ];
+
+ ///
+ public async Task GetMonthSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime costMonth,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 归一化月份并加载分类快照。
+ var normalizedMonth = NormalizeMonthStart(costMonth);
+ var normalizedStoreId = NormalizeStoreId(dimension, storeId);
+ var categories = await GetCategorySnapshotsAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ normalizedMonth,
+ cancellationToken);
+
+ // 2. 读取本月营业额(真实订单与支付记录聚合)。
+ var monthRevenue = await GetRevenueByMonthAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ normalizedMonth,
+ cancellationToken);
+
+ return new FinanceCostMonthSnapshot
+ {
+ Dimension = dimension,
+ StoreId = normalizedStoreId,
+ CostMonth = normalizedMonth,
+ MonthRevenue = monthRevenue,
+ Categories = categories
+ };
+ }
+
+ ///
+ public async Task SaveMonthSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime costMonth,
+ IReadOnlyList categories,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 归一化入参与分类数据。
+ var normalizedMonth = NormalizeMonthStart(costMonth);
+ var normalizedStoreId = NormalizeStoreId(dimension, storeId);
+ var normalizedCategories = NormalizeCategoriesForSave(categories);
+
+ // 2. 删除同维度同月份历史记录(先删明细,再删汇总)。
+ var existingEntries = await context.FinanceCostEntries
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.Dimension == dimension &&
+ item.CostMonth == normalizedMonth &&
+ ((dimension == FinanceCostDimension.Store && item.StoreId == normalizedStoreId) ||
+ (dimension == FinanceCostDimension.Tenant && item.StoreId == null)))
+ .ToListAsync(cancellationToken);
+
+ var existingEntryIds = existingEntries.Select(item => item.Id).ToList();
+ if (existingEntryIds.Count > 0)
+ {
+ var existingItems = await context.FinanceCostEntryItems
+ .Where(item => existingEntryIds.Contains(item.EntryId))
+ .ToListAsync(cancellationToken);
+ context.FinanceCostEntryItems.RemoveRange(existingItems);
+ }
+
+ context.FinanceCostEntries.RemoveRange(existingEntries);
+ await context.SaveChangesAsync(cancellationToken);
+
+ // 3. 新增汇总行并持久化,拿到主键。
+ var newEntries = normalizedCategories
+ .Select(item => new FinanceCostEntry
+ {
+ TenantId = tenantId,
+ Dimension = dimension,
+ StoreId = normalizedStoreId,
+ CostMonth = normalizedMonth,
+ Category = item.Category,
+ TotalAmount = RoundAmount(item.TotalAmount)
+ })
+ .ToList();
+
+ if (newEntries.Count > 0)
+ {
+ await context.FinanceCostEntries.AddRangeAsync(newEntries, cancellationToken);
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ // 4. 写入明细项并持久化。
+ var entryIdMap = newEntries.ToDictionary(item => item.Category, item => item.Id);
+ var newItems = new List();
+ foreach (var category in normalizedCategories)
+ {
+ if (!entryIdMap.TryGetValue(category.Category, out var entryId))
+ {
+ continue;
+ }
+
+ foreach (var detail in category.Items.OrderBy(item => item.SortOrder).ThenBy(item => item.ItemName))
+ {
+ newItems.Add(new FinanceCostEntryItem
+ {
+ TenantId = tenantId,
+ EntryId = entryId,
+ Dimension = dimension,
+ StoreId = normalizedStoreId,
+ CostMonth = normalizedMonth,
+ Category = category.Category,
+ ItemName = detail.ItemName.Trim(),
+ Amount = RoundAmount(detail.Amount),
+ Quantity = detail.Quantity.HasValue ? RoundAmount(detail.Quantity.Value) : null,
+ UnitPrice = detail.UnitPrice.HasValue ? RoundAmount(detail.UnitPrice.Value) : null,
+ SortOrder = detail.SortOrder
+ });
+ }
+ }
+
+ if (newItems.Count > 0)
+ {
+ await context.FinanceCostEntryItems.AddRangeAsync(newItems, cancellationToken);
+ }
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public async Task GetAnalysisSnapshotAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime costMonth,
+ int trendMonthCount,
+ CancellationToken cancellationToken = default)
+ {
+ // 1. 归一化参数并生成趋势月份序列。
+ var normalizedMonth = NormalizeMonthStart(costMonth);
+ var normalizedStoreId = NormalizeStoreId(dimension, storeId);
+ var normalizedTrendCount = Math.Clamp(trendMonthCount, 3, 12);
+ var trendMonths = BuildTrendMonths(normalizedMonth, normalizedTrendCount);
+
+ // 2. 读取当前月分类、营业额、已支付订单量。
+ var currentCategories = await GetCategorySnapshotsAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ normalizedMonth,
+ cancellationToken);
+ var currentTotalCost = RoundAmount(currentCategories.Sum(item => item.TotalAmount));
+ var currentFoodAmount = RoundAmount(currentCategories
+ .FirstOrDefault(item => item.Category == FinanceCostCategory.FoodMaterial)
+ ?.TotalAmount ?? 0m);
+ var currentRevenue = await GetRevenueByMonthAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ normalizedMonth,
+ cancellationToken);
+ var currentPaidOrderCount = await GetPaidOrderCountByMonthAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ normalizedMonth,
+ cancellationToken);
+
+ // 3. 计算环比变化(与上月总成本对比)。
+ var previousMonth = normalizedMonth.AddMonths(-1);
+ var previousCategories = await GetCategorySnapshotsAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ previousMonth,
+ cancellationToken);
+ var previousTotalCost = RoundAmount(previousCategories.Sum(item => item.TotalAmount));
+ var monthOnMonthChangeRate = CalculateMonthOnMonthChangeRate(currentTotalCost, previousTotalCost);
+
+ // 4. 组装趋势与明细表行。
+ var trends = new List(trendMonths.Count);
+ var detailRows = new List(trendMonths.Count);
+
+ foreach (var month in trendMonths)
+ {
+ var monthCategories = await GetCategorySnapshotsAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ month,
+ cancellationToken);
+ var monthRevenue = await GetRevenueByMonthAsync(
+ tenantId,
+ dimension,
+ normalizedStoreId,
+ month,
+ cancellationToken);
+
+ var foodAmount = RoundAmount(monthCategories
+ .FirstOrDefault(item => item.Category == FinanceCostCategory.FoodMaterial)
+ ?.TotalAmount ?? 0m);
+ var laborAmount = RoundAmount(monthCategories
+ .FirstOrDefault(item => item.Category == FinanceCostCategory.Labor)
+ ?.TotalAmount ?? 0m);
+ var fixedAmount = RoundAmount(monthCategories
+ .FirstOrDefault(item => item.Category == FinanceCostCategory.FixedExpense)
+ ?.TotalAmount ?? 0m);
+ var packagingAmount = RoundAmount(monthCategories
+ .FirstOrDefault(item => item.Category == FinanceCostCategory.PackagingConsumable)
+ ?.TotalAmount ?? 0m);
+ var monthTotalCost = RoundAmount(foodAmount + laborAmount + fixedAmount + packagingAmount);
+
+ trends.Add(new FinanceCostTrendSnapshot
+ {
+ MonthStartUtc = month,
+ TotalCost = monthTotalCost,
+ Revenue = monthRevenue
+ });
+
+ detailRows.Add(new FinanceCostMonthlyDetailSnapshot
+ {
+ MonthStartUtc = month,
+ FoodAmount = foodAmount,
+ LaborAmount = laborAmount,
+ FixedAmount = fixedAmount,
+ PackagingAmount = packagingAmount,
+ TotalCost = monthTotalCost,
+ Revenue = monthRevenue
+ });
+ }
+
+ return new FinanceCostAnalysisSnapshot
+ {
+ Dimension = dimension,
+ StoreId = normalizedStoreId,
+ CostMonth = normalizedMonth,
+ CurrentTotalCost = currentTotalCost,
+ CurrentFoodAmount = currentFoodAmount,
+ CurrentRevenue = currentRevenue,
+ CurrentPaidOrderCount = currentPaidOrderCount,
+ MonthOnMonthChangeRate = monthOnMonthChangeRate,
+ CurrentCategories = currentCategories,
+ Trends = trends,
+ DetailRows = detailRows
+ };
+ }
+
+ private async Task> GetCategorySnapshotsAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime month,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取当月汇总与明细。
+ var entryQuery = context.FinanceCostEntries
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.Dimension == dimension &&
+ item.CostMonth == month &&
+ ((dimension == FinanceCostDimension.Store && item.StoreId == storeId) ||
+ (dimension == FinanceCostDimension.Tenant && item.StoreId == null)));
+ var entries = await entryQuery.ToListAsync(cancellationToken);
+
+ var entryIds = entries.Select(item => item.Id).ToList();
+ var items = entryIds.Count == 0
+ ? []
+ : await context.FinanceCostEntryItems
+ .AsNoTracking()
+ .Where(item => entryIds.Contains(item.EntryId))
+ .OrderBy(item => item.SortOrder)
+ .ThenBy(item => item.Id)
+ .ToListAsync(cancellationToken);
+
+ // 2. 按分类聚合,补齐默认分类顺序。
+ var entryMap = entries.ToDictionary(item => item.Category, item => item);
+ var itemGroupMap = items
+ .GroupBy(item => item.Category)
+ .ToDictionary(group => group.Key, group => group.ToList());
+
+ return CategoryOrder.Select(category =>
+ {
+ var totalAmount = entryMap.TryGetValue(category, out var entry)
+ ? RoundAmount(entry.TotalAmount)
+ : 0m;
+ var details = itemGroupMap.TryGetValue(category, out var group)
+ ? group.Select(detail => new FinanceCostDetailItemSnapshot
+ {
+ ItemId = detail.Id,
+ ItemName = detail.ItemName,
+ Amount = RoundAmount(detail.Amount),
+ Quantity = detail.Quantity.HasValue ? RoundAmount(detail.Quantity.Value) : null,
+ UnitPrice = detail.UnitPrice.HasValue ? RoundAmount(detail.UnitPrice.Value) : null,
+ SortOrder = detail.SortOrder
+ }).ToList()
+ : [];
+
+ return new FinanceCostCategorySnapshot
+ {
+ Category = category,
+ TotalAmount = totalAmount,
+ Items = details
+ };
+ }).ToList();
+ }
+
+ private async Task GetRevenueByMonthAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime monthStartUtc,
+ CancellationToken cancellationToken)
+ {
+ var monthEnd = monthStartUtc.AddMonths(1);
+
+ // 1. 聚合支付成功金额。
+ var paidQuery =
+ from payment in context.PaymentRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on payment.OrderId equals order.Id
+ where payment.TenantId == tenantId
+ && order.TenantId == tenantId
+ && payment.Status == PaymentStatus.Paid
+ && (payment.PaidAt ?? payment.CreatedAt) >= monthStartUtc
+ && (payment.PaidAt ?? payment.CreatedAt) < monthEnd
+ select new
+ {
+ order.StoreId,
+ payment.Amount
+ };
+
+ if (dimension == FinanceCostDimension.Store && storeId.HasValue)
+ {
+ paidQuery = paidQuery.Where(item => item.StoreId == storeId.Value);
+ }
+
+ var totalPaidAmount = await paidQuery
+ .Select(item => item.Amount)
+ .DefaultIfEmpty(0m)
+ .SumAsync(cancellationToken);
+
+ // 2. 聚合退款成功金额。
+ var refundQuery =
+ from refund in context.PaymentRefundRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on refund.OrderId equals order.Id
+ where refund.TenantId == tenantId
+ && order.TenantId == tenantId
+ && refund.Status == PaymentRefundStatus.Succeeded
+ && (refund.CompletedAt ?? refund.RequestedAt) >= monthStartUtc
+ && (refund.CompletedAt ?? refund.RequestedAt) < monthEnd
+ select new
+ {
+ order.StoreId,
+ refund.Amount
+ };
+
+ if (dimension == FinanceCostDimension.Store && storeId.HasValue)
+ {
+ refundQuery = refundQuery.Where(item => item.StoreId == storeId.Value);
+ }
+
+ var totalRefundAmount = await refundQuery
+ .Select(item => item.Amount)
+ .DefaultIfEmpty(0m)
+ .SumAsync(cancellationToken);
+
+ return RoundAmount(totalPaidAmount - totalRefundAmount);
+ }
+
+ private async Task GetPaidOrderCountByMonthAsync(
+ long tenantId,
+ FinanceCostDimension dimension,
+ long? storeId,
+ DateTime monthStartUtc,
+ CancellationToken cancellationToken)
+ {
+ var monthEnd = monthStartUtc.AddMonths(1);
+ var paidOrderQuery =
+ from payment in context.PaymentRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on payment.OrderId equals order.Id
+ where payment.TenantId == tenantId
+ && order.TenantId == tenantId
+ && payment.Status == PaymentStatus.Paid
+ && (payment.PaidAt ?? payment.CreatedAt) >= monthStartUtc
+ && (payment.PaidAt ?? payment.CreatedAt) < monthEnd
+ select new
+ {
+ order.StoreId,
+ payment.OrderId
+ };
+
+ if (dimension == FinanceCostDimension.Store && storeId.HasValue)
+ {
+ paidOrderQuery = paidOrderQuery.Where(item => item.StoreId == storeId.Value);
+ }
+
+ return await paidOrderQuery
+ .Select(item => item.OrderId)
+ .Distinct()
+ .CountAsync(cancellationToken);
+ }
+
+ private static IReadOnlyList NormalizeCategoriesForSave(
+ IReadOnlyList categories)
+ {
+ var source = categories ?? [];
+ var map = source
+ .GroupBy(item => item.Category)
+ .ToDictionary(group => group.Key, group => group.First());
+
+ return CategoryOrder.Select((category, index) =>
+ {
+ if (!map.TryGetValue(category, out var current))
+ {
+ return new FinanceCostCategorySnapshot
+ {
+ Category = category,
+ TotalAmount = 0m,
+ Items = []
+ };
+ }
+
+ var normalizedItems = (current.Items ?? [])
+ .Where(item => !string.IsNullOrWhiteSpace(item.ItemName))
+ .Select((item, itemIndex) => new FinanceCostDetailItemSnapshot
+ {
+ ItemId = item.ItemId,
+ ItemName = item.ItemName.Trim(),
+ Amount = RoundAmount(item.Amount),
+ Quantity = item.Quantity.HasValue ? RoundAmount(item.Quantity.Value) : null,
+ UnitPrice = item.UnitPrice.HasValue ? RoundAmount(item.UnitPrice.Value) : null,
+ SortOrder = item.SortOrder <= 0 ? itemIndex + 1 : item.SortOrder
+ })
+ .OrderBy(item => item.SortOrder)
+ .ThenBy(item => item.ItemName)
+ .ToList();
+
+ var totalAmount = current.TotalAmount > 0
+ ? RoundAmount(current.TotalAmount)
+ : RoundAmount(normalizedItems.Sum(item => item.Amount));
+
+ return new FinanceCostCategorySnapshot
+ {
+ Category = category,
+ TotalAmount = totalAmount,
+ Items = normalizedItems
+ };
+ }).ToList();
+ }
+
+ private static DateTime NormalizeMonthStart(DateTime value)
+ {
+ var utcValue = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+
+ return new DateTime(utcValue.Year, utcValue.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+
+ private static long? NormalizeStoreId(FinanceCostDimension dimension, long? storeId)
+ {
+ if (dimension == FinanceCostDimension.Tenant)
+ {
+ return null;
+ }
+
+ return storeId.HasValue && storeId.Value > 0
+ ? storeId.Value
+ : null;
+ }
+
+ private static decimal RoundAmount(decimal value)
+ {
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal CalculateMonthOnMonthChangeRate(decimal currentValue, decimal previousValue)
+ {
+ if (previousValue <= 0)
+ {
+ return currentValue <= 0 ? 0m : 100m;
+ }
+
+ var rate = (currentValue - previousValue) / previousValue * 100m;
+ return RoundAmount(rate);
+ }
+
+ private static List BuildTrendMonths(DateTime currentMonth, int trendMonthCount)
+ {
+ var startMonth = currentMonth.AddMonths(0 - Math.Max(1, trendMonthCount) + 1);
+ var result = new List(trendMonthCount);
+ for (var index = 0; index < trendMonthCount; index++)
+ {
+ result.Add(startMonth.AddMonths(index));
+ }
+
+ return result;
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305010000_AddFinanceCostModule.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305010000_AddFinanceCostModule.cs
new file mode 100644
index 0000000..7a26f3c
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305010000_AddFinanceCostModule.cs
@@ -0,0 +1,112 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations;
+
+///
+/// 新增财务中心成本管理表结构。
+///
+[DbContext(typeof(TakeoutAppDbContext))]
+[Migration("20260305010000_AddFinanceCostModule")]
+public sealed class AddFinanceCostModule : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "finance_cost_entries",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。")
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Dimension = table.Column(type: "integer", nullable: false, comment: "统计维度。"),
+ StoreId = table.Column(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
+ CostMonth = table.Column(type: "timestamp with time zone", nullable: false, comment: "成本月份(UTC 每月第一天)。"),
+ Category = table.Column(type: "integer", nullable: false, comment: "成本分类。"),
+ TotalAmount = 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_cost_entries", x => x.Id);
+ },
+ comment: "财务成本录入月度汇总。");
+
+ migrationBuilder.CreateTable(
+ name: "finance_cost_entry_items",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。")
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ EntryId = table.Column(type: "bigint", nullable: false, comment: "关联汇总标识。"),
+ Dimension = table.Column(type: "integer", nullable: false, comment: "统计维度。"),
+ StoreId = table.Column(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
+ CostMonth = table.Column(type: "timestamp with time zone", nullable: false, comment: "成本月份(UTC 每月第一天)。"),
+ Category = table.Column(type: "integer", nullable: false, comment: "成本分类。"),
+ ItemName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "明细名称。"),
+ Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "明细金额。"),
+ Quantity = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "数量(人工类可用)。"),
+ UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "单价(人工类可用)。"),
+ SortOrder = table.Column(type: "integer", nullable: false, defaultValue: 100, 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_cost_entry_items", x => x.Id);
+ table.ForeignKey(
+ name: "FK_finance_cost_entry_items_finance_cost_entries_EntryId",
+ column: x => x.EntryId,
+ principalTable: "finance_cost_entries",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ },
+ comment: "财务成本录入明细项。");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth",
+ table: "finance_cost_entries",
+ columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth_C~",
+ table: "finance_cost_entries",
+ columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_cost_entry_items_EntryId",
+ table: "finance_cost_entry_items",
+ column: "EntryId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_cost_entry_items_TenantId_Dimension_StoreId_CostMon~",
+ table: "finance_cost_entry_items",
+ columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category", "SortOrder" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "finance_cost_entry_items");
+
+ migrationBuilder.DropTable(
+ name: "finance_cost_entries");
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305013000_SeedFinanceCostMenuAndPermissions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305013000_SeedFinanceCostMenuAndPermissions.cs
new file mode 100644
index 0000000..da33e60
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305013000_SeedFinanceCostMenuAndPermissions.cs
@@ -0,0 +1,243 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using TakeoutSaaS.Infrastructure.Identity.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
+
+///
+/// 写入成本管理菜单与权限定义。
+///
+[DbContext(typeof(IdentityDbContext))]
+[Migration("20260305013000_SeedFinanceCostMenuAndPermissions")]
+public sealed class SeedFinanceCostMenuAndPermissions : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ """
+ DO $$
+ DECLARE
+ v_parent_permission_id bigint;
+ v_view_permission_id bigint;
+ v_manage_permission_id bigint;
+ v_parent_menu_id bigint;
+ v_cost_menu_id bigint;
+ v_permission_seed_base bigint := 840100000000000000;
+ v_menu_seed_base bigint := 850100000000000000;
+ BEGIN
+ -- 1. 确保财务权限分组存在。
+ SELECT "Id"
+ INTO v_parent_permission_id
+ FROM public.permissions
+ WHERE "Code" = 'group:tenant:finance'
+ ORDER BY "Id"
+ LIMIT 1;
+
+ IF v_parent_permission_id IS NULL THEN
+ v_parent_permission_id := v_permission_seed_base + 1;
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_parent_permission_id, '财务中心', 'group:tenant:finance', '财务中心权限分组',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ 0, 5000, 'group', 1)
+ ON CONFLICT ("Code") DO NOTHING;
+ END IF;
+
+ -- 2. Upsert 成本管理查看权限。
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_permission_seed_base + 11, '成本管理查看', 'tenant:finance:cost:view', '查看成本录入与成本分析',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ v_parent_permission_id, 5110, 'leaf', 1)
+ ON CONFLICT ("Code") DO UPDATE
+ SET "Name" = EXCLUDED."Name",
+ "Description" = EXCLUDED."Description",
+ "ParentId" = EXCLUDED."ParentId",
+ "SortOrder" = EXCLUDED."SortOrder",
+ "Type" = EXCLUDED."Type",
+ "Portal" = EXCLUDED."Portal",
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW();
+
+ -- 3. Upsert 成本管理维护权限。
+ INSERT INTO public.permissions (
+ "Id", "Name", "Code", "Description",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy",
+ "ParentId", "SortOrder", "Type", "Portal")
+ VALUES (
+ v_permission_seed_base + 12, '成本管理维护', 'tenant:finance:cost:manage', '维护成本录入明细与保存数据',
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ v_parent_permission_id, 5120, 'leaf', 1)
+ ON CONFLICT ("Code") DO UPDATE
+ SET "Name" = EXCLUDED."Name",
+ "Description" = EXCLUDED."Description",
+ "ParentId" = EXCLUDED."ParentId",
+ "SortOrder" = EXCLUDED."SortOrder",
+ "Type" = EXCLUDED."Type",
+ "Portal" = EXCLUDED."Portal",
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW();
+
+ -- 4. 回填权限 ID。
+ SELECT "Id" INTO v_view_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:cost:view' LIMIT 1;
+ SELECT "Id" INTO v_manage_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:cost:manage' LIMIT 1;
+
+ -- 5. 确保租户端财务父菜单存在。
+ SELECT "Id"
+ INTO v_parent_menu_id
+ FROM public.menu_definitions
+ WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
+ ORDER BY "Id"
+ LIMIT 1;
+
+ IF v_parent_menu_id IS NULL THEN
+ v_parent_menu_id := v_menu_seed_base + 1;
+ INSERT INTO public.menu_definitions (
+ "Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
+ "IsIframe", "Link", "KeepAlive", "SortOrder",
+ "RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
+ "CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
+ VALUES (
+ v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '财务中心', 'lucide:wallet',
+ FALSE, NULL, FALSE, 500,
+ '', '', '', NULL,
+ NOW(), NULL, NULL, NULL, NULL, NULL, 1)
+ ON CONFLICT ("Id") DO NOTHING;
+ END IF;
+
+ -- 6. Upsert 成本管理菜单。
+ SELECT "Id"
+ INTO v_cost_menu_id
+ FROM public.menu_definitions
+ WHERE "Portal" = 1
+ AND ("Path" = '/finance/cost' OR ("Path" = 'cost' AND "Component" = '/finance/cost/index'))
+ ORDER BY "DeletedAt" NULLS FIRST, "Id"
+ LIMIT 1;
+
+ IF v_cost_menu_id IS NULL THEN
+ v_cost_menu_id := v_menu_seed_base + 11;
+ INSERT INTO public.menu_definitions (
+ "Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
+ "IsIframe", "Link", "KeepAlive", "SortOrder",
+ "RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
+ "CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
+ VALUES (
+ v_cost_menu_id, v_parent_menu_id, 'CostManagement', '/finance/cost', '/finance/cost/index', '成本管理', 'lucide:circle-dollar-sign',
+ FALSE, NULL, TRUE, 520,
+ 'tenant:finance:cost:view', 'tenant:finance:cost:view,tenant:finance:cost:manage', '', NULL,
+ NOW(), NULL, NULL, NULL, NULL, NULL, 1)
+ ON CONFLICT ("Id") DO NOTHING;
+ ELSE
+ UPDATE public.menu_definitions
+ SET "ParentId" = v_parent_menu_id,
+ "Name" = 'CostManagement',
+ "Path" = '/finance/cost',
+ "Component" = '/finance/cost/index',
+ "Title" = '成本管理',
+ "Icon" = 'lucide:circle-dollar-sign',
+ "IsIframe" = FALSE,
+ "Link" = NULL,
+ "KeepAlive" = TRUE,
+ "SortOrder" = 520,
+ "RequiredPermissions" = 'tenant:finance:cost:view',
+ "MetaPermissions" = 'tenant:finance:cost:view,tenant:finance:cost:manage',
+ "MetaRoles" = '',
+ "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW(),
+ "Portal" = 1
+ WHERE "Id" = v_cost_menu_id;
+ END IF;
+
+ -- 7. 为 tenant-admin 角色授予成本权限。
+ INSERT INTO public.role_permissions (
+ "Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
+ SELECT
+ ABS(HASHTEXTEXTENDED('tenant-admin:cost:' || role."Id"::text || ':' || permission_id::text, 0)),
+ role."Id",
+ permission_id,
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL,
+ role."TenantId",
+ 1
+ FROM public.roles role
+ CROSS JOIN LATERAL (
+ SELECT UNNEST(ARRAY[v_view_permission_id, v_manage_permission_id]) AS permission_id
+ ) item
+ WHERE role."Code" = 'tenant-admin'
+ AND role."DeletedAt" IS NULL
+ AND item.permission_id IS NOT NULL
+ ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
+ SET "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW(),
+ "Portal" = 1;
+
+ -- 8. 为 tenant-admin 角色模板授予成本权限。
+ INSERT INTO public.role_template_permissions (
+ "Id", "RoleTemplateId", "PermissionCode",
+ "CreatedAt", "UpdatedAt", "DeletedAt",
+ "CreatedBy", "UpdatedBy", "DeletedBy")
+ SELECT
+ ABS(HASHTEXTEXTENDED('template-cost:' || template."Id"::text || ':' || item.permission_code, 0)),
+ template."Id",
+ item.permission_code,
+ NOW(), NULL, NULL,
+ NULL, NULL, NULL
+ FROM public.role_templates template
+ CROSS JOIN LATERAL (
+ SELECT UNNEST(ARRAY['tenant:finance:cost:view', 'tenant:finance:cost:manage']) AS permission_code
+ ) item
+ WHERE template."TemplateCode" = 'tenant-admin'
+ AND template."DeletedAt" IS NULL
+ ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
+ SET "DeletedAt" = NULL,
+ "DeletedBy" = NULL,
+ "UpdatedAt" = NOW();
+ END $$;
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ """
+ DO $$
+ BEGIN
+ DELETE FROM public.role_permissions
+ WHERE "PermissionId" IN (
+ SELECT "Id"
+ FROM public.permissions
+ WHERE "Code" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage'));
+
+ DELETE FROM public.role_template_permissions
+ WHERE "PermissionCode" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage');
+
+ DELETE FROM public.menu_definitions
+ WHERE "Portal" = 1 AND "Path" = '/finance/cost';
+
+ DELETE FROM public.permissions
+ WHERE "Code" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage');
+ END $$;
+ """);
+ }
+}