249 lines
8.7 KiB
C#
249 lines
8.7 KiB
C#
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);
|
||
}
|
||
}
|