feat(finance): add cost management backend module #6
@@ -0,0 +1,384 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 成本模块通用作用域请求。
|
||||
/// </summary>
|
||||
public class FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度(tenant/store)。
|
||||
/// </summary>
|
||||
public string? Dimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string? Month { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryRequest : FinanceCostScopeRequest;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析查询请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisRequest : FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 趋势月份数量。
|
||||
/// </summary>
|
||||
public int TrendMonthCount { get; set; } = 6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入保存请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostEntryRequest : FinanceCostScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类列表。
|
||||
/// </summary>
|
||||
public List<SaveFinanceCostCategoryRequest> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类保存项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostCategoryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码(food/labor/fixed/packaging)。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类明细。
|
||||
/// </summary>
|
||||
public List<SaveFinanceCostDetailRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细保存项请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识(可空)。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal MonthRevenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryCategoryResponse> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryCategoryResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryDetailResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 统计卡。
|
||||
/// </summary>
|
||||
public FinanceCostAnalysisStatsResponse Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 趋势数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostTrendPointResponse> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 构成数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostCompositionResponse> Composition { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 明细表数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostMonthlyDetailResponse> DetailRows { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析统计卡响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本率(%)。
|
||||
/// </summary>
|
||||
public decimal FoodCostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单均成本。
|
||||
/// </summary>
|
||||
public decimal AverageCostPerPaidOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化(%)。
|
||||
/// </summary>
|
||||
public decimal MonthOnMonthChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月支付成功订单数。
|
||||
/// </summary>
|
||||
public int PaidOrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本趋势点响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostTrendPointResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 月度总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostCompositionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析明细表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostMonthlyDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本。
|
||||
/// </summary>
|
||||
public decimal FoodAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人工成本。
|
||||
/// </summary>
|
||||
public decimal LaborAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定费用。
|
||||
/// </summary>
|
||||
public decimal FixedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 包装耗材。
|
||||
/// </summary>
|
||||
public decimal PackagingAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心成本管理。
|
||||
/// </summary>
|
||||
[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";
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本录入数据。
|
||||
/// </summary>
|
||||
[HttpGet("entry")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostEntryResponse>> 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<FinanceCostEntryResponse>.Ok(MapEntry(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存成本录入数据。
|
||||
/// </summary>
|
||||
[HttpPost("entry/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostEntryResponse>> 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<FinanceCostEntryResponse>.Ok(MapEntry(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本分析数据。
|
||||
/// </summary>
|
||||
[HttpGet("analysis")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceCostAnalysisResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceCostAnalysisResponse>> 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<FinanceCostAnalysisResponse>.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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 保存成本录入数据。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostEntryCommand : IRequest<FinanceCostEntryDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标月份(UTC 每月第一天)。
|
||||
/// </summary>
|
||||
public DateTime CostMonth { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<SaveFinanceCostCategoryCommandItem> Categories { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类保存项。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostCategoryCommandItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public FinanceCostCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类明细项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<SaveFinanceCostDetailCommandItem> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细保存项。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostDetailCommandItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识(编辑时透传,可为空)。
|
||||
/// </summary>
|
||||
public long? ItemId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Cost.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入明细项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识。
|
||||
/// </summary>
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入分类 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryCategoryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类明细项。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryDetailDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入页 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal MonthRevenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类集合。
|
||||
/// </summary>
|
||||
public List<FinanceCostEntryCategoryDto> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析统计卡 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本率(%)。
|
||||
/// </summary>
|
||||
public decimal FoodCostRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单均成本。
|
||||
/// </summary>
|
||||
public decimal AverageCostPerPaidOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化(%)。
|
||||
/// </summary>
|
||||
public decimal MonthOnMonthChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月支付成功订单数。
|
||||
/// </summary>
|
||||
public int PaidOrderCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本趋势点 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostTrendPointDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 月度总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本构成项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostCompositionItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类编码。
|
||||
/// </summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public string CategoryText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(%)。
|
||||
/// </summary>
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析明细表行 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostMonthlyDetailRowDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本。
|
||||
/// </summary>
|
||||
public decimal FoodAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人工成本。
|
||||
/// </summary>
|
||||
public decimal LaborAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定费用。
|
||||
/// </summary>
|
||||
public decimal FixedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 包装耗材。
|
||||
/// </summary>
|
||||
public decimal PackagingAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本率(%)。
|
||||
/// </summary>
|
||||
public decimal CostRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析页 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostAnalysisDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度时有值)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份(yyyy-MM)。
|
||||
/// </summary>
|
||||
public string Month { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 统计卡。
|
||||
/// </summary>
|
||||
public FinanceCostAnalysisStatsDto Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 趋势数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostTrendPointDto> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 构成数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostCompositionItemDto> Composition { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 明细表数据。
|
||||
/// </summary>
|
||||
public List<FinanceCostMonthlyDetailRowDto> DetailRows { get; set; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本模块映射与文案转换。
|
||||
/// </summary>
|
||||
internal static class FinanceCostMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度编码转枚举。
|
||||
/// </summary>
|
||||
public static FinanceCostDimension ParseDimensionCode(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"store" => FinanceCostDimension.Store,
|
||||
_ => FinanceCostDimension.Tenant
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 维度枚举转编码。
|
||||
/// </summary>
|
||||
public static string ToDimensionCode(FinanceCostDimension value)
|
||||
{
|
||||
return value == FinanceCostDimension.Store ? "store" : "tenant";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类编码转枚举。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类枚举转编码。
|
||||
/// </summary>
|
||||
public static string ToCategoryCode(FinanceCostCategory value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
FinanceCostCategory.FoodMaterial => "food",
|
||||
FinanceCostCategory.Labor => "labor",
|
||||
FinanceCostCategory.FixedExpense => "fixed",
|
||||
FinanceCostCategory.PackagingConsumable => "packaging",
|
||||
_ => "food"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类文案。
|
||||
/// </summary>
|
||||
public static string ToCategoryText(FinanceCostCategory value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
FinanceCostCategory.FoodMaterial => "食材原料",
|
||||
FinanceCostCategory.Labor => "人工成本",
|
||||
FinanceCostCategory.FixedExpense => "固定费用",
|
||||
FinanceCostCategory.PackagingConsumable => "包装耗材",
|
||||
_ => "食材原料"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化月份字符串(yyyy-MM)。
|
||||
/// </summary>
|
||||
public static string ToMonthText(DateTime month)
|
||||
{
|
||||
return month.ToString("yyyy-MM", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 归一化金额精度。
|
||||
/// </summary>
|
||||
public static decimal RoundAmount(decimal value)
|
||||
{
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建录入页 DTO。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建分析页 DTO。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 归一化为月份起始 UTC 时间。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceCostAnalysisQueryHandler(
|
||||
IFinanceCostRepository financeCostRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceCostAnalysisQuery, FinanceCostAnalysisDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceCostAnalysisDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceCostEntryQueryHandler(
|
||||
IFinanceCostRepository financeCostRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceCostEntryQuery, FinanceCostEntryDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceCostEntryDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入保存处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostEntryCommandHandler(
|
||||
IFinanceCostRepository financeCostRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveFinanceCostEntryCommand, FinanceCostEntryDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceCostEntryDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本分析页数据。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceCostAnalysisQuery : IRequest<FinanceCostAnalysisDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标月份(UTC 每月第一天)。
|
||||
/// </summary>
|
||||
public DateTime CostMonth { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 趋势月份数量。
|
||||
/// </summary>
|
||||
public int TrendMonthCount { get; init; } = 6;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询成本录入页数据。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceCostEntryQuery : IRequest<FinanceCostEntryDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(门店维度必填)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标月份(UTC 每月第一天)。
|
||||
/// </summary>
|
||||
public DateTime CostMonth { get; init; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceCostAnalysisQueryValidator : AbstractValidator<GetFinanceCostAnalysisQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceCostEntryQueryValidator : AbstractValidator<GetFinanceCostEntryQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入保存验证器。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostEntryCommandValidator : AbstractValidator<SaveFinanceCostEntryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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<SaveFinanceCostCategoryCommandItem> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类保存项验证器。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostCategoryCommandItemValidator : AbstractValidator<SaveFinanceCostCategoryCommandItem>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细保存项验证器。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceCostDetailCommandItemValidator : AbstractValidator<SaveFinanceCostDetailCommandItem>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入月度汇总实体(按维度 + 分类)。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntry : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public FinanceCostDimension Dimension { get; set; } = FinanceCostDimension.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(租户汇总维度为空)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本月份(统一存储为 UTC 每月第一天 00:00:00)。
|
||||
/// </summary>
|
||||
public DateTime CostMonth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public FinanceCostCategory Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入明细项实体。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostEntryItem : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 关联汇总行标识。
|
||||
/// </summary>
|
||||
public long EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public FinanceCostDimension Dimension { get; set; } = FinanceCostDimension.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(租户汇总维度为空)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本月份(统一存储为 UTC 每月第一天 00:00:00)。
|
||||
/// </summary>
|
||||
public DateTime CostMonth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public FinanceCostCategory Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public enum FinanceCostCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// 食材原料。
|
||||
/// </summary>
|
||||
FoodMaterial = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 人工成本。
|
||||
/// </summary>
|
||||
Labor = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 固定费用。
|
||||
/// </summary>
|
||||
FixedExpense = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 包装耗材。
|
||||
/// </summary>
|
||||
PackagingConsumable = 4
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 成本统计维度。
|
||||
/// </summary>
|
||||
public enum FinanceCostDimension
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户汇总维度。
|
||||
/// </summary>
|
||||
Tenant = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 门店维度。
|
||||
/// </summary>
|
||||
Store = 2
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细项快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceCostDetailItemSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细标识。
|
||||
/// </summary>
|
||||
public long? ItemId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public required string ItemName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 明细金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单价(人工类可用)。
|
||||
/// </summary>
|
||||
public decimal? UnitPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceCostCategorySnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public required FinanceCostCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类总金额。
|
||||
/// </summary>
|
||||
public decimal TotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceCostDetailItemSnapshot> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本录入页快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceCostMonthSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public required FinanceCostDimension Dimension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(租户维度为空)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本月份。
|
||||
/// </summary>
|
||||
public required DateTime CostMonth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月营业额。
|
||||
/// </summary>
|
||||
public decimal MonthRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceCostCategorySnapshot> Categories { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 月度趋势行。
|
||||
/// </summary>
|
||||
public sealed record FinanceCostTrendSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份起始时间(UTC)。
|
||||
/// </summary>
|
||||
public required DateTime MonthStartUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 月度成本明细表行。
|
||||
/// </summary>
|
||||
public sealed record FinanceCostMonthlyDetailSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 月份起始时间(UTC)。
|
||||
/// </summary>
|
||||
public required DateTime MonthStartUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 食材成本。
|
||||
/// </summary>
|
||||
public decimal FoodAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 人工成本。
|
||||
/// </summary>
|
||||
public decimal LaborAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定费用。
|
||||
/// </summary>
|
||||
public decimal FixedAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 包装耗材。
|
||||
/// </summary>
|
||||
public decimal PackagingAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度总成本。
|
||||
/// </summary>
|
||||
public decimal TotalCost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度营业额。
|
||||
/// </summary>
|
||||
public decimal Revenue { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 成本分析快照。
|
||||
/// </summary>
|
||||
public sealed record FinanceCostAnalysisSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计维度。
|
||||
/// </summary>
|
||||
public required FinanceCostDimension Dimension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识(租户维度为空)。
|
||||
/// </summary>
|
||||
public long? StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前月份。
|
||||
/// </summary>
|
||||
public required DateTime CostMonth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前月总成本。
|
||||
/// </summary>
|
||||
public decimal CurrentTotalCost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前月食材成本。
|
||||
/// </summary>
|
||||
public decimal CurrentFoodAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前月营业额。
|
||||
/// </summary>
|
||||
public decimal CurrentRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前月支付成功订单数。
|
||||
/// </summary>
|
||||
public int CurrentPaidOrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化率(%)。
|
||||
/// </summary>
|
||||
public decimal MonthOnMonthChangeRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类构成。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceCostCategorySnapshot> CurrentCategories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 近 N 月趋势。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceCostTrendSnapshot> Trends { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 明细表数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FinanceCostMonthlyDetailSnapshot> DetailRows { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 成本管理仓储契约。
|
||||
/// </summary>
|
||||
public interface IFinanceCostRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取成本录入页月度快照。
|
||||
/// </summary>
|
||||
Task<FinanceCostMonthSnapshot> GetMonthSnapshotAsync(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime costMonth,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存月度成本录入快照。
|
||||
/// </summary>
|
||||
Task SaveMonthSnapshotAsync(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime costMonth,
|
||||
IReadOnlyList<FinanceCostCategorySnapshot> categories,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取成本分析页快照。
|
||||
/// </summary>
|
||||
Task<FinanceCostAnalysisSnapshot> GetAnalysisSnapshotAsync(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime costMonth,
|
||||
int trendMonthCount,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
|
||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
|
||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
|
||||
@@ -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(
|
||||
/// </summary>
|
||||
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
|
||||
/// <summary>
|
||||
/// 成本录入汇总。
|
||||
/// </summary>
|
||||
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
|
||||
/// <summary>
|
||||
/// 成本录入明细。
|
||||
/// </summary>
|
||||
public DbSet<FinanceCostEntryItem> FinanceCostEntryItems => Set<FinanceCostEntryItem>();
|
||||
/// <summary>
|
||||
/// 配额包定义。
|
||||
/// </summary>
|
||||
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
|
||||
@@ -525,6 +534,8 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
|
||||
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
|
||||
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
|
||||
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
|
||||
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
|
||||
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
||||
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
|
||||
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
|
||||
@@ -1042,6 +1053,46 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => x.TenantId).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureFinanceCostEntry(EntityTypeBuilder<FinanceCostEntry> builder)
|
||||
{
|
||||
builder.ToTable("finance_cost_entries");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Dimension).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.StoreId);
|
||||
builder.Property(x => x.CostMonth).IsRequired();
|
||||
builder.Property(x => x.Category).HasConversion<int>().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<FinanceCostEntryItem> 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<int>().IsRequired();
|
||||
builder.Property(x => x.StoreId);
|
||||
builder.Property(x => x.CostMonth).IsRequired();
|
||||
builder.Property(x => x.Category).HasConversion<int>().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<FinanceCostEntry>()
|
||||
.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<TenantAnnouncement> builder)
|
||||
{
|
||||
builder.ToTable("tenant_announcements");
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 成本管理 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfFinanceCostRepository(TakeoutAppDbContext context) : IFinanceCostRepository
|
||||
{
|
||||
private static readonly FinanceCostCategory[] CategoryOrder =
|
||||
[
|
||||
FinanceCostCategory.FoodMaterial,
|
||||
FinanceCostCategory.Labor,
|
||||
FinanceCostCategory.FixedExpense,
|
||||
FinanceCostCategory.PackagingConsumable
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceCostMonthSnapshot> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveMonthSnapshotAsync(
|
||||
long tenantId,
|
||||
FinanceCostDimension dimension,
|
||||
long? storeId,
|
||||
DateTime costMonth,
|
||||
IReadOnlyList<FinanceCostCategorySnapshot> 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<FinanceCostEntryItem>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceCostAnalysisSnapshot> 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<FinanceCostTrendSnapshot>(trendMonths.Count);
|
||||
var detailRows = new List<FinanceCostMonthlyDetailSnapshot>(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<IReadOnlyList<FinanceCostCategorySnapshot>> 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<decimal> 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<int> 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<FinanceCostCategorySnapshot> NormalizeCategoriesForSave(
|
||||
IReadOnlyList<FinanceCostCategorySnapshot> 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<DateTime> BuildTrendMonths(DateTime currentMonth, int trendMonthCount)
|
||||
{
|
||||
var startMonth = currentMonth.AddMonths(0 - Math.Max(1, trendMonthCount) + 1);
|
||||
var result = new List<DateTime>(trendMonthCount);
|
||||
for (var index = 0; index < trendMonthCount; index++)
|
||||
{
|
||||
result.Add(startMonth.AddMonths(index));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 新增财务中心成本管理表结构。
|
||||
/// </summary>
|
||||
[DbContext(typeof(TakeoutAppDbContext))]
|
||||
[Migration("20260305010000_AddFinanceCostModule")]
|
||||
public sealed class AddFinanceCostModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_cost_entries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
|
||||
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份(UTC 每月第一天)。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
|
||||
TotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "分类总金额。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_cost_entries", x => x.Id);
|
||||
},
|
||||
comment: "财务成本录入月度汇总。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_cost_entry_items",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
EntryId = table.Column<long>(type: "bigint", nullable: false, comment: "关联汇总标识。"),
|
||||
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
|
||||
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份(UTC 每月第一天)。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
|
||||
ItemName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "明细名称。"),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "明细金额。"),
|
||||
Quantity = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "数量(人工类可用)。"),
|
||||
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "单价(人工类可用)。"),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_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" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_cost_entry_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_cost_entries");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
|
||||
|
||||
/// <summary>
|
||||
/// 写入成本管理菜单与权限定义。
|
||||
/// </summary>
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260305013000_SeedFinanceCostMenuAndPermissions")]
|
||||
public sealed class SeedFinanceCostMenuAndPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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 $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
DELETE FROM public.role_permissions
|
||||
WHERE "PermissionId" IN (
|
||||
SELECT "Id"
|
||||
FROM public.permissions
|
||||
WHERE "Code" 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 $$;
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user