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 $$; + """); + } +}