feat(finance): add cost management backend module
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user