feat(finance): add cost management backend module

This commit is contained in:
2026-03-04 16:07:16 +08:00
parent 39e28c1a62
commit fa6e376b86
24 changed files with 3001 additions and 0 deletions

View File

@@ -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);
}
}