Compare commits
11 Commits
c8359c5fc3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba8c0732b | |||
| f7eba55039 | |||
| fdbefca650 | |||
| 4a7d012a58 | |||
| d330db84fc | |||
| c79e9bd6e8 | |||
| 3f308c2d0c | |||
| 5dfaac01fd | |||
| 21a689edec | |||
| fa6e376b86 | |||
| b5aa060faf |
Submodule TakeoutSaaS.Docs updated: 6680599912...6daa444c5e
@@ -0,0 +1,285 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型(daily/weekly/monthly)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PeriodType { get; set; } = "daily";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表详情请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ReportId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表批量导出请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportBatchExportRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型(daily/weekly/monthly)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PeriodType { get; set; } = "daily";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表列表行响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ReportId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 日期文案。
|
||||||
|
/// </summary>
|
||||||
|
public string DateText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageOrderValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RefundRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ProfitRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可下载。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanDownload { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表列表响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportListItemResponse> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// KPI 响应项。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportKpiResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 指标键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标值文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ValueText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同比变化率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YoyChangeRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 环比变化率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MomChangeRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 明细行响应项。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportBreakdownItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 明细键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 明细名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RatioPercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ReportId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// KPI 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportKpiResponse> Kpis { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入明细(按渠道)。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportBreakdownItemResponse> IncomeBreakdowns { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本明细(按类别)。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportBreakdownItemResponse> CostBreakdowns { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表导出响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportExportResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名。
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base64 文件内容。
|
||||||
|
/// </summary>
|
||||||
|
public string FileContentBase64 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出总记录数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
@@ -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,533 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存发票设置请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceSettingSaveRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 企业名称。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户银行。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankAccount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子普通发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicNormalInvoice { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子专用发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动开票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableAutoIssue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动开票单张最大金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(pending/issued/voided)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型(normal/special)。
|
||||||
|
/// </summary>
|
||||||
|
public string? InvoiceType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词(发票号/公司名/申请人)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录详情请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票开票请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordIssueRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱(可选)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssueRemark { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票作废请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordVoidRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废原因。
|
||||||
|
/// </summary>
|
||||||
|
public string VoidReason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票申请请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordApplyRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TaxpayerNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型(normal/special)。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceType { get; set; } = "normal";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? ApplyRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(可空)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? AppliedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票设置响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceSettingResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 企业名称。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户银行。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankAccount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子普通发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicNormalInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子专用发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动开票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableAutoIssue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动开票单张最大金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AutoIssueMaxAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票统计响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本月已开票金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CurrentMonthIssuedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月已开票张数。
|
||||||
|
/// </summary>
|
||||||
|
public int CurrentMonthIssuedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待开票数量。
|
||||||
|
/// </summary>
|
||||||
|
public int PendingCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已作废数量。
|
||||||
|
/// </summary>
|
||||||
|
public int VoidedCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录列表项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(本地显示字符串)。
|
||||||
|
/// </summary>
|
||||||
|
public string AppliedAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TaxpayerNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? ApplyRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(本地显示字符串)。
|
||||||
|
/// </summary>
|
||||||
|
public string AppliedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票时间(本地显示字符串)。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssuedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssuedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssueRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废时间(本地显示字符串)。
|
||||||
|
/// </summary>
|
||||||
|
public string? VoidedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? VoidedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? VoidReason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票开票结果响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceIssueResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票时间(本地显示字符串)。
|
||||||
|
/// </summary>
|
||||||
|
public string IssuedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录分页响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceInvoiceRecordResponse> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总条数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceInvoiceStatsResponse Stats { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览查询请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewDashboardRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 维度(tenant/store)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Dimension { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(门店维度必填)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览指标卡响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewKpiCardResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 指标值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对比值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CompareAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 变化率(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ChangeRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 趋势(up/down/flat)。
|
||||||
|
/// </summary>
|
||||||
|
public string Trend { get; set; } = "flat";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对比文案。
|
||||||
|
/// </summary>
|
||||||
|
public string CompareLabel { get; set; } = "较昨日";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势点响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeTrendPointResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string Date { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轴标签(MM/dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string DateLabel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实收金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeTrendResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 近 7 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewIncomeTrendPointResponse> Last7Days { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewIncomeTrendPointResponse> Last30Days { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势点响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewProfitTrendPointResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string Date { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轴标签(MM/dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string DateLabel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewProfitTrendResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 近 7 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewProfitTrendPointResponse> Last7Days { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewProfitTrendPointResponse> Last30Days { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeCompositionItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percentage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeCompositionResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总实收。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构成项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewIncomeCompositionItemResponse> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本构成项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewCostCompositionItemResponse
|
||||||
|
{
|
||||||
|
/// <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 FinanceOverviewCostCompositionResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总成本。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构成项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewCostCompositionItemResponse> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewTopProductItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 排名。
|
||||||
|
/// </summary>
|
||||||
|
public int Rank { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品名称。
|
||||||
|
/// </summary>
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 销量。
|
||||||
|
/// </summary>
|
||||||
|
public int SalesQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percentage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewTopProductResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 周期天数。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排行项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewTopProductItemResponse> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewDashboardResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 维度编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Dimension { get; set; } = "tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 今日营业额卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardResponse TodayRevenue { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实收卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardResponse ActualReceived { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardResponse RefundAmount { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净收入卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardResponse NetIncome { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可提现余额卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardResponse WithdrawableBalance { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewIncomeTrendResponse IncomeTrend { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewProfitTrendResponse ProfitTrend { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewIncomeCompositionResponse IncomeComposition { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本构成。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewCostCompositionResponse CostComposition { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品排行。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewTopProductResponse TopProducts { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -79,6 +79,10 @@ public sealed class StoreFeesSettingsDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal BaseDeliveryFee { get; set; }
|
public decimal BaseDeliveryFee { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// PlatformServiceRate。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PlatformServiceRate { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// FreeDeliveryThreshold。
|
/// FreeDeliveryThreshold。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? FreeDeliveryThreshold { get; set; }
|
public decimal? FreeDeliveryThreshold { 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,308 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
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/invoice")]
|
||||||
|
public sealed class FinanceInvoiceController(IMediator mediator) : BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:finance:invoice:view";
|
||||||
|
private const string IssuePermission = "tenant:finance:invoice:issue";
|
||||||
|
private const string VoidPermission = "tenant:finance:invoice:void";
|
||||||
|
private const string SettingsPermission = "tenant:finance:invoice:settings";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询发票设置详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("settings/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, SettingsPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsDetail(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetFinanceInvoiceSettingDetailQuery(), cancellationToken);
|
||||||
|
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存发票设置。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("settings/save")]
|
||||||
|
[PermissionAuthorize(SettingsPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsSave(
|
||||||
|
[FromBody] FinanceInvoiceSettingSaveRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new SaveFinanceInvoiceSettingCommand
|
||||||
|
{
|
||||||
|
CompanyName = request.CompanyName,
|
||||||
|
TaxpayerNumber = request.TaxpayerNumber,
|
||||||
|
RegisteredAddress = request.RegisteredAddress,
|
||||||
|
RegisteredPhone = request.RegisteredPhone,
|
||||||
|
BankName = request.BankName,
|
||||||
|
BankAccount = request.BankAccount,
|
||||||
|
EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
|
||||||
|
EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
|
||||||
|
EnableAutoIssue = request.EnableAutoIssue,
|
||||||
|
AutoIssueMaxAmount = request.AutoIssueMaxAmount
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询发票记录分页。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("record/list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceRecordListResultResponse>> RecordList(
|
||||||
|
[FromQuery] FinanceInvoiceRecordListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetFinanceInvoiceRecordListQuery
|
||||||
|
{
|
||||||
|
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||||
|
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||||
|
Status = ParseStatusOrNull(request.Status),
|
||||||
|
InvoiceType = ParseInvoiceTypeOrNull(request.InvoiceType),
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Page = request.Page,
|
||||||
|
PageSize = request.PageSize
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceInvoiceRecordListResultResponse>.Ok(new FinanceInvoiceRecordListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapRecord).ToList(),
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize,
|
||||||
|
TotalCount = result.TotalCount,
|
||||||
|
Stats = new FinanceInvoiceStatsResponse
|
||||||
|
{
|
||||||
|
CurrentMonthIssuedAmount = result.Stats.CurrentMonthIssuedAmount,
|
||||||
|
CurrentMonthIssuedCount = result.Stats.CurrentMonthIssuedCount,
|
||||||
|
PendingCount = result.Stats.PendingCount,
|
||||||
|
VoidedCount = result.Stats.VoidedCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询发票记录详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("record/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordDetail(
|
||||||
|
[FromQuery] FinanceInvoiceRecordDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetFinanceInvoiceRecordDetailQuery
|
||||||
|
{
|
||||||
|
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票开票。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("record/issue")]
|
||||||
|
[PermissionAuthorize(IssuePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceIssueResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceIssueResultResponse>> RecordIssue(
|
||||||
|
[FromBody] FinanceInvoiceRecordIssueRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new IssueFinanceInvoiceRecordCommand
|
||||||
|
{
|
||||||
|
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||||
|
ContactEmail = request.ContactEmail,
|
||||||
|
IssueRemark = request.IssueRemark
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceInvoiceIssueResultResponse>.Ok(MapIssueResult(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废发票。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("record/void")]
|
||||||
|
[PermissionAuthorize(VoidPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordVoid(
|
||||||
|
[FromBody] FinanceInvoiceRecordVoidRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new VoidFinanceInvoiceRecordCommand
|
||||||
|
{
|
||||||
|
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||||
|
VoidReason = request.VoidReason
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请发票。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("record/apply")]
|
||||||
|
[PermissionAuthorize(ViewPermission, IssuePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordApply(
|
||||||
|
[FromBody] FinanceInvoiceRecordApplyRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new ApplyFinanceInvoiceRecordCommand
|
||||||
|
{
|
||||||
|
ApplicantName = request.ApplicantName,
|
||||||
|
CompanyName = request.CompanyName,
|
||||||
|
TaxpayerNumber = request.TaxpayerNumber,
|
||||||
|
InvoiceType = request.InvoiceType,
|
||||||
|
Amount = request.Amount,
|
||||||
|
OrderNo = request.OrderNo,
|
||||||
|
ContactEmail = request.ContactEmail,
|
||||||
|
ContactPhone = request.ContactPhone,
|
||||||
|
ApplyRemark = request.ApplyRemark,
|
||||||
|
AppliedAt = request.AppliedAt
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TenantInvoiceStatus? ParseStatusOrNull(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"pending" => TenantInvoiceStatus.Pending,
|
||||||
|
"issued" => TenantInvoiceStatus.Issued,
|
||||||
|
"voided" => TenantInvoiceStatus.Voided,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TenantInvoiceType? ParseInvoiceTypeOrNull(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"normal" => TenantInvoiceType.Normal,
|
||||||
|
"special" => TenantInvoiceType.Special,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceInvoiceSettingResponse MapSetting(FinanceInvoiceSettingDto source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceSettingResponse
|
||||||
|
{
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
TaxpayerNumber = source.TaxpayerNumber,
|
||||||
|
RegisteredAddress = source.RegisteredAddress,
|
||||||
|
RegisteredPhone = source.RegisteredPhone,
|
||||||
|
BankName = source.BankName,
|
||||||
|
BankAccount = source.BankAccount,
|
||||||
|
EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
|
||||||
|
EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
|
||||||
|
EnableAutoIssue = source.EnableAutoIssue,
|
||||||
|
AutoIssueMaxAmount = source.AutoIssueMaxAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceInvoiceRecordResponse MapRecord(FinanceInvoiceRecordDto source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceRecordResponse
|
||||||
|
{
|
||||||
|
RecordId = source.RecordId.ToString(),
|
||||||
|
InvoiceNo = source.InvoiceNo,
|
||||||
|
ApplicantName = source.ApplicantName,
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
InvoiceType = source.InvoiceType,
|
||||||
|
InvoiceTypeText = source.InvoiceTypeText,
|
||||||
|
Amount = source.Amount,
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = source.StatusText,
|
||||||
|
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceInvoiceRecordDetailResponse MapRecordDetail(FinanceInvoiceRecordDetailDto source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceRecordDetailResponse
|
||||||
|
{
|
||||||
|
RecordId = source.RecordId.ToString(),
|
||||||
|
InvoiceNo = source.InvoiceNo,
|
||||||
|
ApplicantName = source.ApplicantName,
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
TaxpayerNumber = source.TaxpayerNumber,
|
||||||
|
InvoiceType = source.InvoiceType,
|
||||||
|
InvoiceTypeText = source.InvoiceTypeText,
|
||||||
|
Amount = source.Amount,
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
ContactEmail = source.ContactEmail,
|
||||||
|
ContactPhone = source.ContactPhone,
|
||||||
|
ApplyRemark = source.ApplyRemark,
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = source.StatusText,
|
||||||
|
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
IssuedByUserId = source.IssuedByUserId?.ToString(),
|
||||||
|
IssueRemark = source.IssueRemark,
|
||||||
|
VoidedAt = source.VoidedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
VoidedByUserId = source.VoidedByUserId?.ToString(),
|
||||||
|
VoidReason = source.VoidReason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceInvoiceIssueResultResponse MapIssueResult(FinanceInvoiceIssueResultDto source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceIssueResultResponse
|
||||||
|
{
|
||||||
|
RecordId = source.RecordId.ToString(),
|
||||||
|
InvoiceNo = source.InvoiceNo,
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
Amount = source.Amount,
|
||||||
|
ContactEmail = source.ContactEmail,
|
||||||
|
IssuedAt = source.IssuedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = source.StatusText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.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/overview")]
|
||||||
|
public sealed class FinanceOverviewController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService) : BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:finance:overview:view";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询财务概览驾驶舱数据。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("dashboard")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceOverviewDashboardResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceOverviewDashboardResponse>> Dashboard(
|
||||||
|
[FromQuery] FinanceOverviewDashboardRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析维度与作用域。
|
||||||
|
var dimension = ParseDimension(request.Dimension);
|
||||||
|
long? storeId = null;
|
||||||
|
if (dimension == FinanceCostDimension.Store)
|
||||||
|
{
|
||||||
|
storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId.Value, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询概览数据。
|
||||||
|
var dashboard = await mediator.Send(new GetFinanceOverviewDashboardQuery
|
||||||
|
{
|
||||||
|
Dimension = dimension,
|
||||||
|
StoreId = storeId,
|
||||||
|
CurrentUtc = DateTime.UtcNow
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
// 3. 映射响应并返回。
|
||||||
|
return ApiResponse<FinanceOverviewDashboardResponse>.Ok(MapDashboard(dashboard));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"" or "tenant" => FinanceCostDimension.Tenant,
|
||||||
|
"store" => FinanceCostDimension.Store,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceOverviewDashboardResponse MapDashboard(FinanceOverviewDashboardDto source)
|
||||||
|
{
|
||||||
|
return new FinanceOverviewDashboardResponse
|
||||||
|
{
|
||||||
|
Dimension = source.Dimension,
|
||||||
|
StoreId = source.StoreId,
|
||||||
|
TodayRevenue = MapKpi(source.TodayRevenue),
|
||||||
|
ActualReceived = MapKpi(source.ActualReceived),
|
||||||
|
RefundAmount = MapKpi(source.RefundAmount),
|
||||||
|
NetIncome = MapKpi(source.NetIncome),
|
||||||
|
WithdrawableBalance = MapKpi(source.WithdrawableBalance),
|
||||||
|
IncomeTrend = new FinanceOverviewIncomeTrendResponse
|
||||||
|
{
|
||||||
|
Last7Days = source.IncomeTrend.Last7Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
|
||||||
|
{
|
||||||
|
Date = item.Date,
|
||||||
|
DateLabel = item.DateLabel,
|
||||||
|
Amount = item.Amount
|
||||||
|
}).ToList(),
|
||||||
|
Last30Days = source.IncomeTrend.Last30Days.Select(item => new FinanceOverviewIncomeTrendPointResponse
|
||||||
|
{
|
||||||
|
Date = item.Date,
|
||||||
|
DateLabel = item.DateLabel,
|
||||||
|
Amount = item.Amount
|
||||||
|
}).ToList()
|
||||||
|
},
|
||||||
|
ProfitTrend = new FinanceOverviewProfitTrendResponse
|
||||||
|
{
|
||||||
|
Last7Days = source.ProfitTrend.Last7Days.Select(item => new FinanceOverviewProfitTrendPointResponse
|
||||||
|
{
|
||||||
|
Date = item.Date,
|
||||||
|
DateLabel = item.DateLabel,
|
||||||
|
RevenueAmount = item.RevenueAmount,
|
||||||
|
CostAmount = item.CostAmount,
|
||||||
|
NetProfitAmount = item.NetProfitAmount
|
||||||
|
}).ToList(),
|
||||||
|
Last30Days = source.ProfitTrend.Last30Days.Select(item => new FinanceOverviewProfitTrendPointResponse
|
||||||
|
{
|
||||||
|
Date = item.Date,
|
||||||
|
DateLabel = item.DateLabel,
|
||||||
|
RevenueAmount = item.RevenueAmount,
|
||||||
|
CostAmount = item.CostAmount,
|
||||||
|
NetProfitAmount = item.NetProfitAmount
|
||||||
|
}).ToList()
|
||||||
|
},
|
||||||
|
IncomeComposition = new FinanceOverviewIncomeCompositionResponse
|
||||||
|
{
|
||||||
|
TotalAmount = source.IncomeComposition.TotalAmount,
|
||||||
|
Items = source.IncomeComposition.Items.Select(item => new FinanceOverviewIncomeCompositionItemResponse
|
||||||
|
{
|
||||||
|
Channel = item.Channel,
|
||||||
|
ChannelText = item.ChannelText,
|
||||||
|
Amount = item.Amount,
|
||||||
|
Percentage = item.Percentage
|
||||||
|
}).ToList()
|
||||||
|
},
|
||||||
|
CostComposition = new FinanceOverviewCostCompositionResponse
|
||||||
|
{
|
||||||
|
TotalAmount = source.CostComposition.TotalAmount,
|
||||||
|
Items = source.CostComposition.Items.Select(item => new FinanceOverviewCostCompositionItemResponse
|
||||||
|
{
|
||||||
|
Category = item.Category,
|
||||||
|
CategoryText = item.CategoryText,
|
||||||
|
Amount = item.Amount,
|
||||||
|
Percentage = item.Percentage
|
||||||
|
}).ToList()
|
||||||
|
},
|
||||||
|
TopProducts = new FinanceOverviewTopProductResponse
|
||||||
|
{
|
||||||
|
PeriodDays = source.TopProducts.PeriodDays,
|
||||||
|
Items = source.TopProducts.Items.Select(item => new FinanceOverviewTopProductItemResponse
|
||||||
|
{
|
||||||
|
Rank = item.Rank,
|
||||||
|
ProductName = item.ProductName,
|
||||||
|
SalesQuantity = item.SalesQuantity,
|
||||||
|
RevenueAmount = item.RevenueAmount,
|
||||||
|
Percentage = item.Percentage
|
||||||
|
}).ToList()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceOverviewKpiCardResponse MapKpi(FinanceOverviewKpiCardDto source)
|
||||||
|
{
|
||||||
|
return new FinanceOverviewKpiCardResponse
|
||||||
|
{
|
||||||
|
Amount = source.Amount,
|
||||||
|
CompareAmount = source.CompareAmount,
|
||||||
|
ChangeRate = source.ChangeRate,
|
||||||
|
Trend = source.Trend,
|
||||||
|
CompareLabel = source.CompareLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.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/report")]
|
||||||
|
public sealed class FinanceReportController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService) : BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:statistics:report:view";
|
||||||
|
private const string ExportPermission = "tenant:statistics:report:export";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询经营报表列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceBusinessReportListResultResponse>> List(
|
||||||
|
[FromQuery] FinanceBusinessReportListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验门店访问权限并解析查询参数。
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
var periodType = ParsePeriodType(request.PeriodType);
|
||||||
|
|
||||||
|
// 2. 发起查询并返回结果。
|
||||||
|
var result = await mediator.Send(new SearchFinanceBusinessReportListQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PeriodType = periodType,
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceBusinessReportListResultResponse>.Ok(new FinanceBusinessReportListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapListItem).ToList(),
|
||||||
|
Total = result.Total,
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询经营报表详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceBusinessReportDetailResponse>> Detail(
|
||||||
|
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验门店访问权限并解析参数。
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||||
|
|
||||||
|
// 2. 发起详情查询。
|
||||||
|
var detail = await mediator.Send(new GetFinanceBusinessReportDetailQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
ReportId = reportId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (detail is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<FinanceBusinessReportDetailResponse>.Error(ErrorCodes.NotFound, "经营报表不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<FinanceBusinessReportDetailResponse>.Ok(MapDetail(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出单条报表 PDF。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export/pdf")]
|
||||||
|
[PermissionAuthorize(ExportPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportPdf(
|
||||||
|
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验门店访问权限并解析参数。
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||||
|
|
||||||
|
// 2. 执行导出。
|
||||||
|
var export = await mediator.Send(new ExportFinanceBusinessReportPdfQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
ReportId = reportId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出单条报表 Excel。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export/excel")]
|
||||||
|
[PermissionAuthorize(ExportPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportExcel(
|
||||||
|
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验门店访问权限并解析参数。
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||||
|
|
||||||
|
// 2. 执行导出。
|
||||||
|
var export = await mediator.Send(new ExportFinanceBusinessReportExcelQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
ReportId = reportId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量导出报表 ZIP(PDF + Excel)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export/batch")]
|
||||||
|
[PermissionAuthorize(ExportPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportBatch(
|
||||||
|
[FromQuery] FinanceBusinessReportBatchExportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验门店访问权限并解析参数。
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
var periodType = ParsePeriodType(request.PeriodType);
|
||||||
|
|
||||||
|
// 2. 执行批量导出。
|
||||||
|
var export = await mediator.Send(new ExportFinanceBusinessReportBatchQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PeriodType = periodType,
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportPeriodType ParsePeriodType(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"" or "daily" => FinanceBusinessReportPeriodType.Daily,
|
||||||
|
"weekly" => FinanceBusinessReportPeriodType.Weekly,
|
||||||
|
"monthly" => FinanceBusinessReportPeriodType.Monthly,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "periodType 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportListItemResponse MapListItem(FinanceBusinessReportListItemDto source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportListItemResponse
|
||||||
|
{
|
||||||
|
ReportId = source.ReportId,
|
||||||
|
DateText = source.DateText,
|
||||||
|
RevenueAmount = source.RevenueAmount,
|
||||||
|
OrderCount = source.OrderCount,
|
||||||
|
AverageOrderValue = source.AverageOrderValue,
|
||||||
|
RefundRatePercent = source.RefundRatePercent,
|
||||||
|
CostTotalAmount = source.CostTotalAmount,
|
||||||
|
NetProfitAmount = source.NetProfitAmount,
|
||||||
|
ProfitRatePercent = source.ProfitRatePercent,
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = source.StatusText,
|
||||||
|
CanDownload = source.CanDownload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportDetailResponse MapDetail(FinanceBusinessReportDetailDto source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportDetailResponse
|
||||||
|
{
|
||||||
|
ReportId = source.ReportId,
|
||||||
|
Title = source.Title,
|
||||||
|
PeriodType = source.PeriodType,
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = source.StatusText,
|
||||||
|
Kpis = source.Kpis.Select(item => new FinanceBusinessReportKpiResponse
|
||||||
|
{
|
||||||
|
Key = item.Key,
|
||||||
|
Label = item.Label,
|
||||||
|
ValueText = item.ValueText,
|
||||||
|
YoyChangeRate = item.YoyChangeRate,
|
||||||
|
MomChangeRate = item.MomChangeRate
|
||||||
|
}).ToList(),
|
||||||
|
IncomeBreakdowns = source.IncomeBreakdowns.Select(MapBreakdown).ToList(),
|
||||||
|
CostBreakdowns = source.CostBreakdowns.Select(MapBreakdown).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportBreakdownItemResponse MapBreakdown(FinanceBusinessReportBreakdownItemDto source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportBreakdownItemResponse
|
||||||
|
{
|
||||||
|
Key = source.Key,
|
||||||
|
Label = source.Label,
|
||||||
|
Amount = source.Amount,
|
||||||
|
RatioPercent = source.RatioPercent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportExportResponse MapExport(FinanceBusinessReportExportDto source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportExportResponse
|
||||||
|
{
|
||||||
|
FileName = source.FileName,
|
||||||
|
FileContentBase64 = source.FileContentBase64,
|
||||||
|
TotalCount = source.TotalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ public sealed class StoreFeesController(
|
|||||||
StoreId = parsedStoreId,
|
StoreId = parsedStoreId,
|
||||||
MinimumOrderAmount = request.MinimumOrderAmount,
|
MinimumOrderAmount = request.MinimumOrderAmount,
|
||||||
DeliveryFee = request.BaseDeliveryFee,
|
DeliveryFee = request.BaseDeliveryFee,
|
||||||
|
PlatformServiceRate = request.PlatformServiceRate,
|
||||||
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
|
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
|
||||||
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
|
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
|
||||||
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
|
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
|
||||||
@@ -175,6 +176,7 @@ public sealed class StoreFeesController(
|
|||||||
|
|
||||||
targetFee.MinimumOrderAmount = sourceFee.MinimumOrderAmount;
|
targetFee.MinimumOrderAmount = sourceFee.MinimumOrderAmount;
|
||||||
targetFee.BaseDeliveryFee = sourceFee.BaseDeliveryFee;
|
targetFee.BaseDeliveryFee = sourceFee.BaseDeliveryFee;
|
||||||
|
targetFee.PlatformServiceRate = sourceFee.PlatformServiceRate;
|
||||||
targetFee.FreeDeliveryThreshold = sourceFee.FreeDeliveryThreshold;
|
targetFee.FreeDeliveryThreshold = sourceFee.FreeDeliveryThreshold;
|
||||||
targetFee.PackagingFeeMode = sourceFee.PackagingFeeMode;
|
targetFee.PackagingFeeMode = sourceFee.PackagingFeeMode;
|
||||||
targetFee.OrderPackagingFeeMode = sourceFee.OrderPackagingFeeMode;
|
targetFee.OrderPackagingFeeMode = sourceFee.OrderPackagingFeeMode;
|
||||||
@@ -214,6 +216,7 @@ public sealed class StoreFeesController(
|
|||||||
IsConfigured = source is not null,
|
IsConfigured = source is not null,
|
||||||
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
|
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
|
||||||
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
|
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
|
||||||
|
PlatformServiceRate = source?.PlatformServiceRate ?? 0m,
|
||||||
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
|
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
|
||||||
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
|
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
|
||||||
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),
|
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),
|
||||||
|
|||||||
@@ -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,60 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请发票记录命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApplyFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceRecordDetailDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TaxpayerNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型(normal/special)。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceType { get; init; } = "normal";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactPhone { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? ApplyRemark { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(可空,默认当前 UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? AppliedAt { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IssueFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceIssueResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long RecordId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱(可选,传入会覆盖原值)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssueRemark { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存发票设置命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveFinanceInvoiceSettingCommand : IRequest<FinanceInvoiceSettingDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 企业名称。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string TaxpayerNumber { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredAddress { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredPhone { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户银行。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankAccount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子普通发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicNormalInvoice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子专用发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicSpecialInvoice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动开票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableAutoIssue { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动开票单张最大金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AutoIssueMaxAmount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废发票命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoidFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceRecordDetailDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long RecordId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废原因。
|
||||||
|
/// </summary>
|
||||||
|
public string VoidReason { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票开票结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceIssueResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long RecordId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime IssuedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long RecordId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string? TaxpayerNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? ApplyRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime AppliedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? IssuedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long? IssuedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssueRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? VoidedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long? VoidedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? VoidReason { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录列表项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long RecordId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime AppliedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录分页结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceRecordListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceInvoiceRecordDto> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总条数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceInvoiceStatsDto Stats { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票设置 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceSettingDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 企业名称。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户银行。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankAccount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子普通发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicNormalInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子专用发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动开票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableAutoIssue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动开票单张最大金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AutoIssueMaxAmount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票统计 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceInvoiceStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本月已开票金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CurrentMonthIssuedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月已开票张数。
|
||||||
|
/// </summary>
|
||||||
|
public int CurrentMonthIssuedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待开票数量。
|
||||||
|
/// </summary>
|
||||||
|
public int PendingCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已作废数量。
|
||||||
|
/// </summary>
|
||||||
|
public int VoidedCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票模块 DTO 构造器。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FinanceInvoiceDtoFactory
|
||||||
|
{
|
||||||
|
public static FinanceInvoiceSettingDto CreateDefaultSettingDto()
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceSettingDto
|
||||||
|
{
|
||||||
|
CompanyName = string.Empty,
|
||||||
|
TaxpayerNumber = string.Empty,
|
||||||
|
RegisteredAddress = null,
|
||||||
|
RegisteredPhone = null,
|
||||||
|
BankName = null,
|
||||||
|
BankAccount = null,
|
||||||
|
EnableElectronicNormalInvoice = true,
|
||||||
|
EnableElectronicSpecialInvoice = false,
|
||||||
|
EnableAutoIssue = false,
|
||||||
|
AutoIssueMaxAmount = 10_000m
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FinanceInvoiceSettingDto ToSettingDto(TenantInvoiceSetting source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceSettingDto
|
||||||
|
{
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
TaxpayerNumber = source.TaxpayerNumber,
|
||||||
|
RegisteredAddress = source.RegisteredAddress,
|
||||||
|
RegisteredPhone = source.RegisteredPhone,
|
||||||
|
BankName = source.BankName,
|
||||||
|
BankAccount = source.BankAccount,
|
||||||
|
EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
|
||||||
|
EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
|
||||||
|
EnableAutoIssue = source.EnableAutoIssue,
|
||||||
|
AutoIssueMaxAmount = decimal.Round(source.AutoIssueMaxAmount, 2, MidpointRounding.AwayFromZero)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TenantInvoiceSetting CreateSettingEntity(
|
||||||
|
SaveFinanceInvoiceSettingCommand request,
|
||||||
|
string companyName,
|
||||||
|
string taxpayerNumber,
|
||||||
|
string? registeredAddress,
|
||||||
|
string? registeredPhone,
|
||||||
|
string? bankName,
|
||||||
|
string? bankAccount,
|
||||||
|
decimal autoIssueMaxAmount)
|
||||||
|
{
|
||||||
|
return new TenantInvoiceSetting
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
TaxpayerNumber = taxpayerNumber,
|
||||||
|
RegisteredAddress = registeredAddress,
|
||||||
|
RegisteredPhone = registeredPhone,
|
||||||
|
BankName = bankName,
|
||||||
|
BankAccount = bankAccount,
|
||||||
|
EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
|
||||||
|
EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
|
||||||
|
EnableAutoIssue = request.EnableAutoIssue,
|
||||||
|
AutoIssueMaxAmount = autoIssueMaxAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplySettingChanges(
|
||||||
|
TenantInvoiceSetting entity,
|
||||||
|
SaveFinanceInvoiceSettingCommand request,
|
||||||
|
string companyName,
|
||||||
|
string taxpayerNumber,
|
||||||
|
string? registeredAddress,
|
||||||
|
string? registeredPhone,
|
||||||
|
string? bankName,
|
||||||
|
string? bankAccount,
|
||||||
|
decimal autoIssueMaxAmount)
|
||||||
|
{
|
||||||
|
entity.CompanyName = companyName;
|
||||||
|
entity.TaxpayerNumber = taxpayerNumber;
|
||||||
|
entity.RegisteredAddress = registeredAddress;
|
||||||
|
entity.RegisteredPhone = registeredPhone;
|
||||||
|
entity.BankName = bankName;
|
||||||
|
entity.BankAccount = bankAccount;
|
||||||
|
entity.EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice;
|
||||||
|
entity.EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice;
|
||||||
|
entity.EnableAutoIssue = request.EnableAutoIssue;
|
||||||
|
entity.AutoIssueMaxAmount = autoIssueMaxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FinanceInvoiceStatsDto ToStatsDto(TenantInvoiceRecordStatsSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceStatsDto
|
||||||
|
{
|
||||||
|
CurrentMonthIssuedAmount = decimal.Round(source.CurrentMonthIssuedAmount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
CurrentMonthIssuedCount = source.CurrentMonthIssuedCount,
|
||||||
|
PendingCount = source.PendingCount,
|
||||||
|
VoidedCount = source.VoidedCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FinanceInvoiceRecordDto ToRecordDto(TenantInvoiceRecord source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceRecordDto
|
||||||
|
{
|
||||||
|
RecordId = source.Id,
|
||||||
|
InvoiceNo = source.InvoiceNo,
|
||||||
|
ApplicantName = source.ApplicantName,
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
InvoiceType = FinanceInvoiceMapping.ToInvoiceTypeText(source.InvoiceType),
|
||||||
|
InvoiceTypeText = FinanceInvoiceMapping.ToInvoiceTypeDisplayText(source.InvoiceType),
|
||||||
|
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Status = FinanceInvoiceMapping.ToStatusText(source.Status),
|
||||||
|
StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status),
|
||||||
|
AppliedAt = source.AppliedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FinanceInvoiceRecordDetailDto ToRecordDetailDto(TenantInvoiceRecord source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceRecordDetailDto
|
||||||
|
{
|
||||||
|
RecordId = source.Id,
|
||||||
|
InvoiceNo = source.InvoiceNo,
|
||||||
|
ApplicantName = source.ApplicantName,
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
TaxpayerNumber = source.TaxpayerNumber,
|
||||||
|
InvoiceType = FinanceInvoiceMapping.ToInvoiceTypeText(source.InvoiceType),
|
||||||
|
InvoiceTypeText = FinanceInvoiceMapping.ToInvoiceTypeDisplayText(source.InvoiceType),
|
||||||
|
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
ContactEmail = source.ContactEmail,
|
||||||
|
ContactPhone = source.ContactPhone,
|
||||||
|
ApplyRemark = source.ApplyRemark,
|
||||||
|
Status = FinanceInvoiceMapping.ToStatusText(source.Status),
|
||||||
|
StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status),
|
||||||
|
AppliedAt = source.AppliedAt,
|
||||||
|
IssuedAt = source.IssuedAt,
|
||||||
|
IssuedByUserId = source.IssuedByUserId,
|
||||||
|
IssueRemark = source.IssueRemark,
|
||||||
|
VoidedAt = source.VoidedAt,
|
||||||
|
VoidedByUserId = source.VoidedByUserId,
|
||||||
|
VoidReason = source.VoidReason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FinanceInvoiceIssueResultDto ToIssueResultDto(TenantInvoiceRecord source)
|
||||||
|
{
|
||||||
|
return new FinanceInvoiceIssueResultDto
|
||||||
|
{
|
||||||
|
RecordId = source.Id,
|
||||||
|
InvoiceNo = source.InvoiceNo,
|
||||||
|
CompanyName = source.CompanyName,
|
||||||
|
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
ContactEmail = source.ContactEmail,
|
||||||
|
IssuedAt = source.IssuedAt ?? DateTime.UtcNow,
|
||||||
|
Status = FinanceInvoiceMapping.ToStatusText(source.Status),
|
||||||
|
StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TenantInvoiceRecord CreateRecordEntity(
|
||||||
|
long tenantId,
|
||||||
|
string invoiceNo,
|
||||||
|
string applicantName,
|
||||||
|
string companyName,
|
||||||
|
string? taxpayerNumber,
|
||||||
|
TenantInvoiceType invoiceType,
|
||||||
|
decimal amount,
|
||||||
|
string orderNo,
|
||||||
|
string? contactEmail,
|
||||||
|
string? contactPhone,
|
||||||
|
string? applyRemark,
|
||||||
|
DateTime appliedAt)
|
||||||
|
{
|
||||||
|
return new TenantInvoiceRecord
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
InvoiceNo = invoiceNo,
|
||||||
|
ApplicantName = applicantName,
|
||||||
|
CompanyName = companyName,
|
||||||
|
TaxpayerNumber = taxpayerNumber,
|
||||||
|
InvoiceType = invoiceType,
|
||||||
|
Amount = amount,
|
||||||
|
OrderNo = orderNo,
|
||||||
|
ContactEmail = contactEmail,
|
||||||
|
ContactPhone = contactPhone,
|
||||||
|
ApplyRemark = applyRemark,
|
||||||
|
Status = TenantInvoiceStatus.Pending,
|
||||||
|
AppliedAt = appliedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.Net.Mail;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票模块映射与参数标准化。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FinanceInvoiceMapping
|
||||||
|
{
|
||||||
|
public static TenantInvoiceType ParseInvoiceTypeRequired(string? value)
|
||||||
|
{
|
||||||
|
return ParseInvoiceTypeOptional(value)
|
||||||
|
?? throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TenantInvoiceType? ParseInvoiceTypeOptional(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"normal" => TenantInvoiceType.Normal,
|
||||||
|
"special" => TenantInvoiceType.Special,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TenantInvoiceStatus? ParseStatusOptional(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"pending" => TenantInvoiceStatus.Pending,
|
||||||
|
"issued" => TenantInvoiceStatus.Issued,
|
||||||
|
"voided" => TenantInvoiceStatus.Voided,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToInvoiceTypeText(TenantInvoiceType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
TenantInvoiceType.Normal => "normal",
|
||||||
|
TenantInvoiceType.Special => "special",
|
||||||
|
_ => "normal"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToInvoiceTypeDisplayText(TenantInvoiceType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
TenantInvoiceType.Normal => "普票",
|
||||||
|
TenantInvoiceType.Special => "专票",
|
||||||
|
_ => "普票"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToStatusText(TenantInvoiceStatus value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
TenantInvoiceStatus.Pending => "pending",
|
||||||
|
TenantInvoiceStatus.Issued => "issued",
|
||||||
|
TenantInvoiceStatus.Voided => "voided",
|
||||||
|
_ => "pending"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToStatusDisplayText(TenantInvoiceStatus value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
TenantInvoiceStatus.Pending => "待开票",
|
||||||
|
TenantInvoiceStatus.Issued => "已开票",
|
||||||
|
TenantInvoiceStatus.Voided => "已作废",
|
||||||
|
_ => "待开票"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeCompanyName(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeRequiredText(value, "companyName", 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeApplicantName(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeRequiredText(value, "applicantName", 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeOrderNo(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeRequiredText(value, "orderNo", 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeTaxpayerNumber(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeRequiredText(value, "taxpayerNumber", 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeOptionalTaxpayerNumber(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeOptionalText(value, "taxpayerNumber", 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeOptionalKeyword(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeOptionalText(value, "keyword", 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeOptionalEmail(string? value)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptionalText(value, "contactEmail", 128);
|
||||||
|
if (normalized is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new MailAddress(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "contactEmail 参数不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeOptionalPhone(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeOptionalText(value, "contactPhone", 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeOptionalRemark(string? value, string fieldName, int maxLength = 256)
|
||||||
|
{
|
||||||
|
return NormalizeOptionalText(value, fieldName, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeVoidReason(string? value)
|
||||||
|
{
|
||||||
|
return NormalizeRequiredText(value, "voidReason", 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal NormalizeAmount(decimal value)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "amount 参数不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal NormalizeAutoIssueMaxAmount(decimal value)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "autoIssueMaxAmount 参数不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (DateTime? StartUtc, DateTime? EndUtc) NormalizeDateRange(DateTime? startUtc, DateTime? endUtc)
|
||||||
|
{
|
||||||
|
DateTime? normalizedStart = null;
|
||||||
|
DateTime? normalizedEnd = null;
|
||||||
|
|
||||||
|
if (startUtc.HasValue)
|
||||||
|
{
|
||||||
|
var utcValue = NormalizeUtc(startUtc.Value);
|
||||||
|
normalizedStart = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endUtc.HasValue)
|
||||||
|
{
|
||||||
|
var utcValue = NormalizeUtc(endUtc.Value);
|
||||||
|
normalizedEnd = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc)
|
||||||
|
.AddDays(1)
|
||||||
|
.AddTicks(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart > normalizedEnd)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (normalizedStart, normalizedEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime NormalizeUtc(DateTime value)
|
||||||
|
{
|
||||||
|
return value.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Utc => value,
|
||||||
|
DateTimeKind.Local => value.ToUniversalTime(),
|
||||||
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildInvoiceNo(DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var utcNow = NormalizeUtc(nowUtc);
|
||||||
|
return $"INV{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(100, 999)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequiredText(string? value, string fieldName, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.Length > maxLength)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim();
|
||||||
|
if (normalized.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.Length > maxLength)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请发票处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApplyFinanceInvoiceRecordCommandHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider,
|
||||||
|
ICurrentUserAccessor currentUserAccessor)
|
||||||
|
: IRequestHandler<ApplyFinanceInvoiceRecordCommand, FinanceInvoiceRecordDetailDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceRecordDetailDto> Handle(
|
||||||
|
ApplyFinanceInvoiceRecordCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var invoiceType = FinanceInvoiceMapping.ParseInvoiceTypeRequired(request.InvoiceType);
|
||||||
|
var applicantName = FinanceInvoiceMapping.NormalizeApplicantName(request.ApplicantName);
|
||||||
|
var companyName = FinanceInvoiceMapping.NormalizeCompanyName(request.CompanyName);
|
||||||
|
var taxpayerNumber = FinanceInvoiceMapping.NormalizeOptionalTaxpayerNumber(request.TaxpayerNumber);
|
||||||
|
var amount = FinanceInvoiceMapping.NormalizeAmount(request.Amount);
|
||||||
|
var orderNo = FinanceInvoiceMapping.NormalizeOrderNo(request.OrderNo);
|
||||||
|
var contactEmail = FinanceInvoiceMapping.NormalizeOptionalEmail(request.ContactEmail);
|
||||||
|
var contactPhone = FinanceInvoiceMapping.NormalizeOptionalPhone(request.ContactPhone);
|
||||||
|
var applyRemark = FinanceInvoiceMapping.NormalizeOptionalRemark(request.ApplyRemark, "applyRemark");
|
||||||
|
var appliedAt = request.AppliedAt.HasValue
|
||||||
|
? FinanceInvoiceMapping.NormalizeUtc(request.AppliedAt.Value)
|
||||||
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (invoiceType == TenantInvoiceType.Special && string.IsNullOrWhiteSpace(taxpayerNumber))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "专票必须填写纳税人识别号");
|
||||||
|
}
|
||||||
|
|
||||||
|
var setting = await repository.GetSettingAsync(tenantId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.BadRequest, "请先完成发票设置");
|
||||||
|
EnsureTypeEnabled(setting, invoiceType);
|
||||||
|
|
||||||
|
var invoiceNo = await GenerateInvoiceNoAsync(tenantId, cancellationToken);
|
||||||
|
var entity = FinanceInvoiceDtoFactory.CreateRecordEntity(
|
||||||
|
tenantId,
|
||||||
|
invoiceNo,
|
||||||
|
applicantName,
|
||||||
|
companyName,
|
||||||
|
taxpayerNumber,
|
||||||
|
invoiceType,
|
||||||
|
amount,
|
||||||
|
orderNo,
|
||||||
|
contactEmail,
|
||||||
|
contactPhone,
|
||||||
|
applyRemark,
|
||||||
|
appliedAt);
|
||||||
|
|
||||||
|
if (setting.EnableAutoIssue && amount <= setting.AutoIssueMaxAmount)
|
||||||
|
{
|
||||||
|
entity.Status = TenantInvoiceStatus.Issued;
|
||||||
|
entity.IssuedAt = DateTime.UtcNow;
|
||||||
|
entity.IssuedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
|
||||||
|
entity.IssueRemark = "系统自动开票";
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.AddRecordAsync(entity, cancellationToken);
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FinanceInvoiceDtoFactory.ToRecordDetailDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureTypeEnabled(TenantInvoiceSetting setting, TenantInvoiceType type)
|
||||||
|
{
|
||||||
|
if (type == TenantInvoiceType.Normal && !setting.EnableElectronicNormalInvoice)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "电子普通发票未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == TenantInvoiceType.Special && !setting.EnableElectronicSpecialInvoice)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "电子专用发票未启用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateInvoiceNoAsync(long tenantId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < 10; index += 1)
|
||||||
|
{
|
||||||
|
var invoiceNo = FinanceInvoiceMapping.BuildInvoiceNo(DateTime.UtcNow);
|
||||||
|
var exists = await repository.ExistsInvoiceNoAsync(tenantId, invoiceNo, cancellationToken);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
return invoiceNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "生成发票号码失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceInvoiceRecordDetailQueryHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceInvoiceRecordDetailQuery, FinanceInvoiceRecordDetailDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceRecordDetailDto> Handle(
|
||||||
|
GetFinanceInvoiceRecordDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
|
||||||
|
|
||||||
|
return FinanceInvoiceDtoFactory.ToRecordDetailDto(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录分页查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceInvoiceRecordListQueryHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceInvoiceRecordListQuery, FinanceInvoiceRecordListResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceRecordListResultDto> Handle(
|
||||||
|
GetFinanceInvoiceRecordListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var keyword = FinanceInvoiceMapping.NormalizeOptionalKeyword(request.Keyword);
|
||||||
|
var (startUtc, endUtc) = FinanceInvoiceMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
|
||||||
|
var page = Math.Max(1, request.Page);
|
||||||
|
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
|
||||||
|
var (items, totalCount) = await repository.SearchRecordsAsync(
|
||||||
|
tenantId,
|
||||||
|
startUtc,
|
||||||
|
endUtc,
|
||||||
|
request.Status,
|
||||||
|
request.InvoiceType,
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var statsSnapshot = await repository.GetStatsAsync(tenantId, DateTime.UtcNow, cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceInvoiceRecordListResultDto
|
||||||
|
{
|
||||||
|
Items = items.Select(FinanceInvoiceDtoFactory.ToRecordDto).ToList(),
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Stats = FinanceInvoiceDtoFactory.ToStatsDto(statsSnapshot)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票设置详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceInvoiceSettingDetailQueryHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceInvoiceSettingDetailQuery, FinanceInvoiceSettingDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceSettingDto> Handle(
|
||||||
|
GetFinanceInvoiceSettingDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var setting = await repository.GetSettingAsync(tenantId, cancellationToken);
|
||||||
|
|
||||||
|
return setting is null
|
||||||
|
? FinanceInvoiceDtoFactory.CreateDefaultSettingDto()
|
||||||
|
: FinanceInvoiceDtoFactory.ToSettingDto(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票开票处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IssueFinanceInvoiceRecordCommandHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider,
|
||||||
|
ICurrentUserAccessor currentUserAccessor)
|
||||||
|
: IRequestHandler<IssueFinanceInvoiceRecordCommand, FinanceInvoiceIssueResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceIssueResultDto> Handle(
|
||||||
|
IssueFinanceInvoiceRecordCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
|
||||||
|
|
||||||
|
if (record.Status != TenantInvoiceStatus.Pending)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "仅待开票记录允许开票");
|
||||||
|
}
|
||||||
|
|
||||||
|
var setting = await repository.GetSettingAsync(tenantId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.BadRequest, "请先完成发票设置");
|
||||||
|
EnsureTypeEnabled(setting, record.InvoiceType);
|
||||||
|
|
||||||
|
record.ContactEmail = FinanceInvoiceMapping.NormalizeOptionalEmail(request.ContactEmail) ?? record.ContactEmail;
|
||||||
|
record.IssueRemark = FinanceInvoiceMapping.NormalizeOptionalRemark(request.IssueRemark, "issueRemark");
|
||||||
|
record.Status = TenantInvoiceStatus.Issued;
|
||||||
|
record.IssuedAt = DateTime.UtcNow;
|
||||||
|
record.IssuedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
|
||||||
|
|
||||||
|
await repository.UpdateRecordAsync(record, cancellationToken);
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FinanceInvoiceDtoFactory.ToIssueResultDto(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureTypeEnabled(TenantInvoiceSetting setting, TenantInvoiceType type)
|
||||||
|
{
|
||||||
|
if (type == TenantInvoiceType.Normal && !setting.EnableElectronicNormalInvoice)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "电子普通发票未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == TenantInvoiceType.Special && !setting.EnableElectronicSpecialInvoice)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "电子专用发票未启用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存发票设置处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveFinanceInvoiceSettingCommandHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SaveFinanceInvoiceSettingCommand, FinanceInvoiceSettingDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceSettingDto> Handle(
|
||||||
|
SaveFinanceInvoiceSettingCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!request.EnableElectronicNormalInvoice && !request.EnableElectronicSpecialInvoice)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "至少启用一种发票类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var companyName = FinanceInvoiceMapping.NormalizeCompanyName(request.CompanyName);
|
||||||
|
var taxpayerNumber = FinanceInvoiceMapping.NormalizeTaxpayerNumber(request.TaxpayerNumber);
|
||||||
|
var registeredAddress = FinanceInvoiceMapping.NormalizeOptionalRemark(request.RegisteredAddress, "registeredAddress", 256);
|
||||||
|
var registeredPhone = FinanceInvoiceMapping.NormalizeOptionalPhone(request.RegisteredPhone);
|
||||||
|
var bankName = FinanceInvoiceMapping.NormalizeOptionalRemark(request.BankName, "bankName", 128);
|
||||||
|
var bankAccount = FinanceInvoiceMapping.NormalizeOptionalRemark(request.BankAccount, "bankAccount", 64);
|
||||||
|
var autoIssueMaxAmount = FinanceInvoiceMapping.NormalizeAutoIssueMaxAmount(request.AutoIssueMaxAmount);
|
||||||
|
|
||||||
|
var setting = await repository.GetSettingAsync(tenantId, cancellationToken);
|
||||||
|
if (setting is null)
|
||||||
|
{
|
||||||
|
setting = FinanceInvoiceDtoFactory.CreateSettingEntity(
|
||||||
|
request,
|
||||||
|
companyName,
|
||||||
|
taxpayerNumber,
|
||||||
|
registeredAddress,
|
||||||
|
registeredPhone,
|
||||||
|
bankName,
|
||||||
|
bankAccount,
|
||||||
|
autoIssueMaxAmount);
|
||||||
|
|
||||||
|
await repository.AddSettingAsync(setting, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FinanceInvoiceDtoFactory.ApplySettingChanges(
|
||||||
|
setting,
|
||||||
|
request,
|
||||||
|
companyName,
|
||||||
|
taxpayerNumber,
|
||||||
|
registeredAddress,
|
||||||
|
registeredPhone,
|
||||||
|
bankName,
|
||||||
|
bankAccount,
|
||||||
|
autoIssueMaxAmount);
|
||||||
|
|
||||||
|
await repository.UpdateSettingAsync(setting, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
return FinanceInvoiceDtoFactory.ToSettingDto(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票作废处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoidFinanceInvoiceRecordCommandHandler(
|
||||||
|
ITenantInvoiceRepository repository,
|
||||||
|
ITenantProvider tenantProvider,
|
||||||
|
ICurrentUserAccessor currentUserAccessor)
|
||||||
|
: IRequestHandler<VoidFinanceInvoiceRecordCommand, FinanceInvoiceRecordDetailDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceInvoiceRecordDetailDto> Handle(
|
||||||
|
VoidFinanceInvoiceRecordCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
|
||||||
|
|
||||||
|
if (record.Status != TenantInvoiceStatus.Issued)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "仅已开票记录允许作废");
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Status = TenantInvoiceStatus.Voided;
|
||||||
|
record.VoidReason = FinanceInvoiceMapping.NormalizeVoidReason(request.VoidReason);
|
||||||
|
record.VoidedAt = DateTime.UtcNow;
|
||||||
|
record.VoidedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
|
||||||
|
|
||||||
|
await repository.UpdateRecordAsync(record, cancellationToken);
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FinanceInvoiceDtoFactory.ToRecordDetailDto(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询发票记录详情。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceInvoiceRecordDetailQuery : IRequest<FinanceInvoiceRecordDetailDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long RecordId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询发票记录分页。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceInvoiceRecordListQuery : IRequest<FinanceInvoiceRecordListResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 开始日期(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartDateUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束日期(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? EndDateUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态筛选。
|
||||||
|
/// </summary>
|
||||||
|
public TenantInvoiceStatus? Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类型筛选。
|
||||||
|
/// </summary>
|
||||||
|
public TenantInvoiceType? InvoiceType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询发票设置详情。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceInvoiceSettingDetailQuery : IRequest<FinanceInvoiceSettingDto>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览指标卡 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewKpiCardDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 指标值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对比基准值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CompareAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 变化率(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ChangeRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 趋势(up/down/flat)。
|
||||||
|
/// </summary>
|
||||||
|
public string Trend { get; set; } = "flat";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对比文案(较昨日/较上周)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompareLabel { get; set; } = "较昨日";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势点 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeTrendPointDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string Date { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轴标签(MM/dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string DateLabel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实收金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeTrendDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 近 7 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewIncomeTrendPointDto> Last7Days { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewIncomeTrendPointDto> Last30Days { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势点 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewProfitTrendPointDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string Date { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轴标签(MM/dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string DateLabel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewProfitTrendDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 近 7 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewProfitTrendPointDto> Last7Days { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewProfitTrendPointDto> Last30Days { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeCompositionItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道编码(delivery/pickup/dine_in)。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percentage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewIncomeCompositionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总实收。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构成项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewIncomeCompositionItemDto> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本构成项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewCostCompositionItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分类编码(food/labor/fixed/packaging/platform)。
|
||||||
|
/// </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 FinanceOverviewCostCompositionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 总成本。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构成项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewCostCompositionItemDto> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewTopProductItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 排名。
|
||||||
|
/// </summary>
|
||||||
|
public int Rank { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品名称。
|
||||||
|
/// </summary>
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 销量。
|
||||||
|
/// </summary>
|
||||||
|
public int SalesQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收占比(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Percentage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品区块 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewTopProductDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 统计周期(天)。
|
||||||
|
/// </summary>
|
||||||
|
public int PeriodDays { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排行数据。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceOverviewTopProductItemDto> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览页面 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceOverviewDashboardDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 维度编码(tenant/store)。
|
||||||
|
/// </summary>
|
||||||
|
public string Dimension { get; set; } = "tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识(门店维度时有值)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 今日营业额卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardDto TodayRevenue { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实收卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardDto ActualReceived { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardDto RefundAmount { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净收入卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardDto NetIncome { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可提现余额卡片。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewKpiCardDto WithdrawableBalance { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewIncomeTrendDto IncomeTrend { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewProfitTrendDto ProfitTrend { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewIncomeCompositionDto IncomeComposition { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本构成。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewCostCompositionDto CostComposition { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品排行。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceOverviewTopProductDto TopProducts { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Overview.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览映射与格式化。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FinanceOverviewMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 构建财务概览页面 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceOverviewDashboardDto ToDashboardDto(FinanceOverviewDashboardSnapshot snapshot)
|
||||||
|
{
|
||||||
|
// 1. 指标卡映射。
|
||||||
|
var todayNetIncome = snapshot.Summary.TodayNetReceived - snapshot.Summary.TodayTotalCost;
|
||||||
|
var yesterdayNetIncome = snapshot.Summary.YesterdayNetReceived - snapshot.Summary.YesterdayTotalCost;
|
||||||
|
|
||||||
|
// 2. 近 30/7 天趋势映射。
|
||||||
|
var incomeLast30 = snapshot.IncomeTrend
|
||||||
|
.OrderBy(item => item.BusinessDate)
|
||||||
|
.Select(item => new FinanceOverviewIncomeTrendPointDto
|
||||||
|
{
|
||||||
|
Date = item.BusinessDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
DateLabel = item.BusinessDate.ToString("MM/dd", CultureInfo.InvariantCulture),
|
||||||
|
Amount = RoundAmount(item.NetReceivedAmount)
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var incomeLast7 = incomeLast30.Skip(Math.Max(0, incomeLast30.Count - 7)).ToList();
|
||||||
|
|
||||||
|
var profitLast30 = snapshot.ProfitTrend
|
||||||
|
.OrderBy(item => item.BusinessDate)
|
||||||
|
.Select(item => new FinanceOverviewProfitTrendPointDto
|
||||||
|
{
|
||||||
|
Date = item.BusinessDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
DateLabel = item.BusinessDate.ToString("MM/dd", CultureInfo.InvariantCulture),
|
||||||
|
RevenueAmount = RoundAmount(item.RevenueAmount),
|
||||||
|
CostAmount = RoundAmount(item.CostAmount),
|
||||||
|
NetProfitAmount = RoundAmount(item.NetProfitAmount)
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var profitLast7 = profitLast30.Skip(Math.Max(0, profitLast30.Count - 7)).ToList();
|
||||||
|
|
||||||
|
// 3. 收入构成映射。
|
||||||
|
var incomeTotal = RoundAmount(snapshot.IncomeComposition.Sum(item => item.Amount));
|
||||||
|
var incomeItems = snapshot.IncomeComposition
|
||||||
|
.OrderBy(item => GetChannelSort(item.Channel))
|
||||||
|
.Select(item => new FinanceOverviewIncomeCompositionItemDto
|
||||||
|
{
|
||||||
|
Channel = ToChannelCode(item.Channel),
|
||||||
|
ChannelText = ToChannelText(item.Channel),
|
||||||
|
Amount = RoundAmount(item.Amount),
|
||||||
|
Percentage = incomeTotal > 0
|
||||||
|
? RoundAmount(item.Amount / incomeTotal * 100m)
|
||||||
|
: 0m
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 4. 成本构成映射。
|
||||||
|
var costTotal = RoundAmount(snapshot.CostComposition.Sum(item => item.Amount));
|
||||||
|
var costItems = snapshot.CostComposition
|
||||||
|
.OrderBy(item => GetCostCategorySort(item.CategoryCode))
|
||||||
|
.Select(item => new FinanceOverviewCostCompositionItemDto
|
||||||
|
{
|
||||||
|
Category = item.CategoryCode,
|
||||||
|
CategoryText = ToCostCategoryText(item.CategoryCode),
|
||||||
|
Amount = RoundAmount(item.Amount),
|
||||||
|
Percentage = costTotal > 0
|
||||||
|
? RoundAmount(item.Amount / costTotal * 100m)
|
||||||
|
: 0m
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 5. TOP10 映射。
|
||||||
|
var topTotalRevenue = snapshot.TopProductTotalRevenue > 0
|
||||||
|
? snapshot.TopProductTotalRevenue
|
||||||
|
: snapshot.TopProducts.Sum(item => item.RevenueAmount);
|
||||||
|
var topItems = snapshot.TopProducts
|
||||||
|
.OrderByDescending(item => item.RevenueAmount)
|
||||||
|
.ThenByDescending(item => item.SalesQuantity)
|
||||||
|
.Select((item, index) => new FinanceOverviewTopProductItemDto
|
||||||
|
{
|
||||||
|
Rank = index + 1,
|
||||||
|
ProductName = item.ProductName,
|
||||||
|
SalesQuantity = item.SalesQuantity,
|
||||||
|
RevenueAmount = RoundAmount(item.RevenueAmount),
|
||||||
|
Percentage = topTotalRevenue > 0
|
||||||
|
? RoundAmount(item.RevenueAmount / topTotalRevenue * 100m)
|
||||||
|
: 0m
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new FinanceOverviewDashboardDto
|
||||||
|
{
|
||||||
|
Dimension = ToDimensionCode(snapshot.Dimension),
|
||||||
|
StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
|
||||||
|
TodayRevenue = BuildKpi(snapshot.Summary.TodayGrossRevenue, snapshot.Summary.YesterdayGrossRevenue, "较昨日"),
|
||||||
|
ActualReceived = BuildKpi(snapshot.Summary.TodayNetReceived, snapshot.Summary.YesterdayNetReceived, "较昨日"),
|
||||||
|
RefundAmount = BuildKpi(snapshot.Summary.TodayRefundAmount, snapshot.Summary.YesterdayRefundAmount, "较昨日"),
|
||||||
|
NetIncome = BuildKpi(todayNetIncome, yesterdayNetIncome, "较昨日"),
|
||||||
|
WithdrawableBalance = BuildKpi(
|
||||||
|
snapshot.Summary.WithdrawableBalance,
|
||||||
|
snapshot.Summary.WithdrawableBalanceLastWeek,
|
||||||
|
"较上周"),
|
||||||
|
IncomeTrend = new FinanceOverviewIncomeTrendDto
|
||||||
|
{
|
||||||
|
Last7Days = incomeLast7,
|
||||||
|
Last30Days = incomeLast30
|
||||||
|
},
|
||||||
|
ProfitTrend = new FinanceOverviewProfitTrendDto
|
||||||
|
{
|
||||||
|
Last7Days = profitLast7,
|
||||||
|
Last30Days = profitLast30
|
||||||
|
},
|
||||||
|
IncomeComposition = new FinanceOverviewIncomeCompositionDto
|
||||||
|
{
|
||||||
|
TotalAmount = incomeTotal,
|
||||||
|
Items = incomeItems
|
||||||
|
},
|
||||||
|
CostComposition = new FinanceOverviewCostCompositionDto
|
||||||
|
{
|
||||||
|
TotalAmount = costTotal,
|
||||||
|
Items = costItems
|
||||||
|
},
|
||||||
|
TopProducts = new FinanceOverviewTopProductDto
|
||||||
|
{
|
||||||
|
PeriodDays = 30,
|
||||||
|
Items = topItems
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceOverviewKpiCardDto BuildKpi(decimal current, decimal previous, string compareLabel)
|
||||||
|
{
|
||||||
|
var normalizedCurrent = RoundAmount(current);
|
||||||
|
var normalizedPrevious = RoundAmount(previous);
|
||||||
|
return new FinanceOverviewKpiCardDto
|
||||||
|
{
|
||||||
|
Amount = normalizedCurrent,
|
||||||
|
CompareAmount = normalizedPrevious,
|
||||||
|
ChangeRate = CalculateChangeRate(normalizedCurrent, normalizedPrevious),
|
||||||
|
Trend = ResolveTrend(normalizedCurrent, normalizedPrevious),
|
||||||
|
CompareLabel = compareLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateChangeRate(decimal current, decimal previous)
|
||||||
|
{
|
||||||
|
if (previous == 0m)
|
||||||
|
{
|
||||||
|
return current == 0m ? 0m : 100m;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rate = (current - previous) / previous * 100m;
|
||||||
|
return RoundAmount(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTrend(decimal current, decimal previous)
|
||||||
|
{
|
||||||
|
if (current > previous)
|
||||||
|
{
|
||||||
|
return "up";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < previous)
|
||||||
|
{
|
||||||
|
return "down";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "flat";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDimensionCode(FinanceCostDimension value)
|
||||||
|
{
|
||||||
|
return value == FinanceCostDimension.Store ? "store" : "tenant";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToChannelCode(DeliveryType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => "delivery",
|
||||||
|
DeliveryType.Pickup => "pickup",
|
||||||
|
DeliveryType.DineIn => "dine_in",
|
||||||
|
_ => "delivery"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToChannelText(DeliveryType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => "外卖",
|
||||||
|
DeliveryType.Pickup => "自提",
|
||||||
|
DeliveryType.DineIn => "堂食",
|
||||||
|
_ => "外卖"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetChannelSort(DeliveryType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => 0,
|
||||||
|
DeliveryType.Pickup => 1,
|
||||||
|
DeliveryType.DineIn => 2,
|
||||||
|
_ => 9
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetCostCategorySort(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"food" => 0,
|
||||||
|
"labor" => 1,
|
||||||
|
"fixed" => 2,
|
||||||
|
"packaging" => 3,
|
||||||
|
"platform" => 4,
|
||||||
|
_ => 9
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToCostCategoryText(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"food" => "食材",
|
||||||
|
"labor" => "人工",
|
||||||
|
"fixed" => "固定",
|
||||||
|
"packaging" => "包装",
|
||||||
|
"platform" => "平台",
|
||||||
|
_ => "其他"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal RoundAmount(decimal value)
|
||||||
|
{
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Overview.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceOverviewDashboardQueryHandler(
|
||||||
|
IFinanceOverviewRepository financeOverviewRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceOverviewDashboardQuery, FinanceOverviewDashboardDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceOverviewDashboardDto> Handle(
|
||||||
|
GetFinanceOverviewDashboardQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 拉取租户上下文并读取快照。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var snapshot = await financeOverviewRepository.GetDashboardSnapshotAsync(
|
||||||
|
tenantId,
|
||||||
|
request.Dimension,
|
||||||
|
request.StoreId,
|
||||||
|
request.CurrentUtc,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 映射页面 DTO。
|
||||||
|
return FinanceOverviewMapping.ToDashboardDto(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Overview.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询财务概览驾驶舱数据。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceOverviewDashboardQuery : IRequest<FinanceOverviewDashboardDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 统计维度。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识(门店维度必填)。
|
||||||
|
/// </summary>
|
||||||
|
public long? StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前 UTC 时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CurrentUtc { get; init; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Overview.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Overview.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceOverviewDashboardQueryValidator : AbstractValidator<GetFinanceOverviewDashboardQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public GetFinanceOverviewDashboardQueryValidator()
|
||||||
|
{
|
||||||
|
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.CurrentUtc)
|
||||||
|
.Must(value => value.Year is >= 2000 and <= 2100)
|
||||||
|
.WithMessage("currentUtc 非法");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表列表行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ReportId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 日期文案。
|
||||||
|
/// </summary>
|
||||||
|
public string DateText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageOrderValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RefundRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ProfitRatePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可下载。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanDownload { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表列表结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportListItemDto> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 KPI DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportKpiDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 指标键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标值文本。
|
||||||
|
/// </summary>
|
||||||
|
public string ValueText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同比变化率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YoyChangeRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 环比变化率(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MomChangeRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表明细行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportBreakdownItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 明细键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 明细名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(百分数)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RatioPercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ReportId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键指标。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportKpiDto> Kpis { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入明细(按渠道)。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportBreakdownItemDto> IncomeBreakdowns { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本明细(按类别)。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportBreakdownItemDto> CostBreakdowns { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表导出 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportExportDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名。
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base64 文件内容。
|
||||||
|
/// </summary>
|
||||||
|
public string FileContentBase64 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总记录数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Services;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表批量导出处理器(ZIP:PDF + Excel)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportBatchQueryHandler(
|
||||||
|
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||||
|
IFinanceBusinessReportExportService financeBusinessReportExportService,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportFinanceBusinessReportBatchQuery, FinanceBusinessReportExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportExportDto> Handle(
|
||||||
|
ExportFinanceBusinessReportBatchQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并归一化分页参数。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var normalizedPage = Math.Max(1, request.Page);
|
||||||
|
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
|
||||||
|
// 2. 确保成本配置并补齐快照。
|
||||||
|
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
cancellationToken);
|
||||||
|
await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.PeriodType,
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 3. 查询导出明细集合(允许实时补算)。
|
||||||
|
var details = await financeBusinessReportRepository.ListBatchDetailsAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.PeriodType,
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
|
allowRealtimeBuild: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 4. 生成批量 PDF/Excel 并打包 ZIP。
|
||||||
|
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(request.PeriodType);
|
||||||
|
var zipBytes = await CreateZipAsync(
|
||||||
|
details,
|
||||||
|
periodCode,
|
||||||
|
financeBusinessReportExportService,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceBusinessReportExportDto
|
||||||
|
{
|
||||||
|
FileName = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"business-report-batch-{request.StoreId}-{periodCode}-{DateTime.UtcNow:yyyyMMddHHmmss}.zip"),
|
||||||
|
FileContentBase64 = Convert.ToBase64String(zipBytes),
|
||||||
|
TotalCount = details.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> CreateZipAsync(
|
||||||
|
IReadOnlyList<Domain.Finance.Models.FinanceBusinessReportDetailSnapshot> details,
|
||||||
|
string periodCode,
|
||||||
|
IFinanceBusinessReportExportService exportService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (details.Count == 0)
|
||||||
|
{
|
||||||
|
using var emptyStream = new MemoryStream();
|
||||||
|
using (var emptyArchive = new ZipArchive(emptyStream, ZipArchiveMode.Create, true))
|
||||||
|
{
|
||||||
|
var entry = emptyArchive.CreateEntry("README.txt");
|
||||||
|
await using var writer = new StreamWriter(entry.Open());
|
||||||
|
await writer.WriteAsync("No business report data in current selection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var pdfBytes = await exportService.ExportBatchPdfAsync(details, cancellationToken);
|
||||||
|
var excelBytes = await exportService.ExportBatchExcelAsync(details, cancellationToken);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
|
||||||
|
{
|
||||||
|
var pdfEntry = archive.CreateEntry($"business-report-{periodCode}.pdf");
|
||||||
|
await using (var pdfEntryStream = pdfEntry.Open())
|
||||||
|
{
|
||||||
|
await pdfEntryStream.WriteAsync(pdfBytes, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var excelEntry = archive.CreateEntry($"business-report-{periodCode}.xlsx");
|
||||||
|
await using (var excelEntryStream = excelEntry.Open())
|
||||||
|
{
|
||||||
|
await excelEntryStream.WriteAsync(excelBytes, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Services;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 Excel 导出处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportExcelQueryHandler(
|
||||||
|
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||||
|
IFinanceBusinessReportExportService financeBusinessReportExportService,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportFinanceBusinessReportExcelQuery, FinanceBusinessReportExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportExportDto> Handle(
|
||||||
|
ExportFinanceBusinessReportExcelQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并确保成本配置存在。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询报表详情(允许实时补算)。
|
||||||
|
var detail = await financeBusinessReportRepository.GetDetailAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.ReportId,
|
||||||
|
allowRealtimeBuild: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (detail is null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 导出 Excel 并返回 Base64。
|
||||||
|
var fileBytes = await financeBusinessReportExportService.ExportSingleExcelAsync(detail, cancellationToken);
|
||||||
|
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
|
||||||
|
return new FinanceBusinessReportExportDto
|
||||||
|
{
|
||||||
|
FileName = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.xlsx"),
|
||||||
|
FileContentBase64 = Convert.ToBase64String(fileBytes),
|
||||||
|
TotalCount = 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Services;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 PDF 导出处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportPdfQueryHandler(
|
||||||
|
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||||
|
IFinanceBusinessReportExportService financeBusinessReportExportService,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportFinanceBusinessReportPdfQuery, FinanceBusinessReportExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportExportDto> Handle(
|
||||||
|
ExportFinanceBusinessReportPdfQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并确保成本配置存在。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询报表详情(允许实时补算)。
|
||||||
|
var detail = await financeBusinessReportRepository.GetDetailAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.ReportId,
|
||||||
|
allowRealtimeBuild: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (detail is null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 导出 PDF 并返回 Base64。
|
||||||
|
var fileBytes = await financeBusinessReportExportService.ExportSinglePdfAsync(detail, cancellationToken);
|
||||||
|
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
|
||||||
|
return new FinanceBusinessReportExportDto
|
||||||
|
{
|
||||||
|
FileName = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.pdf"),
|
||||||
|
FileContentBase64 = Convert.ToBase64String(fileBytes),
|
||||||
|
TotalCount = 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表映射工具。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FinanceBusinessReportMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 映射列表行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceBusinessReportListItemDto ToListItem(FinanceBusinessReportListItemSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportListItemDto
|
||||||
|
{
|
||||||
|
ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
DateText = FormatPeriodText(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
|
||||||
|
RevenueAmount = RoundMoney(source.RevenueAmount),
|
||||||
|
OrderCount = Math.Max(0, source.OrderCount),
|
||||||
|
AverageOrderValue = RoundMoney(source.AverageOrderValue),
|
||||||
|
RefundRatePercent = RoundPercent(source.RefundRate),
|
||||||
|
CostTotalAmount = RoundMoney(source.CostTotalAmount),
|
||||||
|
NetProfitAmount = RoundMoney(source.NetProfitAmount),
|
||||||
|
ProfitRatePercent = RoundPercent(source.ProfitRate),
|
||||||
|
Status = ToStatusCode(source.Status),
|
||||||
|
StatusText = ToStatusText(source.Status),
|
||||||
|
CanDownload = source.Status == FinanceBusinessReportStatus.Succeeded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceBusinessReportDetailDto ToDetail(FinanceBusinessReportDetailSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportDetailDto
|
||||||
|
{
|
||||||
|
ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Title = BuildTitle(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
|
||||||
|
PeriodType = ToPeriodTypeCode(source.PeriodType),
|
||||||
|
Status = ToStatusCode(source.Status),
|
||||||
|
StatusText = ToStatusText(source.Status),
|
||||||
|
Kpis = source.Kpis.Select(ToKpi).ToList(),
|
||||||
|
IncomeBreakdowns = source.IncomeBreakdowns.Select(ToBreakdown).ToList(),
|
||||||
|
CostBreakdowns = source.CostBreakdowns.Select(ToBreakdown).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型编码。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToPeriodTypeCode(FinanceBusinessReportPeriodType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
FinanceBusinessReportPeriodType.Daily => "daily",
|
||||||
|
FinanceBusinessReportPeriodType.Weekly => "weekly",
|
||||||
|
FinanceBusinessReportPeriodType.Monthly => "monthly",
|
||||||
|
_ => "daily"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportKpiDto ToKpi(FinanceBusinessReportKpiSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportKpiDto
|
||||||
|
{
|
||||||
|
Key = source.Key,
|
||||||
|
Label = source.Label,
|
||||||
|
ValueText = FormatKpiValue(source.Key, source.Value),
|
||||||
|
YoyChangeRate = RoundRate(source.YoyChangeRate),
|
||||||
|
MomChangeRate = RoundRate(source.MomChangeRate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceBusinessReportBreakdownItemDto ToBreakdown(FinanceBusinessReportBreakdownSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportBreakdownItemDto
|
||||||
|
{
|
||||||
|
Key = source.Key,
|
||||||
|
Label = source.Label,
|
||||||
|
Amount = RoundMoney(source.Amount),
|
||||||
|
RatioPercent = RoundPercent(source.Ratio)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatPeriodText(
|
||||||
|
FinanceBusinessReportPeriodType periodType,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt)
|
||||||
|
{
|
||||||
|
return periodType switch
|
||||||
|
{
|
||||||
|
FinanceBusinessReportPeriodType.Daily => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
FinanceBusinessReportPeriodType.Weekly =>
|
||||||
|
$"{startAt:MM-dd} ~ {endAt.AddDays(-1):MM-dd}",
|
||||||
|
FinanceBusinessReportPeriodType.Monthly => startAt.ToString("yyyy年M月", CultureInfo.InvariantCulture),
|
||||||
|
_ => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTitle(
|
||||||
|
FinanceBusinessReportPeriodType periodType,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt)
|
||||||
|
{
|
||||||
|
return periodType switch
|
||||||
|
{
|
||||||
|
FinanceBusinessReportPeriodType.Daily => $"{startAt:yyyy年M月d日} 经营日报",
|
||||||
|
FinanceBusinessReportPeriodType.Weekly => $"{startAt:yyyy年M月d日}~{endAt.AddDays(-1):M月d日} 经营周报",
|
||||||
|
FinanceBusinessReportPeriodType.Monthly => $"{startAt:yyyy年M月} 经营月报",
|
||||||
|
_ => "经营报表"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToStatusCode(FinanceBusinessReportStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
FinanceBusinessReportStatus.Queued => "queued",
|
||||||
|
FinanceBusinessReportStatus.Running => "running",
|
||||||
|
FinanceBusinessReportStatus.Succeeded => "succeeded",
|
||||||
|
FinanceBusinessReportStatus.Failed => "failed",
|
||||||
|
_ => "queued"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToStatusText(FinanceBusinessReportStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
FinanceBusinessReportStatus.Queued => "排队中",
|
||||||
|
FinanceBusinessReportStatus.Running => "生成中",
|
||||||
|
FinanceBusinessReportStatus.Succeeded => "已生成",
|
||||||
|
FinanceBusinessReportStatus.Failed => "生成失败",
|
||||||
|
_ => "排队中"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatKpiValue(string key, decimal value)
|
||||||
|
{
|
||||||
|
if (key is "order_count")
|
||||||
|
{
|
||||||
|
return Math.Round(value, 0, MidpointRounding.AwayFromZero).ToString("0", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key is "refund_rate" or "profit_rate")
|
||||||
|
{
|
||||||
|
return $"{RoundPercent(value):0.##}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key is "average_order_value")
|
||||||
|
{
|
||||||
|
return $"¥{RoundMoney(value):0.##}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"¥{RoundMoney(value):0.##}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal RoundMoney(decimal value)
|
||||||
|
{
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal RoundPercent(decimal value)
|
||||||
|
{
|
||||||
|
return decimal.Round(value * 100m, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal RoundRate(decimal value)
|
||||||
|
{
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表详情查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceBusinessReportDetailQueryHandler(
|
||||||
|
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceBusinessReportDetailQuery, FinanceBusinessReportDetailDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportDetailDto?> Handle(
|
||||||
|
GetFinanceBusinessReportDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并确保成本配置存在。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询详情(允许实时补算)并映射输出。
|
||||||
|
var detail = await financeBusinessReportRepository.GetDetailAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.ReportId,
|
||||||
|
allowRealtimeBuild: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return detail is null ? null : FinanceBusinessReportMapping.ToDetail(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表分页查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceBusinessReportListQueryHandler(
|
||||||
|
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SearchFinanceBusinessReportListQuery, FinanceBusinessReportListResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportListResultDto> Handle(
|
||||||
|
SearchFinanceBusinessReportListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取租户上下文并归一化分页参数。
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var normalizedPage = Math.Max(1, request.Page);
|
||||||
|
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
|
||||||
|
// 2. 确保成本配置并补齐分页周期快照。
|
||||||
|
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
cancellationToken);
|
||||||
|
await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.PeriodType,
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 3. 查询分页快照并映射输出。
|
||||||
|
var pageSnapshot = await financeBusinessReportRepository.SearchPageAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.PeriodType,
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceBusinessReportListResultDto
|
||||||
|
{
|
||||||
|
Items = pageSnapshot.Items.Select(FinanceBusinessReportMapping.ToListItem).ToList(),
|
||||||
|
Total = pageSnapshot.TotalCount,
|
||||||
|
Page = normalizedPage,
|
||||||
|
PageSize = normalizedPageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量导出经营报表(ZIP:PDF + Excel)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportBatchQuery : IRequest<FinanceBusinessReportExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出经营报表 Excel。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportExcelQuery : IRequest<FinanceBusinessReportExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ReportId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出经营报表 PDF。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportPdfQuery : IRequest<FinanceBusinessReportExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ReportId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询经营报表详情。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceBusinessReportDetailQuery : IRequest<FinanceBusinessReportDetailDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ReportId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询经营报表分页列表。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceBusinessReportListQuery : IRequest<FinanceBusinessReportListResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表批量导出查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportBatchQueryValidator : AbstractValidator<ExportFinanceBusinessReportBatchQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public ExportFinanceBusinessReportBatchQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Page).GreaterThan(0);
|
||||||
|
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 Excel 导出查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportExcelQueryValidator : AbstractValidator<ExportFinanceBusinessReportExcelQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public ExportFinanceBusinessReportExcelQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.ReportId).GreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 PDF 导出查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceBusinessReportPdfQueryValidator : AbstractValidator<ExportFinanceBusinessReportPdfQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public ExportFinanceBusinessReportPdfQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.ReportId).GreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表详情查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceBusinessReportDetailQueryValidator : AbstractValidator<GetFinanceBusinessReportDetailQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public GetFinanceBusinessReportDetailQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.ReportId).GreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表分页查询验证器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceBusinessReportListQueryValidator : AbstractValidator<SearchFinanceBusinessReportListQuery>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化验证规则。
|
||||||
|
/// </summary>
|
||||||
|
public SearchFinanceBusinessReportListQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.Page).GreaterThan(0);
|
||||||
|
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal DeliveryFee { get; init; }
|
public decimal DeliveryFee { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台服务费率(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PlatformServiceRate { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 打包费模式。
|
/// 打包费模式。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public sealed record StoreFeeDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal DeliveryFee { get; init; }
|
public decimal DeliveryFee { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台服务费率(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PlatformServiceRate { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 餐具费是否启用。
|
/// 餐具费是否启用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public sealed class UpdateStoreFeeCommandHandler(
|
|||||||
// 3. (空行后) 应用更新字段
|
// 3. (空行后) 应用更新字段
|
||||||
fee.MinimumOrderAmount = request.MinimumOrderAmount;
|
fee.MinimumOrderAmount = request.MinimumOrderAmount;
|
||||||
fee.BaseDeliveryFee = request.DeliveryFee;
|
fee.BaseDeliveryFee = request.DeliveryFee;
|
||||||
|
fee.PlatformServiceRate = request.PlatformServiceRate;
|
||||||
fee.PackagingFeeMode = request.PackagingFeeMode;
|
fee.PackagingFeeMode = request.PackagingFeeMode;
|
||||||
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||||
? request.OrderPackagingFeeMode
|
? request.OrderPackagingFeeMode
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ public static class StoreMapping
|
|||||||
StoreId = fee.StoreId,
|
StoreId = fee.StoreId,
|
||||||
MinimumOrderAmount = fee.MinimumOrderAmount,
|
MinimumOrderAmount = fee.MinimumOrderAmount,
|
||||||
DeliveryFee = fee.BaseDeliveryFee,
|
DeliveryFee = fee.BaseDeliveryFee,
|
||||||
|
PlatformServiceRate = fee.PlatformServiceRate,
|
||||||
CutleryFeeEnabled = fee.CutleryFeeEnabled,
|
CutleryFeeEnabled = fee.CutleryFeeEnabled,
|
||||||
CutleryFeeAmount = fee.CutleryFeeAmount,
|
CutleryFeeAmount = fee.CutleryFeeAmount,
|
||||||
RushFeeEnabled = fee.RushFeeEnabled,
|
RushFeeEnabled = fee.RushFeeEnabled,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
|||||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||||
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).LessThanOrEqualTo(9999.99m);
|
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).LessThanOrEqualTo(9999.99m);
|
||||||
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).LessThanOrEqualTo(999.99m);
|
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).LessThanOrEqualTo(999.99m);
|
||||||
|
RuleFor(x => x.PlatformServiceRate).GreaterThanOrEqualTo(0).LessThanOrEqualTo(100m);
|
||||||
RuleFor(x => x.FreeDeliveryThreshold).GreaterThanOrEqualTo(0).When(x => x.FreeDeliveryThreshold.HasValue);
|
RuleFor(x => x.FreeDeliveryThreshold).GreaterThanOrEqualTo(0).When(x => x.FreeDeliveryThreshold.HasValue);
|
||||||
|
|
||||||
RuleFor(x => x.FixedPackagingFee)
|
RuleFor(x => x.FixedPackagingFee)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表快照实体。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportSnapshot : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 所属门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportPeriodType PeriodType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期开始时间(UTC,含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PeriodStartAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期结束时间(UTC,不含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PeriodEndAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成状态。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportStatus Status { get; set; } = FinanceBusinessReportStatus.Queued;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageOrderValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款率(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RefundRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润率(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ProfitRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// KPI 比较快照 JSON(同比/环比)。
|
||||||
|
/// </summary>
|
||||||
|
public string KpiComparisonJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入明细快照 JSON(按渠道)。
|
||||||
|
/// </summary>
|
||||||
|
public string IncomeBreakdownJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本明细快照 JSON(按类别)。
|
||||||
|
/// </summary>
|
||||||
|
public string CostBreakdownJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成开始时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成完成时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? FinishedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近一次失败信息。
|
||||||
|
/// </summary>
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 重试次数。
|
||||||
|
/// </summary>
|
||||||
|
public int RetryCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 调度任务 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? HangfireJobId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本日覆盖实体。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceCostDailyOverride : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 业务日期(UTC 日期)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime BusinessDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本分类。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceCostCategory Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 覆盖金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,56 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本配置实体(类别级规则)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceCostProfile : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本分类。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceCostCategory Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算模式。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceCostCalcMode CalcMode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 比例值(0-1,Ratio 模式使用)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Ratio { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 固定日金额(FixedDaily 模式使用)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal FixedDailyAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生效开始日期(UTC 日期)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime EffectiveFrom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生效结束日期(UTC 日期,含,null 表示长期)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? EffectiveTo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序值。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; } = 100;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表周期类型。
|
||||||
|
/// </summary>
|
||||||
|
public enum FinanceBusinessReportPeriodType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日报。
|
||||||
|
/// </summary>
|
||||||
|
Daily = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周报。
|
||||||
|
/// </summary>
|
||||||
|
Weekly = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 月报。
|
||||||
|
/// </summary>
|
||||||
|
Monthly = 3
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表快照状态。
|
||||||
|
/// </summary>
|
||||||
|
public enum FinanceBusinessReportStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 已排队。
|
||||||
|
/// </summary>
|
||||||
|
Queued = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成中。
|
||||||
|
/// </summary>
|
||||||
|
Running = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已生成。
|
||||||
|
/// </summary>
|
||||||
|
Succeeded = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成失败。
|
||||||
|
/// </summary>
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本计算模式。
|
||||||
|
/// </summary>
|
||||||
|
public enum FinanceCostCalcMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 按营业额比例计算。
|
||||||
|
/// </summary>
|
||||||
|
Ratio = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按固定日金额计算。
|
||||||
|
/// </summary>
|
||||||
|
FixedDaily = 2
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,245 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 KPI 快照项。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportKpiSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 指标键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标值。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同比变化率(百分数,如 3.5 表示 +3.5%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YoyChangeRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 环比变化率(百分数,如 2.1 表示 +2.1%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MomChangeRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表明细行快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportBreakdownSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 明细键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 明细名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 占比(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Ratio { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表列表行快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportListItemSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ReportId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportPeriodType PeriodType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期开始时间(UTC,含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PeriodStartAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期结束时间(UTC,不含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PeriodEndAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportStatus Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageOrderValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款率(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RefundRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润率(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ProfitRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表分页快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportPageSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportListItemSnapshot> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表详情快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportDetailSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 报表 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long ReportId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportPeriodType PeriodType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期开始时间(UTC,含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PeriodStartAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期结束时间(UTC,不含)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PeriodEndAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态。
|
||||||
|
/// </summary>
|
||||||
|
public FinanceBusinessReportStatus Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营业额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int OrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageOrderValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款率(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RefundRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润率(0-1)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ProfitRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键指标快照列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportKpiSnapshot> Kpis { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入明细(按渠道)。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本明细(按类别)。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待处理报表任务快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceBusinessReportPendingSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 快照 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long SnapshotId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long TenantId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,211 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览核心指标快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewSummarySnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 今日营业额(支付成功总额)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TodayGrossRevenue { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 昨日营业额(支付成功总额)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YesterdayGrossRevenue { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 今日实收(营业额 - 退款)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TodayNetReceived { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 昨日实收(营业额 - 退款)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YesterdayNetReceived { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 今日退款。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TodayRefundAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 昨日退款。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YesterdayRefundAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 今日总成本。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TodayTotalCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 昨日总成本。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YesterdayTotalCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前可提现余额(累计净收入口径)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal WithdrawableBalance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上周同日可提现余额(用于环比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal WithdrawableBalanceLastWeek { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入趋势点快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewIncomeTrendPointSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 业务日期(UTC 日期)。
|
||||||
|
/// </summary>
|
||||||
|
public required DateTime BusinessDate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实收金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetReceivedAmount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 利润趋势点快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewProfitTrendPointSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 业务日期(UTC 日期)。
|
||||||
|
/// </summary>
|
||||||
|
public required DateTime BusinessDate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收金额(支付成功总额)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 净利润金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal NetProfitAmount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成项快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewIncomeCompositionSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public required DeliveryType Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本构成项快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewCostCompositionSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分类编码(food/labor/fixed/packaging/platform)。
|
||||||
|
/// </summary>
|
||||||
|
public required string CategoryCode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 商品快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewTopProductSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 商品标识。
|
||||||
|
/// </summary>
|
||||||
|
public long ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品名称。
|
||||||
|
/// </summary>
|
||||||
|
public required string ProductName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 销量。
|
||||||
|
/// </summary>
|
||||||
|
public int SalesQuantity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营收金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RevenueAmount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览页面快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FinanceOverviewDashboardSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 统计维度。
|
||||||
|
/// </summary>
|
||||||
|
public required FinanceCostDimension Dimension { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识(租户维度为空)。
|
||||||
|
/// </summary>
|
||||||
|
public long? StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核心指标汇总。
|
||||||
|
/// </summary>
|
||||||
|
public required FinanceOverviewSummarySnapshot Summary { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天收入趋势。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FinanceOverviewIncomeTrendPointSnapshot> IncomeTrend { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 近 30 天利润趋势。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FinanceOverviewProfitTrendPointSnapshot> ProfitTrend { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入构成。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FinanceOverviewIncomeCompositionSnapshot> IncomeComposition { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成本构成。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FinanceOverviewCostCompositionSnapshot> CostComposition { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP10 商品营收排行。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FinanceOverviewTopProductSnapshot> TopProducts { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOP 榜单统计周期内商品总营收。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TopProductTotalRevenue { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表仓储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFinanceBusinessReportRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 确保门店存在默认成本配置。
|
||||||
|
/// </summary>
|
||||||
|
Task EnsureDefaultCostProfilesAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为指定分页周期补齐快照并排队。
|
||||||
|
/// </summary>
|
||||||
|
Task QueueSnapshotsForPageAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
FinanceBusinessReportPeriodType periodType,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询经营报表分页结果。
|
||||||
|
/// </summary>
|
||||||
|
Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
FinanceBusinessReportPeriodType periodType,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询经营报表详情。
|
||||||
|
/// </summary>
|
||||||
|
Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
long reportId,
|
||||||
|
bool allowRealtimeBuild,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询批量导出详情集合。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
FinanceBusinessReportPeriodType periodType,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
bool allowRealtimeBuild,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拉取待处理任务。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(
|
||||||
|
int take,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行报表快照生成。
|
||||||
|
/// </summary>
|
||||||
|
Task GenerateSnapshotAsync(
|
||||||
|
long snapshotId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览仓储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFinanceOverviewRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取财务概览页快照。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户标识。</param>
|
||||||
|
/// <param name="dimension">统计维度。</param>
|
||||||
|
/// <param name="storeId">门店标识(门店维度必填)。</param>
|
||||||
|
/// <param name="currentUtc">当前 UTC 时间。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
|
||||||
|
long tenantId,
|
||||||
|
FinanceCostDimension dimension,
|
||||||
|
long? storeId,
|
||||||
|
DateTime currentUtc,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Finance.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表导出服务契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFinanceBusinessReportExportService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 导出单条报表 PDF。
|
||||||
|
/// </summary>
|
||||||
|
Task<byte[]> ExportSinglePdfAsync(
|
||||||
|
FinanceBusinessReportDetailSnapshot detail,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出单条报表 Excel。
|
||||||
|
/// </summary>
|
||||||
|
Task<byte[]> ExportSingleExcelAsync(
|
||||||
|
FinanceBusinessReportDetailSnapshot detail,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出批量报表 PDF。
|
||||||
|
/// </summary>
|
||||||
|
Task<byte[]> ExportBatchPdfAsync(
|
||||||
|
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出批量报表 Excel。
|
||||||
|
/// </summary>
|
||||||
|
Task<byte[]> ExportBatchExcelAsync(
|
||||||
|
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,11 @@ public sealed class StoreFee : MultiTenantEntityBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal BaseDeliveryFee { get; set; } = 0m;
|
public decimal BaseDeliveryFee { get; set; } = 0m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台服务费率(%)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal PlatformServiceRate { get; set; } = 0m;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 打包费模式。
|
/// 打包费模式。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票记录。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TenantInvoiceRecord : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发票号码。
|
||||||
|
/// </summary>
|
||||||
|
public string InvoiceNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请人。
|
||||||
|
/// </summary>
|
||||||
|
public string ApplicantName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票抬头(公司名)。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号快照。
|
||||||
|
/// </summary>
|
||||||
|
public string? TaxpayerNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票类型。
|
||||||
|
/// </summary>
|
||||||
|
public TenantInvoiceType InvoiceType { get; set; } = TenantInvoiceType.Normal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收邮箱。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联系电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? ContactPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? ApplyRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票状态。
|
||||||
|
/// </summary>
|
||||||
|
public TenantInvoiceStatus Status { get; set; } = TenantInvoiceStatus.Pending;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 申请时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime AppliedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? IssuedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long? IssuedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开票备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssueRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? VoidedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long? VoidedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作废原因。
|
||||||
|
/// </summary>
|
||||||
|
public string? VoidReason { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票开票基础设置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TenantInvoiceSetting : MultiTenantEntityBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 企业名称。
|
||||||
|
/// </summary>
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 纳税人识别号。
|
||||||
|
/// </summary>
|
||||||
|
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册地址。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册电话。
|
||||||
|
/// </summary>
|
||||||
|
public string? RegisteredPhone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户银行。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string? BankAccount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子普通发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicNormalInvoice { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用电子专用发票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动开票。
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableAutoIssue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动开票单张最大金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票状态。
|
||||||
|
/// </summary>
|
||||||
|
public enum TenantInvoiceStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 待开票。
|
||||||
|
/// </summary>
|
||||||
|
Pending = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已开票。
|
||||||
|
/// </summary>
|
||||||
|
Issued = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已作废。
|
||||||
|
/// </summary>
|
||||||
|
Voided = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票类型。
|
||||||
|
/// </summary>
|
||||||
|
public enum TenantInvoiceType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 电子普通发票。
|
||||||
|
/// </summary>
|
||||||
|
Normal = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 电子专用发票。
|
||||||
|
/// </summary>
|
||||||
|
Special = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票仓储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface ITenantInvoiceRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 查询租户发票设置。
|
||||||
|
/// </summary>
|
||||||
|
Task<TenantInvoiceSetting?> GetSettingAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增发票设置。
|
||||||
|
/// </summary>
|
||||||
|
Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新发票设置。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询发票记录。
|
||||||
|
/// </summary>
|
||||||
|
Task<(IReadOnlyList<TenantInvoiceRecord> Items, int TotalCount)> SearchRecordsAsync(
|
||||||
|
long tenantId,
|
||||||
|
DateTime? startUtc,
|
||||||
|
DateTime? endUtc,
|
||||||
|
TenantInvoiceStatus? status,
|
||||||
|
TenantInvoiceType? invoiceType,
|
||||||
|
string? keyword,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取发票页统计。
|
||||||
|
/// </summary>
|
||||||
|
Task<TenantInvoiceRecordStatsSnapshot> GetStatsAsync(
|
||||||
|
long tenantId,
|
||||||
|
DateTime nowUtc,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据标识查询发票记录。
|
||||||
|
/// </summary>
|
||||||
|
Task<TenantInvoiceRecord?> FindRecordByIdAsync(
|
||||||
|
long tenantId,
|
||||||
|
long recordId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断租户下发票号码是否已存在。
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExistsInvoiceNoAsync(
|
||||||
|
long tenantId,
|
||||||
|
string invoiceNo,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增发票记录。
|
||||||
|
/// </summary>
|
||||||
|
Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新发票记录。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 持久化变更。
|
||||||
|
/// </summary>
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发票页面统计快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TenantInvoiceRecordStatsSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本月已开票金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CurrentMonthIssuedAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月已开票张数。
|
||||||
|
/// </summary>
|
||||||
|
public int CurrentMonthIssuedCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待开票张数。
|
||||||
|
/// </summary>
|
||||||
|
public int PendingCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已作废张数。
|
||||||
|
/// </summary>
|
||||||
|
public int VoidedCount { get; init; }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using TakeoutSaaS.Application.App.Stores.Services;
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Services;
|
||||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||||
@@ -55,6 +56,9 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
|
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
|
||||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||||
|
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
|
||||||
|
services.AddScoped<IFinanceOverviewRepository, EfFinanceOverviewRepository>();
|
||||||
|
services.AddScoped<IFinanceBusinessReportRepository, EfFinanceBusinessReportRepository>();
|
||||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||||
@@ -68,6 +72,7 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
||||||
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
|
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
|
||||||
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
|
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
|
||||||
|
services.AddScoped<ITenantInvoiceRepository, EfTenantInvoiceRepository>();
|
||||||
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
|
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
|
||||||
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
|
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
|
||||||
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
||||||
@@ -78,6 +83,7 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
||||||
services.AddScoped<IBillingExportService, BillingExportService>();
|
services.AddScoped<IBillingExportService, BillingExportService>();
|
||||||
services.AddScoped<IMerchantExportService, MerchantExportService>();
|
services.AddScoped<IMerchantExportService, MerchantExportService>();
|
||||||
|
services.AddScoped<IFinanceBusinessReportExportService, FinanceBusinessReportExportService>();
|
||||||
|
|
||||||
// 2. (空行后) 门店配置服务
|
// 2. (空行后) 门店配置服务
|
||||||
services.AddScoped<IGeoJsonValidationService, GeoJsonValidationService>();
|
services.AddScoped<IGeoJsonValidationService, GeoJsonValidationService>();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using TakeoutSaaS.Domain.Deliveries.Entities;
|
|||||||
using TakeoutSaaS.Domain.Distribution.Entities;
|
using TakeoutSaaS.Domain.Distribution.Entities;
|
||||||
using TakeoutSaaS.Domain.Common.Enums;
|
using TakeoutSaaS.Domain.Common.Enums;
|
||||||
using TakeoutSaaS.Domain.Engagement.Entities;
|
using TakeoutSaaS.Domain.Engagement.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Entities;
|
||||||
using TakeoutSaaS.Domain.GroupBuying.Entities;
|
using TakeoutSaaS.Domain.GroupBuying.Entities;
|
||||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||||
using TakeoutSaaS.Domain.Membership.Entities;
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
@@ -94,6 +95,34 @@ public sealed class TakeoutAppDbContext(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
|
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 租户发票设置。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<TenantInvoiceSetting> TenantInvoiceSettings => Set<TenantInvoiceSetting>();
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票记录。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<TenantInvoiceRecord> TenantInvoiceRecords => Set<TenantInvoiceRecord>();
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表快照。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<FinanceBusinessReportSnapshot> FinanceBusinessReportSnapshots => Set<FinanceBusinessReportSnapshot>();
|
||||||
|
/// <summary>
|
||||||
|
/// 成本配置。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<FinanceCostProfile> FinanceCostProfiles => Set<FinanceCostProfile>();
|
||||||
|
/// <summary>
|
||||||
|
/// 成本日覆盖。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<FinanceCostDailyOverride> FinanceCostDailyOverrides => Set<FinanceCostDailyOverride>();
|
||||||
|
/// <summary>
|
||||||
|
/// 成本录入汇总。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
|
||||||
|
/// <summary>
|
||||||
|
/// 成本录入明细。
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<FinanceCostEntryItem> FinanceCostEntryItems => Set<FinanceCostEntryItem>();
|
||||||
|
/// <summary>
|
||||||
/// 配额包定义。
|
/// 配额包定义。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
|
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
|
||||||
@@ -525,6 +554,13 @@ public sealed class TakeoutAppDbContext(
|
|||||||
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
|
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
|
||||||
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
|
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
|
||||||
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
|
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
|
||||||
|
ConfigureTenantInvoiceSetting(modelBuilder.Entity<TenantInvoiceSetting>());
|
||||||
|
ConfigureTenantInvoiceRecord(modelBuilder.Entity<TenantInvoiceRecord>());
|
||||||
|
ConfigureFinanceBusinessReportSnapshot(modelBuilder.Entity<FinanceBusinessReportSnapshot>());
|
||||||
|
ConfigureFinanceCostProfile(modelBuilder.Entity<FinanceCostProfile>());
|
||||||
|
ConfigureFinanceCostDailyOverride(modelBuilder.Entity<FinanceCostDailyOverride>());
|
||||||
|
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
|
||||||
|
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
|
||||||
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
||||||
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
|
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
|
||||||
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
|
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
|
||||||
@@ -774,6 +810,7 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.Property(x => x.StoreId).IsRequired();
|
builder.Property(x => x.StoreId).IsRequired();
|
||||||
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
|
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
|
||||||
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
|
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
|
||||||
|
builder.Property(x => x.PlatformServiceRate).HasPrecision(5, 2);
|
||||||
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
|
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
|
||||||
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
|
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
|
||||||
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
|
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
|
||||||
@@ -1042,6 +1079,154 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.HasIndex(x => x.TenantId).IsUnique();
|
builder.HasIndex(x => x.TenantId).IsUnique();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConfigureTenantInvoiceSetting(EntityTypeBuilder<TenantInvoiceSetting> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("finance_invoice_settings");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.TenantId).IsRequired();
|
||||||
|
builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
|
||||||
|
builder.Property(x => x.TaxpayerNumber).HasMaxLength(64).IsRequired();
|
||||||
|
builder.Property(x => x.RegisteredAddress).HasMaxLength(256);
|
||||||
|
builder.Property(x => x.RegisteredPhone).HasMaxLength(32);
|
||||||
|
builder.Property(x => x.BankName).HasMaxLength(128);
|
||||||
|
builder.Property(x => x.BankAccount).HasMaxLength(64);
|
||||||
|
builder.Property(x => x.EnableElectronicNormalInvoice).IsRequired();
|
||||||
|
builder.Property(x => x.EnableElectronicSpecialInvoice).IsRequired();
|
||||||
|
builder.Property(x => x.EnableAutoIssue).IsRequired();
|
||||||
|
builder.Property(x => x.AutoIssueMaxAmount).HasPrecision(18, 2).IsRequired();
|
||||||
|
|
||||||
|
builder.HasIndex(x => x.TenantId).IsUnique();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureTenantInvoiceRecord(EntityTypeBuilder<TenantInvoiceRecord> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("finance_invoice_records");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.TenantId).IsRequired();
|
||||||
|
builder.Property(x => x.InvoiceNo).HasMaxLength(32).IsRequired();
|
||||||
|
builder.Property(x => x.ApplicantName).HasMaxLength(64).IsRequired();
|
||||||
|
builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
|
||||||
|
builder.Property(x => x.TaxpayerNumber).HasMaxLength(64);
|
||||||
|
builder.Property(x => x.InvoiceType).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
|
||||||
|
builder.Property(x => x.OrderNo).HasMaxLength(32).IsRequired();
|
||||||
|
builder.Property(x => x.ContactEmail).HasMaxLength(128);
|
||||||
|
builder.Property(x => x.ContactPhone).HasMaxLength(32);
|
||||||
|
builder.Property(x => x.ApplyRemark).HasMaxLength(256);
|
||||||
|
builder.Property(x => x.Status).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.AppliedAt).IsRequired();
|
||||||
|
builder.Property(x => x.IssueRemark).HasMaxLength(256);
|
||||||
|
builder.Property(x => x.VoidReason).HasMaxLength(256);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.InvoiceNo }).IsUnique();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.OrderNo });
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.Status, x.AppliedAt });
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.Status, x.IssuedAt });
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureFinanceBusinessReportSnapshot(EntityTypeBuilder<FinanceBusinessReportSnapshot> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("finance_business_report_snapshots");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.TenantId).IsRequired();
|
||||||
|
builder.Property(x => x.StoreId).IsRequired();
|
||||||
|
builder.Property(x => x.PeriodType).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.PeriodStartAt).IsRequired();
|
||||||
|
builder.Property(x => x.PeriodEndAt).IsRequired();
|
||||||
|
builder.Property(x => x.Status).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.RevenueAmount).HasPrecision(18, 2);
|
||||||
|
builder.Property(x => x.OrderCount).IsRequired();
|
||||||
|
builder.Property(x => x.AverageOrderValue).HasPrecision(18, 2);
|
||||||
|
builder.Property(x => x.RefundRate).HasPrecision(9, 4);
|
||||||
|
builder.Property(x => x.CostTotalAmount).HasPrecision(18, 2);
|
||||||
|
builder.Property(x => x.NetProfitAmount).HasPrecision(18, 2);
|
||||||
|
builder.Property(x => x.ProfitRate).HasPrecision(9, 4);
|
||||||
|
builder.Property(x => x.KpiComparisonJson).HasColumnType("text").IsRequired();
|
||||||
|
builder.Property(x => x.IncomeBreakdownJson).HasColumnType("text").IsRequired();
|
||||||
|
builder.Property(x => x.CostBreakdownJson).HasColumnType("text").IsRequired();
|
||||||
|
builder.Property(x => x.LastError).HasMaxLength(1024);
|
||||||
|
builder.Property(x => x.HangfireJobId).HasMaxLength(64);
|
||||||
|
builder.Property(x => x.RetryCount).HasDefaultValue(0);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.PeriodStartAt }).IsUnique();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.Status, x.PeriodStartAt });
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.Status, x.CreatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureFinanceCostProfile(EntityTypeBuilder<FinanceCostProfile> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("finance_cost_profiles");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.TenantId).IsRequired();
|
||||||
|
builder.Property(x => x.StoreId).IsRequired();
|
||||||
|
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.CalcMode).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.Ratio).HasPrecision(9, 6).IsRequired();
|
||||||
|
builder.Property(x => x.FixedDailyAmount).HasPrecision(18, 2).IsRequired();
|
||||||
|
builder.Property(x => x.EffectiveFrom).IsRequired();
|
||||||
|
builder.Property(x => x.EffectiveTo);
|
||||||
|
builder.Property(x => x.IsEnabled).IsRequired();
|
||||||
|
builder.Property(x => x.SortOrder).HasDefaultValue(100);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Category, x.EffectiveFrom, x.EffectiveTo });
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.IsEnabled, x.SortOrder });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureFinanceCostDailyOverride(EntityTypeBuilder<FinanceCostDailyOverride> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("finance_cost_daily_overrides");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.TenantId).IsRequired();
|
||||||
|
builder.Property(x => x.StoreId).IsRequired();
|
||||||
|
builder.Property(x => x.BusinessDate).IsRequired();
|
||||||
|
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
|
||||||
|
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
|
||||||
|
builder.Property(x => x.Remark).HasMaxLength(256);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate, x.Category }).IsUnique();
|
||||||
|
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate });
|
||||||
|
}
|
||||||
|
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)
|
private static void ConfigureTenantAnnouncement(EntityTypeBuilder<TenantAnnouncement> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("tenant_announcements");
|
builder.ToTable("tenant_announcements");
|
||||||
@@ -2262,4 +2447,3 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
|
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
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.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 经营报表 EF Core 仓储实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfFinanceBusinessReportRepository(
|
||||||
|
TakeoutAppDbContext context,
|
||||||
|
ITenantContextAccessor tenantContextAccessor) : IFinanceBusinessReportRepository
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
private static readonly FinanceCostCategory[] CostCategoryOrder =
|
||||||
|
[
|
||||||
|
FinanceCostCategory.FoodMaterial,
|
||||||
|
FinanceCostCategory.Labor,
|
||||||
|
FinanceCostCategory.FixedExpense,
|
||||||
|
FinanceCostCategory.PackagingConsumable
|
||||||
|
];
|
||||||
|
private static readonly DeliveryType[] IncomeChannelOrder =
|
||||||
|
[
|
||||||
|
DeliveryType.Delivery,
|
||||||
|
DeliveryType.Pickup,
|
||||||
|
DeliveryType.DineIn
|
||||||
|
];
|
||||||
|
private static readonly IReadOnlyDictionary<FinanceCostCategory, (FinanceCostCalcMode Mode, decimal Ratio, decimal Fixed)> DefaultCostProfileMap =
|
||||||
|
new Dictionary<FinanceCostCategory, (FinanceCostCalcMode, decimal, decimal)>
|
||||||
|
{
|
||||||
|
[FinanceCostCategory.FoodMaterial] = (FinanceCostCalcMode.Ratio, 0.36m, 0m),
|
||||||
|
[FinanceCostCategory.Labor] = (FinanceCostCalcMode.Ratio, 0.19m, 0m),
|
||||||
|
[FinanceCostCategory.FixedExpense] = (FinanceCostCalcMode.FixedDaily, 0m, 190m),
|
||||||
|
[FinanceCostCategory.PackagingConsumable] = (FinanceCostCalcMode.Ratio, 0.04m, 0m)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task EnsureDefaultCostProfilesAsync(long tenantId, long storeId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (tenantId <= 0 || storeId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await context.FinanceCostProfiles
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null)
|
||||||
|
.Select(item => item.Category)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var missing = CostCategoryOrder.Where(item => !existing.Contains(item)).ToList();
|
||||||
|
if (missing.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveFrom = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var entities = missing.Select((category, index) =>
|
||||||
|
{
|
||||||
|
var profile = DefaultCostProfileMap[category];
|
||||||
|
return new FinanceCostProfile
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
StoreId = storeId,
|
||||||
|
Category = category,
|
||||||
|
CalcMode = profile.Mode,
|
||||||
|
Ratio = profile.Ratio,
|
||||||
|
FixedDailyAmount = profile.Fixed,
|
||||||
|
EffectiveFrom = effectiveFrom,
|
||||||
|
IsEnabled = true,
|
||||||
|
SortOrder = (index + 1) * 10
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await context.FinanceCostProfiles.AddRangeAsync(entities, cancellationToken);
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task QueueSnapshotsForPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (tenantId <= 0 || storeId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var periods = BuildPagedPeriods(periodType, page, pageSize, now);
|
||||||
|
var starts = periods.Select(item => item.StartAt).ToHashSet();
|
||||||
|
var existing = await context.FinanceBusinessReportSnapshots
|
||||||
|
.Where(item =>
|
||||||
|
item.TenantId == tenantId &&
|
||||||
|
item.StoreId == storeId &&
|
||||||
|
item.PeriodType == periodType &&
|
||||||
|
item.DeletedAt == null &&
|
||||||
|
starts.Contains(item.PeriodStartAt))
|
||||||
|
.OrderByDescending(item => item.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var map = existing.GroupBy(item => item.PeriodStartAt).ToDictionary(group => group.Key, group => group.First());
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
foreach (var period in periods)
|
||||||
|
{
|
||||||
|
if (!map.TryGetValue(period.StartAt, out var snapshot))
|
||||||
|
{
|
||||||
|
await context.FinanceBusinessReportSnapshots.AddAsync(new FinanceBusinessReportSnapshot
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
StoreId = storeId,
|
||||||
|
PeriodType = periodType,
|
||||||
|
PeriodStartAt = period.StartAt,
|
||||||
|
PeriodEndAt = period.EndAt,
|
||||||
|
Status = FinanceBusinessReportStatus.Queued
|
||||||
|
}, cancellationToken);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.PeriodEndAt != period.EndAt)
|
||||||
|
{
|
||||||
|
snapshot.PeriodEndAt = period.EndAt;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.Status == FinanceBusinessReportStatus.Failed && snapshot.RetryCount < 5)
|
||||||
|
{
|
||||||
|
snapshot.Status = FinanceBusinessReportStatus.Queued;
|
||||||
|
snapshot.LastError = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= period.StartAt
|
||||||
|
&& now < period.EndAt
|
||||||
|
&& snapshot.Status == FinanceBusinessReportStatus.Succeeded
|
||||||
|
&& (!snapshot.FinishedAt.HasValue || snapshot.FinishedAt.Value.AddMinutes(30) <= now))
|
||||||
|
{
|
||||||
|
snapshot.Status = FinanceBusinessReportStatus.Queued;
|
||||||
|
snapshot.StartedAt = null;
|
||||||
|
snapshot.FinishedAt = null;
|
||||||
|
snapshot.LastError = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedPage = Math.Max(1, page);
|
||||||
|
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||||
|
var query = context.FinanceBusinessReportSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.TenantId == tenantId &&
|
||||||
|
item.StoreId == storeId &&
|
||||||
|
item.PeriodType == periodType &&
|
||||||
|
item.DeletedAt == null);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
if (totalCount == 0)
|
||||||
|
{
|
||||||
|
return new FinanceBusinessReportPageSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(item => item.PeriodStartAt)
|
||||||
|
.ThenByDescending(item => item.Id)
|
||||||
|
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||||
|
.Take(normalizedPageSize)
|
||||||
|
.Select(item => new FinanceBusinessReportListItemSnapshot
|
||||||
|
{
|
||||||
|
ReportId = item.Id,
|
||||||
|
PeriodType = item.PeriodType,
|
||||||
|
PeriodStartAt = item.PeriodStartAt,
|
||||||
|
PeriodEndAt = item.PeriodEndAt,
|
||||||
|
Status = item.Status,
|
||||||
|
RevenueAmount = item.RevenueAmount,
|
||||||
|
OrderCount = item.OrderCount,
|
||||||
|
AverageOrderValue = item.AverageOrderValue,
|
||||||
|
RefundRate = item.RefundRate,
|
||||||
|
CostTotalAmount = item.CostTotalAmount,
|
||||||
|
NetProfitAmount = item.NetProfitAmount,
|
||||||
|
ProfitRate = item.ProfitRate
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceBusinessReportPageSnapshot
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
TotalCount = totalCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(long tenantId, long storeId, long reportId, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var snapshot = await context.FinanceBusinessReportSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowRealtimeBuild && snapshot.Status != FinanceBusinessReportStatus.Succeeded)
|
||||||
|
{
|
||||||
|
await GenerateSnapshotAsync(reportId, cancellationToken);
|
||||||
|
snapshot = await context.FinanceBusinessReportSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FinanceBusinessReportDetailSnapshot
|
||||||
|
{
|
||||||
|
ReportId = snapshot.Id,
|
||||||
|
StoreId = snapshot.StoreId,
|
||||||
|
PeriodType = snapshot.PeriodType,
|
||||||
|
PeriodStartAt = snapshot.PeriodStartAt,
|
||||||
|
PeriodEndAt = snapshot.PeriodEndAt,
|
||||||
|
Status = snapshot.Status,
|
||||||
|
RevenueAmount = snapshot.RevenueAmount,
|
||||||
|
OrderCount = snapshot.OrderCount,
|
||||||
|
AverageOrderValue = snapshot.AverageOrderValue,
|
||||||
|
RefundRate = snapshot.RefundRate,
|
||||||
|
CostTotalAmount = snapshot.CostTotalAmount,
|
||||||
|
NetProfitAmount = snapshot.NetProfitAmount,
|
||||||
|
ProfitRate = snapshot.ProfitRate,
|
||||||
|
Kpis = Deserialize<FinanceBusinessReportKpiSnapshot>(snapshot.KpiComparisonJson),
|
||||||
|
IncomeBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.IncomeBreakdownJson),
|
||||||
|
CostBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.CostBreakdownJson)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedPage = Math.Max(1, page);
|
||||||
|
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||||
|
var reportIds = await context.FinanceBusinessReportSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.PeriodType == periodType && item.DeletedAt == null)
|
||||||
|
.OrderByDescending(item => item.PeriodStartAt)
|
||||||
|
.ThenByDescending(item => item.Id)
|
||||||
|
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||||
|
.Take(normalizedPageSize)
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var list = new List<FinanceBusinessReportDetailSnapshot>(reportIds.Count);
|
||||||
|
foreach (var reportId in reportIds)
|
||||||
|
{
|
||||||
|
var detail = await GetDetailAsync(tenantId, storeId, reportId, allowRealtimeBuild, cancellationToken);
|
||||||
|
if (detail is not null)
|
||||||
|
{
|
||||||
|
list.Add(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedTake = Math.Clamp(take, 1, 200);
|
||||||
|
if ((tenantContextAccessor.Current?.TenantId ?? 0) != 0)
|
||||||
|
{
|
||||||
|
return await QueryPendingAsync(normalizedTake, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantIds = await context.Tenants.AsNoTracking().Where(item => item.DeletedAt == null && item.Id > 0).Select(item => item.Id).ToListAsync(cancellationToken);
|
||||||
|
var pending = new List<(long SnapshotId, long TenantId, DateTime CreatedAt)>();
|
||||||
|
foreach (var tenantId in tenantIds)
|
||||||
|
{
|
||||||
|
using (tenantContextAccessor.EnterTenantScope(tenantId, "finance-report"))
|
||||||
|
{
|
||||||
|
var rows = await context.FinanceBusinessReportSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.DeletedAt == null &&
|
||||||
|
(item.Status == FinanceBusinessReportStatus.Queued || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
|
||||||
|
.OrderBy(item => item.CreatedAt)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.Take(normalizedTake)
|
||||||
|
.Select(item => new { item.Id, item.TenantId, item.CreatedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
pending.AddRange(rows.Select(item => (item.Id, item.TenantId, item.CreatedAt)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending.OrderBy(item => item.CreatedAt).ThenBy(item => item.SnapshotId).Take(normalizedTake).Select(item => new FinanceBusinessReportPendingSnapshot { SnapshotId = item.SnapshotId, TenantId = item.TenantId }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task GenerateSnapshotAsync(long snapshotId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var snapshot = await context.FinanceBusinessReportSnapshots.FirstOrDefaultAsync(item => item.Id == snapshotId && item.DeletedAt == null, cancellationToken);
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.Status == FinanceBusinessReportStatus.Running
|
||||||
|
&& snapshot.StartedAt.HasValue
|
||||||
|
&& snapshot.StartedAt.Value.AddMinutes(10) > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.Status = FinanceBusinessReportStatus.Running;
|
||||||
|
snapshot.StartedAt = DateTime.UtcNow;
|
||||||
|
snapshot.LastError = null;
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureDefaultCostProfilesAsync(snapshot.TenantId, snapshot.StoreId, cancellationToken);
|
||||||
|
var report = await BuildComputedSnapshotAsync(snapshot.TenantId, snapshot.StoreId, snapshot.PeriodType, snapshot.PeriodStartAt, snapshot.PeriodEndAt, cancellationToken);
|
||||||
|
snapshot.RevenueAmount = report.RevenueAmount;
|
||||||
|
snapshot.OrderCount = report.OrderCount;
|
||||||
|
snapshot.AverageOrderValue = report.AverageOrderValue;
|
||||||
|
snapshot.RefundRate = report.RefundRate;
|
||||||
|
snapshot.CostTotalAmount = report.CostTotalAmount;
|
||||||
|
snapshot.NetProfitAmount = report.NetProfitAmount;
|
||||||
|
snapshot.ProfitRate = report.ProfitRate;
|
||||||
|
snapshot.KpiComparisonJson = JsonSerializer.Serialize(report.Kpis, JsonOptions);
|
||||||
|
snapshot.IncomeBreakdownJson = JsonSerializer.Serialize(report.IncomeBreakdowns, JsonOptions);
|
||||||
|
snapshot.CostBreakdownJson = JsonSerializer.Serialize(report.CostBreakdowns, JsonOptions);
|
||||||
|
snapshot.Status = FinanceBusinessReportStatus.Succeeded;
|
||||||
|
snapshot.FinishedAt = DateTime.UtcNow;
|
||||||
|
snapshot.LastError = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
snapshot.Status = FinanceBusinessReportStatus.Failed;
|
||||||
|
snapshot.FinishedAt = DateTime.UtcNow;
|
||||||
|
snapshot.RetryCount += 1;
|
||||||
|
snapshot.LastError = ex.Message[..Math.Min(1024, ex.Message.Length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> QueryPendingAsync(int take, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await context.FinanceBusinessReportSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
item.DeletedAt == null &&
|
||||||
|
(item.Status == FinanceBusinessReportStatus.Queued
|
||||||
|
|| (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
|
||||||
|
.OrderBy(item => item.CreatedAt)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.Take(take)
|
||||||
|
.Select(item => new FinanceBusinessReportPendingSnapshot
|
||||||
|
{
|
||||||
|
SnapshotId = item.Id,
|
||||||
|
TenantId = item.TenantId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ComputedReportSnapshot> BuildComputedSnapshotAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
FinanceBusinessReportPeriodType periodType,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await BuildRawMetricsAsync(tenantId, storeId, startAt, endAt, cancellationToken);
|
||||||
|
var previous = ResolvePreviousPeriod(periodType, startAt, endAt);
|
||||||
|
var yearAgo = (startAt.AddYears(-1), endAt.AddYears(-1));
|
||||||
|
var mom = await BuildRawMetricsAsync(tenantId, storeId, previous.StartAt, previous.EndAt, cancellationToken);
|
||||||
|
var yoy = await BuildRawMetricsAsync(tenantId, storeId, yearAgo.Item1, yearAgo.Item2, cancellationToken);
|
||||||
|
|
||||||
|
return current with
|
||||||
|
{
|
||||||
|
Kpis = BuildKpis(current, mom, yoy)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ComputedReportSnapshot> BuildRawMetricsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var summary = await QueryRevenueSummaryAsync(tenantId, storeId, startAt, endAt, cancellationToken);
|
||||||
|
var averageOrderValue = summary.OrderCount <= 0 ? 0m : RoundMoney(summary.RevenueAmount / summary.OrderCount);
|
||||||
|
var refundRate = summary.OrderCount <= 0 ? 0m : RoundRatio((decimal)summary.RefundOrderCount / summary.OrderCount);
|
||||||
|
var incomeBreakdowns = await QueryIncomeBreakdownsAsync(tenantId, storeId, startAt, endAt, summary.RevenueAmount, cancellationToken);
|
||||||
|
var dailyRevenueMap = await QueryDailyRevenueMapAsync(tenantId, storeId, startAt, endAt, cancellationToken);
|
||||||
|
var costBreakdowns = await BuildCostBreakdownsAsync(tenantId, storeId, startAt, endAt, dailyRevenueMap, cancellationToken);
|
||||||
|
var costTotalAmount = RoundMoney(costBreakdowns.Sum(item => item.Amount));
|
||||||
|
var netProfitAmount = RoundMoney(summary.RevenueAmount - costTotalAmount);
|
||||||
|
var profitRate = summary.RevenueAmount <= 0 ? 0m : RoundRatio(netProfitAmount / summary.RevenueAmount);
|
||||||
|
|
||||||
|
return new ComputedReportSnapshot
|
||||||
|
{
|
||||||
|
RevenueAmount = summary.RevenueAmount,
|
||||||
|
OrderCount = summary.OrderCount,
|
||||||
|
AverageOrderValue = averageOrderValue,
|
||||||
|
RefundRate = refundRate,
|
||||||
|
CostTotalAmount = costTotalAmount,
|
||||||
|
NetProfitAmount = netProfitAmount,
|
||||||
|
ProfitRate = profitRate,
|
||||||
|
Kpis = [],
|
||||||
|
IncomeBreakdowns = incomeBreakdowns,
|
||||||
|
CostBreakdowns = costBreakdowns
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(decimal RevenueAmount, int OrderCount, int RefundOrderCount)> QueryRevenueSummaryAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var paidBaseQuery =
|
||||||
|
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
|
||||||
|
&& order.StoreId == storeId
|
||||||
|
&& payment.Status == PaymentStatus.Paid
|
||||||
|
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
|
||||||
|
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
|
||||||
|
select new { payment.Amount, payment.OrderId };
|
||||||
|
|
||||||
|
var paidAmount = await paidBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
|
||||||
|
var orderCount = await paidBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var refundBaseQuery =
|
||||||
|
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
|
||||||
|
&& order.StoreId == storeId
|
||||||
|
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||||
|
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
|
||||||
|
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
|
||||||
|
select new { refund.Amount, refund.OrderId };
|
||||||
|
|
||||||
|
var refundAmount = await refundBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
|
||||||
|
var refundOrderCount = await refundBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
|
||||||
|
return (RoundMoney(paidAmount - refundAmount), orderCount, refundOrderCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyDictionary<DateTime, decimal>> QueryDailyRevenueMapAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var paidRows = await (
|
||||||
|
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
|
||||||
|
&& order.StoreId == storeId
|
||||||
|
&& payment.Status == PaymentStatus.Paid
|
||||||
|
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
|
||||||
|
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
|
||||||
|
group payment by (payment.PaidAt ?? payment.CreatedAt).Date into grouped
|
||||||
|
select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var refundRows = await (
|
||||||
|
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
|
||||||
|
&& order.StoreId == storeId
|
||||||
|
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||||
|
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
|
||||||
|
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
|
||||||
|
group refund by (refund.CompletedAt ?? refund.RequestedAt).Date into grouped
|
||||||
|
select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var map = new Dictionary<DateTime, decimal>();
|
||||||
|
foreach (var row in paidRows)
|
||||||
|
{
|
||||||
|
var date = ToUtcDate(row.BusinessDate);
|
||||||
|
map[date] = map.GetValueOrDefault(date, 0m) + row.Amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in refundRows)
|
||||||
|
{
|
||||||
|
var date = ToUtcDate(row.BusinessDate);
|
||||||
|
map[date] = map.GetValueOrDefault(date, 0m) - row.Amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map.ToDictionary(item => item.Key, item => RoundMoney(item.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<FinanceBusinessReportBreakdownSnapshot>> QueryIncomeBreakdownsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt,
|
||||||
|
decimal totalRevenue,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var paidRows = await (
|
||||||
|
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
|
||||||
|
&& order.StoreId == storeId
|
||||||
|
&& payment.Status == PaymentStatus.Paid
|
||||||
|
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
|
||||||
|
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
|
||||||
|
group payment by order.DeliveryType into grouped
|
||||||
|
select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var refundRows = await (
|
||||||
|
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
|
||||||
|
&& order.StoreId == storeId
|
||||||
|
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||||
|
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
|
||||||
|
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
|
||||||
|
group refund by order.DeliveryType into grouped
|
||||||
|
select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var paidMap = paidRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
|
||||||
|
var refundMap = refundRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
|
||||||
|
|
||||||
|
return IncomeChannelOrder.Select(channel =>
|
||||||
|
{
|
||||||
|
var amount = paidMap.GetValueOrDefault(channel, 0m) - refundMap.GetValueOrDefault(channel, 0m);
|
||||||
|
return new FinanceBusinessReportBreakdownSnapshot
|
||||||
|
{
|
||||||
|
Key = channel switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => "delivery",
|
||||||
|
DeliveryType.Pickup => "pickup",
|
||||||
|
DeliveryType.DineIn => "dine_in",
|
||||||
|
_ => "delivery"
|
||||||
|
},
|
||||||
|
Label = channel switch
|
||||||
|
{
|
||||||
|
DeliveryType.Delivery => "外卖",
|
||||||
|
DeliveryType.Pickup => "自提",
|
||||||
|
DeliveryType.DineIn => "堂食",
|
||||||
|
_ => "外卖"
|
||||||
|
},
|
||||||
|
Amount = RoundMoney(amount),
|
||||||
|
Ratio = totalRevenue <= 0 ? 0m : RoundRatio(amount / totalRevenue)
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<FinanceBusinessReportBreakdownSnapshot>> BuildCostBreakdownsAsync(
|
||||||
|
long tenantId,
|
||||||
|
long storeId,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt,
|
||||||
|
IReadOnlyDictionary<DateTime, decimal> dailyRevenueMap,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profiles = await context.FinanceCostProfiles.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.IsEnabled)
|
||||||
|
.OrderBy(item => item.SortOrder).ThenByDescending(item => item.EffectiveFrom).ToListAsync(cancellationToken);
|
||||||
|
var overrides = await context.FinanceCostDailyOverrides.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.BusinessDate >= startAt.Date && item.BusinessDate < endAt.Date)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var overrideMap = overrides.ToDictionary(item => $"{item.BusinessDate:yyyyMMdd}:{(int)item.Category}", item => item.Amount);
|
||||||
|
var categoryAmountMap = CostCategoryOrder.ToDictionary(item => item, _ => 0m);
|
||||||
|
|
||||||
|
for (var businessDay = startAt.Date; businessDay < endAt.Date; businessDay = businessDay.AddDays(1))
|
||||||
|
{
|
||||||
|
var dayRevenue = dailyRevenueMap.GetValueOrDefault(ToUtcDate(businessDay), 0m);
|
||||||
|
foreach (var category in CostCategoryOrder)
|
||||||
|
{
|
||||||
|
var key = $"{businessDay:yyyyMMdd}:{(int)category}";
|
||||||
|
decimal amount;
|
||||||
|
if (overrideMap.TryGetValue(key, out var overrideAmount))
|
||||||
|
{
|
||||||
|
amount = overrideAmount;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var profile = profiles.FirstOrDefault(item =>
|
||||||
|
item.Category == category &&
|
||||||
|
item.EffectiveFrom.Date <= businessDay &&
|
||||||
|
(!item.EffectiveTo.HasValue || item.EffectiveTo.Value.Date >= businessDay));
|
||||||
|
var defaults = DefaultCostProfileMap[category];
|
||||||
|
var mode = profile?.CalcMode ?? defaults.Mode;
|
||||||
|
var ratio = profile?.Ratio ?? defaults.Ratio;
|
||||||
|
var fixedDaily = profile?.FixedDailyAmount ?? defaults.Fixed;
|
||||||
|
amount = mode == FinanceCostCalcMode.FixedDaily ? fixedDaily : dayRevenue * Math.Max(0m, ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryAmountMap[category] += RoundMoney(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCostAmount = categoryAmountMap.Sum(item => item.Value);
|
||||||
|
return CostCategoryOrder.Select(category => new FinanceBusinessReportBreakdownSnapshot
|
||||||
|
{
|
||||||
|
Key = category switch
|
||||||
|
{
|
||||||
|
FinanceCostCategory.FoodMaterial => "food_material",
|
||||||
|
FinanceCostCategory.Labor => "labor",
|
||||||
|
FinanceCostCategory.FixedExpense => "fixed_expense",
|
||||||
|
FinanceCostCategory.PackagingConsumable => "packaging_consumable",
|
||||||
|
_ => "food_material"
|
||||||
|
},
|
||||||
|
Label = category switch
|
||||||
|
{
|
||||||
|
FinanceCostCategory.FoodMaterial => "食材成本",
|
||||||
|
FinanceCostCategory.Labor => "人工成本",
|
||||||
|
FinanceCostCategory.FixedExpense => "固定成本",
|
||||||
|
FinanceCostCategory.PackagingConsumable => "包装成本",
|
||||||
|
_ => "食材成本"
|
||||||
|
},
|
||||||
|
Amount = RoundMoney(categoryAmountMap[category]),
|
||||||
|
Ratio = totalCostAmount <= 0m ? 0m : RoundRatio(categoryAmountMap[category] / totalCostAmount)
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FinanceBusinessReportKpiSnapshot> BuildKpis(ComputedReportSnapshot current, ComputedReportSnapshot mom, ComputedReportSnapshot yoy)
|
||||||
|
{
|
||||||
|
var definitions = new List<(string Key, string Label, decimal Current, decimal PrevMom, decimal PrevYoy)>
|
||||||
|
{
|
||||||
|
("revenue", "营业额", current.RevenueAmount, mom.RevenueAmount, yoy.RevenueAmount),
|
||||||
|
("order_count", "订单数", current.OrderCount, mom.OrderCount, yoy.OrderCount),
|
||||||
|
("average_order_value", "客单价", current.AverageOrderValue, mom.AverageOrderValue, yoy.AverageOrderValue),
|
||||||
|
("refund_rate", "退款率", current.RefundRate, mom.RefundRate, yoy.RefundRate),
|
||||||
|
("net_profit", "净利润", current.NetProfitAmount, mom.NetProfitAmount, yoy.NetProfitAmount),
|
||||||
|
("profit_rate", "利润率", current.ProfitRate, mom.ProfitRate, yoy.ProfitRate)
|
||||||
|
};
|
||||||
|
|
||||||
|
return definitions.Select(item => new FinanceBusinessReportKpiSnapshot
|
||||||
|
{
|
||||||
|
Key = item.Key,
|
||||||
|
Label = item.Label,
|
||||||
|
Value = item.Current,
|
||||||
|
MomChangeRate = CalculateChangeRate(item.Current, item.PrevMom),
|
||||||
|
YoyChangeRate = CalculateChangeRate(item.Current, item.PrevYoy)
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DateTime StartAt, DateTime EndAt) ResolvePreviousPeriod(FinanceBusinessReportPeriodType periodType, DateTime startAt, DateTime endAt)
|
||||||
|
{
|
||||||
|
return periodType switch
|
||||||
|
{
|
||||||
|
FinanceBusinessReportPeriodType.Daily => (startAt.AddDays(-1), endAt.AddDays(-1)),
|
||||||
|
FinanceBusinessReportPeriodType.Weekly => (startAt.AddDays(-7), endAt.AddDays(-7)),
|
||||||
|
FinanceBusinessReportPeriodType.Monthly => (startAt.AddMonths(-1), endAt.AddMonths(-1)),
|
||||||
|
_ => (startAt.AddDays(-1), endAt.AddDays(-1))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(DateTime StartAt, DateTime EndAt)> BuildPagedPeriods(FinanceBusinessReportPeriodType periodType, int page, int pageSize, DateTime now)
|
||||||
|
{
|
||||||
|
var normalizedPage = Math.Max(1, page);
|
||||||
|
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||||
|
var offsetStart = (normalizedPage - 1) * normalizedPageSize;
|
||||||
|
var today = ToUtcDate(now);
|
||||||
|
var list = new List<(DateTime StartAt, DateTime EndAt)>(normalizedPageSize);
|
||||||
|
for (var index = 0; index < normalizedPageSize; index++)
|
||||||
|
{
|
||||||
|
var offset = offsetStart + index;
|
||||||
|
if (periodType == FinanceBusinessReportPeriodType.Weekly)
|
||||||
|
{
|
||||||
|
var weekStart = GetWeekStart(today).AddDays(-7 * offset);
|
||||||
|
list.Add((weekStart, weekStart.AddDays(7)));
|
||||||
|
}
|
||||||
|
else if (periodType == FinanceBusinessReportPeriodType.Monthly)
|
||||||
|
{
|
||||||
|
var monthStart = new DateTime(today.Year, today.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-offset);
|
||||||
|
list.Add((monthStart, monthStart.AddMonths(1)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dayStart = today.AddDays(-offset);
|
||||||
|
list.Add((dayStart, dayStart.AddDays(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TItem> Deserialize<TItem>(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<TItem>>(json, JsonOptions) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ComputedReportSnapshot
|
||||||
|
{
|
||||||
|
public decimal RevenueAmount { get; init; }
|
||||||
|
|
||||||
|
public int OrderCount { get; init; }
|
||||||
|
|
||||||
|
public decimal AverageOrderValue { get; init; }
|
||||||
|
|
||||||
|
public decimal RefundRate { get; init; }
|
||||||
|
|
||||||
|
public decimal CostTotalAmount { get; init; }
|
||||||
|
|
||||||
|
public decimal NetProfitAmount { get; init; }
|
||||||
|
|
||||||
|
public decimal ProfitRate { get; init; }
|
||||||
|
|
||||||
|
public List<FinanceBusinessReportKpiSnapshot> Kpis { get; init; } = [];
|
||||||
|
|
||||||
|
public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; init; } = [];
|
||||||
|
|
||||||
|
public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateChangeRate(decimal currentValue, decimal previousValue) => previousValue <= 0m ? (currentValue <= 0m ? 0m : 100m) : RoundRate((currentValue - previousValue) / previousValue * 100m);
|
||||||
|
private static decimal RoundMoney(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
private static decimal RoundRate(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
private static decimal RoundRatio(decimal value) => decimal.Round(value, 4, MidpointRounding.AwayFromZero);
|
||||||
|
private static DateTime ToUtcDate(DateTime value) => new(value.Year, value.Month, value.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static DateTime GetWeekStart(DateTime date) => date.AddDays(0 - (((int)date.DayOfWeek + 6) % 7));
|
||||||
|
}
|
||||||
@@ -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,615 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Orders.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 财务概览 EF Core 仓储实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfFinanceOverviewRepository(TakeoutAppDbContext context) : IFinanceOverviewRepository
|
||||||
|
{
|
||||||
|
private const int TrendDays = 30;
|
||||||
|
private const int TopProductDays = 30;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
|
||||||
|
long tenantId,
|
||||||
|
FinanceCostDimension dimension,
|
||||||
|
long? storeId,
|
||||||
|
DateTime currentUtc,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var utcNow = NormalizeUtc(currentUtc);
|
||||||
|
var todayStart = NormalizeDate(utcNow);
|
||||||
|
var tomorrowStart = todayStart.AddDays(1);
|
||||||
|
var yesterdayStart = todayStart.AddDays(-1);
|
||||||
|
var trendStart = todayStart.AddDays(0 - TrendDays + 1);
|
||||||
|
var weekStart = todayStart.AddDays(-7);
|
||||||
|
|
||||||
|
// 1. 查询支付与退款基础数据。
|
||||||
|
var paidQuery = BuildPaidRecordQuery(tenantId, dimension, storeId);
|
||||||
|
var refundQuery = BuildRefundRecordQuery(tenantId, dimension, storeId);
|
||||||
|
|
||||||
|
// 2. 读取近 30 天按日按门店支付与退款汇总。
|
||||||
|
var paidDailyRows = await paidQuery
|
||||||
|
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
|
||||||
|
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
|
||||||
|
.Select(group => new DailyStoreAmountRow
|
||||||
|
{
|
||||||
|
BusinessDate = group.Key.Date,
|
||||||
|
StoreId = group.Key.StoreId,
|
||||||
|
Amount = group.Sum(item => item.Amount)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var refundDailyRows = await refundQuery
|
||||||
|
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
|
||||||
|
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
|
||||||
|
.Select(group => new DailyStoreAmountRow
|
||||||
|
{
|
||||||
|
BusinessDate = group.Key.Date,
|
||||||
|
StoreId = group.Key.StoreId,
|
||||||
|
Amount = group.Sum(item => item.Amount)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 3. 读取今日收入构成(渠道维度)。
|
||||||
|
var paidTodayByChannel = await paidQuery
|
||||||
|
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
|
||||||
|
.GroupBy(item => item.DeliveryType)
|
||||||
|
.Select(group => new DailyChannelAmountRow
|
||||||
|
{
|
||||||
|
Channel = group.Key,
|
||||||
|
Amount = group.Sum(item => item.Amount)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var refundTodayByChannel = await refundQuery
|
||||||
|
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
|
||||||
|
.GroupBy(item => item.DeliveryType)
|
||||||
|
.Select(group => new DailyChannelAmountRow
|
||||||
|
{
|
||||||
|
Channel = group.Key,
|
||||||
|
Amount = group.Sum(item => item.Amount)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 4. 读取作用域门店平台费率。
|
||||||
|
var scopedStoreIds = paidDailyRows
|
||||||
|
.Select(item => item.StoreId)
|
||||||
|
.Concat(refundDailyRows.Select(item => item.StoreId))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (dimension == FinanceCostDimension.Store && storeId.HasValue && !scopedStoreIds.Contains(storeId.Value))
|
||||||
|
{
|
||||||
|
scopedStoreIds.Add(storeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformRateMap = scopedStoreIds.Count == 0
|
||||||
|
? new Dictionary<long, decimal>()
|
||||||
|
: await context.StoreFees
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && scopedStoreIds.Contains(item.StoreId))
|
||||||
|
.Select(item => new { item.StoreId, item.PlatformServiceRate })
|
||||||
|
.ToDictionaryAsync(item => item.StoreId, item => item.PlatformServiceRate, cancellationToken);
|
||||||
|
|
||||||
|
// 5. 近 30 天按日收入/退款/平台成本汇总。
|
||||||
|
var grossByDate = new Dictionary<DateTime, decimal>();
|
||||||
|
var refundByDate = new Dictionary<DateTime, decimal>();
|
||||||
|
var platformCostByDate = new Dictionary<DateTime, decimal>();
|
||||||
|
|
||||||
|
foreach (var row in paidDailyRows)
|
||||||
|
{
|
||||||
|
var businessDate = NormalizeDate(row.BusinessDate);
|
||||||
|
AddAmount(grossByDate, businessDate, row.Amount);
|
||||||
|
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
|
||||||
|
AddAmount(platformCostByDate, businessDate, row.Amount * platformRate / 100m);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in refundDailyRows)
|
||||||
|
{
|
||||||
|
var businessDate = NormalizeDate(row.BusinessDate);
|
||||||
|
AddAmount(refundByDate, businessDate, row.Amount);
|
||||||
|
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
|
||||||
|
AddAmount(platformCostByDate, businessDate, 0m - row.Amount * platformRate / 100m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 读取近 30 天覆盖月份的月度成本录入,并折算为日成本。
|
||||||
|
var monthStarts = BuildMonthStartRange(trendStart, todayStart);
|
||||||
|
var monthlyCostRows = await BuildMonthlyCostQuery(tenantId, dimension, storeId, monthStarts)
|
||||||
|
.Select(item => new MonthlyCostRow
|
||||||
|
{
|
||||||
|
CostMonth = item.CostMonth,
|
||||||
|
Category = item.Category,
|
||||||
|
Amount = item.TotalAmount
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var monthlyCostMap = BuildMonthlyCostMap(monthlyCostRows);
|
||||||
|
|
||||||
|
// 7. 组装趋势数据与今日/昨日成本构成。
|
||||||
|
var incomeTrend = new List<FinanceOverviewIncomeTrendPointSnapshot>(TrendDays);
|
||||||
|
var profitTrend = new List<FinanceOverviewProfitTrendPointSnapshot>(TrendDays);
|
||||||
|
var todayCostComposition = CreateEmptyCostComposition();
|
||||||
|
var yesterdayCostComposition = CreateEmptyCostComposition();
|
||||||
|
|
||||||
|
for (var currentDate = trendStart; currentDate <= todayStart; currentDate = currentDate.AddDays(1))
|
||||||
|
{
|
||||||
|
var grossAmount = RoundAmount(GetAmount(grossByDate, currentDate));
|
||||||
|
var refundAmount = RoundAmount(GetAmount(refundByDate, currentDate));
|
||||||
|
var netReceived = RoundAmount(grossAmount - refundAmount);
|
||||||
|
var platformCost = RoundAmount(GetAmount(platformCostByDate, currentDate));
|
||||||
|
|
||||||
|
var baseCost = GetDailyBaseCost(currentDate, monthlyCostMap);
|
||||||
|
var totalCost = RoundAmount(baseCost.TotalCost + platformCost);
|
||||||
|
var netProfit = RoundAmount(netReceived - totalCost);
|
||||||
|
|
||||||
|
incomeTrend.Add(new FinanceOverviewIncomeTrendPointSnapshot
|
||||||
|
{
|
||||||
|
BusinessDate = currentDate,
|
||||||
|
NetReceivedAmount = netReceived
|
||||||
|
});
|
||||||
|
profitTrend.Add(new FinanceOverviewProfitTrendPointSnapshot
|
||||||
|
{
|
||||||
|
BusinessDate = currentDate,
|
||||||
|
RevenueAmount = grossAmount,
|
||||||
|
CostAmount = totalCost,
|
||||||
|
NetProfitAmount = netProfit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentDate == todayStart)
|
||||||
|
{
|
||||||
|
todayCostComposition = new DailyCostAmounts
|
||||||
|
{
|
||||||
|
Food = baseCost.Food,
|
||||||
|
Labor = baseCost.Labor,
|
||||||
|
Fixed = baseCost.Fixed,
|
||||||
|
Packaging = baseCost.Packaging,
|
||||||
|
Platform = platformCost
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate == yesterdayStart)
|
||||||
|
{
|
||||||
|
yesterdayCostComposition = new DailyCostAmounts
|
||||||
|
{
|
||||||
|
Food = baseCost.Food,
|
||||||
|
Labor = baseCost.Labor,
|
||||||
|
Fixed = baseCost.Fixed,
|
||||||
|
Packaging = baseCost.Packaging,
|
||||||
|
Platform = platformCost
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 汇总核心指标。
|
||||||
|
var todayGrossRevenue = RoundAmount(GetAmount(grossByDate, todayStart));
|
||||||
|
var yesterdayGrossRevenue = RoundAmount(GetAmount(grossByDate, yesterdayStart));
|
||||||
|
var todayRefundAmount = RoundAmount(GetAmount(refundByDate, todayStart));
|
||||||
|
var yesterdayRefundAmount = RoundAmount(GetAmount(refundByDate, yesterdayStart));
|
||||||
|
var todayNetReceived = RoundAmount(todayGrossRevenue - todayRefundAmount);
|
||||||
|
var yesterdayNetReceived = RoundAmount(yesterdayGrossRevenue - yesterdayRefundAmount);
|
||||||
|
var todayTotalCost = RoundAmount(todayCostComposition.TotalCost);
|
||||||
|
var yesterdayTotalCost = RoundAmount(yesterdayCostComposition.TotalCost);
|
||||||
|
|
||||||
|
var paidTotalAmount = await paidQuery
|
||||||
|
.Where(item => item.OccurredAt < tomorrowStart)
|
||||||
|
.Select(item => item.Amount)
|
||||||
|
.DefaultIfEmpty(0m)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
|
var refundTotalAmount = await refundQuery
|
||||||
|
.Where(item => item.OccurredAt < tomorrowStart)
|
||||||
|
.Select(item => item.Amount)
|
||||||
|
.DefaultIfEmpty(0m)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
|
var paidBeforeWeekAmount = await paidQuery
|
||||||
|
.Where(item => item.OccurredAt < weekStart)
|
||||||
|
.Select(item => item.Amount)
|
||||||
|
.DefaultIfEmpty(0m)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
|
var refundBeforeWeekAmount = await refundQuery
|
||||||
|
.Where(item => item.OccurredAt < weekStart)
|
||||||
|
.Select(item => item.Amount)
|
||||||
|
.DefaultIfEmpty(0m)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
|
|
||||||
|
var withdrawableBalance = RoundAmount(paidTotalAmount - refundTotalAmount);
|
||||||
|
var withdrawableBalanceLastWeek = RoundAmount(paidBeforeWeekAmount - refundBeforeWeekAmount);
|
||||||
|
|
||||||
|
// 9. 收入构成映射(今日)。
|
||||||
|
var paidChannelMap = paidTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
|
||||||
|
var refundChannelMap = refundTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
|
||||||
|
var incomeComposition = new List<FinanceOverviewIncomeCompositionSnapshot>(3);
|
||||||
|
foreach (var channel in new[] { DeliveryType.Delivery, DeliveryType.Pickup, DeliveryType.DineIn })
|
||||||
|
{
|
||||||
|
paidChannelMap.TryGetValue(channel, out var paidAmount);
|
||||||
|
refundChannelMap.TryGetValue(channel, out var refundAmount);
|
||||||
|
incomeComposition.Add(new FinanceOverviewIncomeCompositionSnapshot
|
||||||
|
{
|
||||||
|
Channel = channel,
|
||||||
|
Amount = RoundAmount(Math.Max(0m, paidAmount - refundAmount))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 成本构成映射(今日)。
|
||||||
|
var costComposition = new List<FinanceOverviewCostCompositionSnapshot>
|
||||||
|
{
|
||||||
|
new() { CategoryCode = "food", Amount = RoundAmount(todayCostComposition.Food) },
|
||||||
|
new() { CategoryCode = "labor", Amount = RoundAmount(todayCostComposition.Labor) },
|
||||||
|
new() { CategoryCode = "fixed", Amount = RoundAmount(todayCostComposition.Fixed) },
|
||||||
|
new() { CategoryCode = "packaging", Amount = RoundAmount(todayCostComposition.Packaging) },
|
||||||
|
new() { CategoryCode = "platform", Amount = RoundAmount(todayCostComposition.Platform) }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 11. 查询 TOP10 商品营收排行(固定近 30 天)。
|
||||||
|
var topRangeStart = todayStart.AddDays(0 - TopProductDays + 1);
|
||||||
|
var paidOrderIdsQuery = BuildPaidOrderIdsQuery(
|
||||||
|
tenantId,
|
||||||
|
dimension,
|
||||||
|
storeId,
|
||||||
|
topRangeStart,
|
||||||
|
tomorrowStart);
|
||||||
|
|
||||||
|
var topProductQuery = context.OrderItems
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && paidOrderIdsQuery.Contains(item.OrderId))
|
||||||
|
.GroupBy(item => new { item.ProductId, item.ProductName })
|
||||||
|
.Select(group => new FinanceOverviewTopProductSnapshot
|
||||||
|
{
|
||||||
|
ProductId = group.Key.ProductId,
|
||||||
|
ProductName = group.Key.ProductName,
|
||||||
|
SalesQuantity = group.Sum(item => item.Quantity),
|
||||||
|
RevenueAmount = group.Sum(item => item.SubTotal)
|
||||||
|
});
|
||||||
|
|
||||||
|
var topProductTotalRevenue = await topProductQuery
|
||||||
|
.Select(item => item.RevenueAmount)
|
||||||
|
.DefaultIfEmpty(0m)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
|
var topProducts = await topProductQuery
|
||||||
|
.OrderByDescending(item => item.RevenueAmount)
|
||||||
|
.ThenByDescending(item => item.SalesQuantity)
|
||||||
|
.Take(10)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceOverviewDashboardSnapshot
|
||||||
|
{
|
||||||
|
Dimension = dimension,
|
||||||
|
StoreId = dimension == FinanceCostDimension.Store ? storeId : null,
|
||||||
|
Summary = new FinanceOverviewSummarySnapshot
|
||||||
|
{
|
||||||
|
TodayGrossRevenue = todayGrossRevenue,
|
||||||
|
YesterdayGrossRevenue = yesterdayGrossRevenue,
|
||||||
|
TodayNetReceived = todayNetReceived,
|
||||||
|
YesterdayNetReceived = yesterdayNetReceived,
|
||||||
|
TodayRefundAmount = todayRefundAmount,
|
||||||
|
YesterdayRefundAmount = yesterdayRefundAmount,
|
||||||
|
TodayTotalCost = todayTotalCost,
|
||||||
|
YesterdayTotalCost = yesterdayTotalCost,
|
||||||
|
WithdrawableBalance = withdrawableBalance,
|
||||||
|
WithdrawableBalanceLastWeek = withdrawableBalanceLastWeek
|
||||||
|
},
|
||||||
|
IncomeTrend = incomeTrend,
|
||||||
|
ProfitTrend = profitTrend,
|
||||||
|
IncomeComposition = incomeComposition,
|
||||||
|
CostComposition = costComposition,
|
||||||
|
TopProducts = topProducts,
|
||||||
|
TopProductTotalRevenue = RoundAmount(topProductTotalRevenue)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<PaidRecordProjection> BuildPaidRecordQuery(
|
||||||
|
long tenantId,
|
||||||
|
FinanceCostDimension dimension,
|
||||||
|
long? storeId)
|
||||||
|
{
|
||||||
|
var query =
|
||||||
|
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.HasValue
|
||||||
|
select new PaidRecordProjection
|
||||||
|
{
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
Amount = payment.Amount,
|
||||||
|
OccurredAt = payment.PaidAt!.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.StoreId == storeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<RefundRecordProjection> BuildRefundRecordQuery(
|
||||||
|
long tenantId,
|
||||||
|
FinanceCostDimension dimension,
|
||||||
|
long? storeId)
|
||||||
|
{
|
||||||
|
var query =
|
||||||
|
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
|
||||||
|
select new RefundRecordProjection
|
||||||
|
{
|
||||||
|
StoreId = order.StoreId,
|
||||||
|
DeliveryType = order.DeliveryType,
|
||||||
|
Amount = refund.Amount,
|
||||||
|
OccurredAt = refund.CompletedAt ?? refund.RequestedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.StoreId == storeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<long> BuildPaidOrderIdsQuery(
|
||||||
|
long tenantId,
|
||||||
|
FinanceCostDimension dimension,
|
||||||
|
long? storeId,
|
||||||
|
DateTime startAt,
|
||||||
|
DateTime endAt)
|
||||||
|
{
|
||||||
|
var query =
|
||||||
|
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.HasValue
|
||||||
|
&& payment.PaidAt.Value >= startAt
|
||||||
|
&& payment.PaidAt.Value < endAt
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
order.Id,
|
||||||
|
order.StoreId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.StoreId == storeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Select(item => item.Id).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TakeoutSaaS.Domain.Finance.Entities.FinanceCostEntry> BuildMonthlyCostQuery(
|
||||||
|
long tenantId,
|
||||||
|
FinanceCostDimension dimension,
|
||||||
|
long? storeId,
|
||||||
|
IReadOnlyCollection<DateTime> monthStarts)
|
||||||
|
{
|
||||||
|
var query = context.FinanceCostEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && monthStarts.Contains(item.CostMonth));
|
||||||
|
|
||||||
|
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
|
||||||
|
{
|
||||||
|
return query.Where(item =>
|
||||||
|
item.Dimension == FinanceCostDimension.Store &&
|
||||||
|
item.StoreId == storeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Where(item =>
|
||||||
|
item.Dimension == FinanceCostDimension.Tenant &&
|
||||||
|
item.StoreId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> BuildMonthlyCostMap(
|
||||||
|
IReadOnlyCollection<MonthlyCostRow> rows)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var monthStart = NormalizeMonthStart(row.CostMonth);
|
||||||
|
if (!result.TryGetValue(monthStart, out var categoryMap))
|
||||||
|
{
|
||||||
|
categoryMap = new Dictionary<FinanceCostCategory, decimal>();
|
||||||
|
result[monthStart] = categoryMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categoryMap.ContainsKey(row.Category))
|
||||||
|
{
|
||||||
|
categoryMap[row.Category] = 0m;
|
||||||
|
}
|
||||||
|
categoryMap[row.Category] += row.Amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DailyCostAmounts GetDailyBaseCost(
|
||||||
|
DateTime businessDate,
|
||||||
|
IReadOnlyDictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> monthlyCostMap)
|
||||||
|
{
|
||||||
|
var monthStart = NormalizeMonthStart(businessDate);
|
||||||
|
var daysInMonth = DateTime.DaysInMonth(monthStart.Year, monthStart.Month);
|
||||||
|
|
||||||
|
monthlyCostMap.TryGetValue(monthStart, out var categoryMap);
|
||||||
|
categoryMap ??= new Dictionary<FinanceCostCategory, decimal>();
|
||||||
|
|
||||||
|
var food = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FoodMaterial, daysInMonth);
|
||||||
|
var labor = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.Labor, daysInMonth);
|
||||||
|
var fixedCost = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FixedExpense, daysInMonth);
|
||||||
|
var packaging = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.PackagingConsumable, daysInMonth);
|
||||||
|
|
||||||
|
return new DailyCostAmounts
|
||||||
|
{
|
||||||
|
Food = food,
|
||||||
|
Labor = labor,
|
||||||
|
Fixed = fixedCost,
|
||||||
|
Packaging = packaging,
|
||||||
|
Platform = 0m
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ResolveDailyCategoryAmount(
|
||||||
|
IReadOnlyDictionary<FinanceCostCategory, decimal> categoryMap,
|
||||||
|
FinanceCostCategory category,
|
||||||
|
int daysInMonth)
|
||||||
|
{
|
||||||
|
categoryMap.TryGetValue(category, out var monthTotal);
|
||||||
|
if (daysInMonth <= 0)
|
||||||
|
{
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoundAmount(monthTotal / daysInMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DateTime> BuildMonthStartRange(DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
var result = new List<DateTime>();
|
||||||
|
var monthStart = NormalizeMonthStart(startDate);
|
||||||
|
var endMonth = NormalizeMonthStart(endDate);
|
||||||
|
while (monthStart <= endMonth)
|
||||||
|
{
|
||||||
|
result.Add(monthStart);
|
||||||
|
monthStart = monthStart.AddMonths(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DailyCostAmounts CreateEmptyCostComposition()
|
||||||
|
{
|
||||||
|
return new DailyCostAmounts
|
||||||
|
{
|
||||||
|
Food = 0m,
|
||||||
|
Labor = 0m,
|
||||||
|
Fixed = 0m,
|
||||||
|
Packaging = 0m,
|
||||||
|
Platform = 0m
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddAmount(IDictionary<DateTime, decimal> map, DateTime key, decimal amount)
|
||||||
|
{
|
||||||
|
if (!map.ContainsKey(key))
|
||||||
|
{
|
||||||
|
map[key] = 0m;
|
||||||
|
}
|
||||||
|
map[key] += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal GetAmount(IReadOnlyDictionary<DateTime, decimal> map, DateTime key)
|
||||||
|
{
|
||||||
|
return map.TryGetValue(key, out var value) ? value : 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ResolvePlatformRate(IReadOnlyDictionary<long, decimal> map, long storeId)
|
||||||
|
{
|
||||||
|
if (!map.TryGetValue(storeId, out var rate))
|
||||||
|
{
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(rate, 0m, 100m);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime NormalizeUtc(DateTime value)
|
||||||
|
{
|
||||||
|
return value.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Utc => value,
|
||||||
|
DateTimeKind.Local => value.ToUniversalTime(),
|
||||||
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime NormalizeDate(DateTime value)
|
||||||
|
{
|
||||||
|
var utc = NormalizeUtc(value);
|
||||||
|
return new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime NormalizeMonthStart(DateTime value)
|
||||||
|
{
|
||||||
|
var utc = NormalizeUtc(value);
|
||||||
|
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal RoundAmount(decimal value)
|
||||||
|
{
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PaidRecordProjection
|
||||||
|
{
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
public required DeliveryType DeliveryType { get; init; }
|
||||||
|
|
||||||
|
public required decimal Amount { get; init; }
|
||||||
|
|
||||||
|
public required DateTime OccurredAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RefundRecordProjection
|
||||||
|
{
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
public required DeliveryType DeliveryType { get; init; }
|
||||||
|
|
||||||
|
public required decimal Amount { get; init; }
|
||||||
|
|
||||||
|
public required DateTime OccurredAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DailyStoreAmountRow
|
||||||
|
{
|
||||||
|
public required DateTime BusinessDate { get; init; }
|
||||||
|
|
||||||
|
public required long StoreId { get; init; }
|
||||||
|
|
||||||
|
public required decimal Amount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DailyChannelAmountRow
|
||||||
|
{
|
||||||
|
public required DeliveryType Channel { get; init; }
|
||||||
|
|
||||||
|
public required decimal Amount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MonthlyCostRow
|
||||||
|
{
|
||||||
|
public required DateTime CostMonth { get; init; }
|
||||||
|
|
||||||
|
public required FinanceCostCategory Category { get; init; }
|
||||||
|
|
||||||
|
public required decimal Amount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DailyCostAmounts
|
||||||
|
{
|
||||||
|
public decimal Food { get; init; }
|
||||||
|
|
||||||
|
public decimal Labor { get; init; }
|
||||||
|
|
||||||
|
public decimal Fixed { get; init; }
|
||||||
|
|
||||||
|
public decimal Packaging { get; init; }
|
||||||
|
|
||||||
|
public decimal Platform { get; init; }
|
||||||
|
|
||||||
|
public decimal TotalCost => RoundAmount(Food + Labor + Fixed + Packaging + Platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户发票仓储 EF Core 实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfTenantInvoiceRepository(TakeoutAppDbContext context) : ITenantInvoiceRepository
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TenantInvoiceSetting?> GetSettingAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantInvoiceSettings
|
||||||
|
.Where(item => item.TenantId == tenantId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantInvoiceSettings.AddAsync(entity, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.TenantInvoiceSettings.Update(entity);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<(IReadOnlyList<TenantInvoiceRecord> Items, int TotalCount)> SearchRecordsAsync(
|
||||||
|
long tenantId,
|
||||||
|
DateTime? startUtc,
|
||||||
|
DateTime? endUtc,
|
||||||
|
TenantInvoiceStatus? status,
|
||||||
|
TenantInvoiceType? invoiceType,
|
||||||
|
string? keyword,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedPage = Math.Max(1, page);
|
||||||
|
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
|
||||||
|
|
||||||
|
var query = BuildRecordQuery(tenantId, startUtc, endUtc, status, invoiceType, keyword);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
if (totalCount == 0)
|
||||||
|
{
|
||||||
|
return ([], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(item => item.AppliedAt)
|
||||||
|
.ThenByDescending(item => item.Id)
|
||||||
|
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||||
|
.Take(normalizedPageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (items, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<TenantInvoiceRecordStatsSnapshot> GetStatsAsync(
|
||||||
|
long tenantId,
|
||||||
|
DateTime nowUtc,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var utcNow = NormalizeUtc(nowUtc);
|
||||||
|
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var summary = await context.TenantInvoiceRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
CurrentMonthIssuedAmount = group
|
||||||
|
.Where(item =>
|
||||||
|
item.Status == TenantInvoiceStatus.Issued &&
|
||||||
|
item.IssuedAt.HasValue &&
|
||||||
|
item.IssuedAt.Value >= monthStart &&
|
||||||
|
item.IssuedAt.Value <= utcNow)
|
||||||
|
.Sum(item => item.Amount),
|
||||||
|
CurrentMonthIssuedCount = group
|
||||||
|
.Count(item =>
|
||||||
|
item.Status == TenantInvoiceStatus.Issued &&
|
||||||
|
item.IssuedAt.HasValue &&
|
||||||
|
item.IssuedAt.Value >= monthStart &&
|
||||||
|
item.IssuedAt.Value <= utcNow),
|
||||||
|
PendingCount = group.Count(item => item.Status == TenantInvoiceStatus.Pending),
|
||||||
|
VoidedCount = group.Count(item => item.Status == TenantInvoiceStatus.Voided)
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (summary is null)
|
||||||
|
{
|
||||||
|
return new TenantInvoiceRecordStatsSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TenantInvoiceRecordStatsSnapshot
|
||||||
|
{
|
||||||
|
CurrentMonthIssuedAmount = summary.CurrentMonthIssuedAmount,
|
||||||
|
CurrentMonthIssuedCount = summary.CurrentMonthIssuedCount,
|
||||||
|
PendingCount = summary.PendingCount,
|
||||||
|
VoidedCount = summary.VoidedCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TenantInvoiceRecord?> FindRecordByIdAsync(
|
||||||
|
long tenantId,
|
||||||
|
long recordId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantInvoiceRecords
|
||||||
|
.Where(item => item.TenantId == tenantId && item.Id == recordId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<bool> ExistsInvoiceNoAsync(
|
||||||
|
long tenantId,
|
||||||
|
string invoiceNo,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantInvoiceRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(
|
||||||
|
item => item.TenantId == tenantId && item.InvoiceNo == invoiceNo,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantInvoiceRecords.AddAsync(entity, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.TenantInvoiceRecords.Update(entity);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TenantInvoiceRecord> BuildRecordQuery(
|
||||||
|
long tenantId,
|
||||||
|
DateTime? startUtc,
|
||||||
|
DateTime? endUtc,
|
||||||
|
TenantInvoiceStatus? status,
|
||||||
|
TenantInvoiceType? invoiceType,
|
||||||
|
string? keyword)
|
||||||
|
{
|
||||||
|
var query = context.TenantInvoiceRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId);
|
||||||
|
|
||||||
|
if (startUtc.HasValue)
|
||||||
|
{
|
||||||
|
var normalizedStart = NormalizeUtc(startUtc.Value);
|
||||||
|
query = query.Where(item => item.AppliedAt >= normalizedStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endUtc.HasValue)
|
||||||
|
{
|
||||||
|
var normalizedEnd = NormalizeUtc(endUtc.Value);
|
||||||
|
query = query.Where(item => item.AppliedAt <= normalizedEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.Status == status.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceType.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(item => item.InvoiceType == invoiceType.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||||
|
{
|
||||||
|
var like = $"%{normalizedKeyword}%";
|
||||||
|
query = query.Where(item =>
|
||||||
|
EF.Functions.ILike(item.InvoiceNo, like) ||
|
||||||
|
EF.Functions.ILike(item.CompanyName, like) ||
|
||||||
|
EF.Functions.ILike(item.ApplicantName, like) ||
|
||||||
|
EF.Functions.ILike(item.OrderNo, like));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime NormalizeUtc(DateTime value)
|
||||||
|
{
|
||||||
|
return value.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Utc => value,
|
||||||
|
DateTimeKind.Local => value.ToUniversalTime(),
|
||||||
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user