Compare commits
17 Commits
e4f7ceeaa7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba8c0732b | |||
| f7eba55039 | |||
| fdbefca650 | |||
| 4a7d012a58 | |||
| d330db84fc | |||
| c79e9bd6e8 | |||
| 3f308c2d0c | |||
| 5dfaac01fd | |||
| 21a689edec | |||
| fa6e376b86 | |||
| 76366cbc30 | |||
| b0bb87d97c | |||
| b5aa060faf | |||
| 39e28c1a62 | |||
| dd2ac79d48 | |||
| bd418c5927 | |||
| a8cfda88f7 |
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账统计请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementStatsRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账筛选请求。
|
||||||
|
/// </summary>
|
||||||
|
public class FinanceSettlementFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道(wechat/alipay)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Channel { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementListRequest : FinanceSettlementFilterRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账明细请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string ArrivedDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道(wechat/alipay)。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账统计响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 今日到账。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TodayArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 昨日到账。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YesterdayArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月到账。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CurrentMonthArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月交易笔数。
|
||||||
|
/// </summary>
|
||||||
|
public int CurrentMonthTransactionCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账账户信息响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementAccountResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 银行名称。
|
||||||
|
/// </summary>
|
||||||
|
public string BankName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户名。
|
||||||
|
/// </summary>
|
||||||
|
public string BankAccountName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脱敏银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string BankAccountNoMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脱敏微信商户号。
|
||||||
|
/// </summary>
|
||||||
|
public string WechatMerchantNoMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脱敏支付宝 PID。
|
||||||
|
/// </summary>
|
||||||
|
public string AlipayPidMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结算周期文案。
|
||||||
|
/// </summary>
|
||||||
|
public string SettlementPeriodText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账列表行响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 到账日期。
|
||||||
|
/// </summary>
|
||||||
|
public string ArrivedDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道编码。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易笔数。
|
||||||
|
/// </summary>
|
||||||
|
public int TransactionCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ArrivedAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账列表响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceSettlementListItemResponse> 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>
|
||||||
|
/// 到账明细行响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementDetailItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付时间。
|
||||||
|
/// </summary>
|
||||||
|
public string PaidAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账明细响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementDetailResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 明细列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceSettlementDetailItemResponse> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账导出响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementExportResponse
|
||||||
|
{
|
||||||
|
/// <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,585 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息触达统计请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachStatsRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 状态过滤(draft/pending/sending/sent/failed)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道过滤(inapp/sms/wechat-mini)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Channel { 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 MemberMessageReachDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存消息请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberMessageReachRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息 ID(编辑时传)。
|
||||||
|
/// </summary>
|
||||||
|
public string? MessageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可选)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 ID(可选)。
|
||||||
|
/// </summary>
|
||||||
|
public string? TemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送渠道。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Channels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标类型(all/tag)。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceType { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AudienceTags { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间类型(immediate/scheduled)。
|
||||||
|
/// </summary>
|
||||||
|
public string ScheduleType { get; set; } = "immediate";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(UTC 或本地时间,后端统一转 UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提交动作(draft/send)。
|
||||||
|
/// </summary>
|
||||||
|
public string SubmitAction { get; set; } = "draft";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除消息请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteMemberMessageReachRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 估算人群请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageAudienceEstimateRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 目标类型(all/tag)。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceType { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Tags { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageTemplateListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板分类(marketing/notice/recall)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Category { 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 MemberMessageTemplateDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string TemplateId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存模板请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberMessageTemplateRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 ID(编辑时传)。
|
||||||
|
/// </summary>
|
||||||
|
public string? TemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板分类(marketing/notice/recall)。
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; set; } = "notice";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除模板请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteMemberMessageTemplateRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string TemplateId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息触达统计响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本月发送条数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlySentCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int ReachMemberCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal OpenRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConversionRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息列表项响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Channels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预计触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int EstimatedReachCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? SentAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal OpenRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConversionRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息列表响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<MemberMessageReachListItemResponse> 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 sealed class MemberMessageReachRecipientResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号。
|
||||||
|
/// </summary>
|
||||||
|
public string? Mobile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenId。
|
||||||
|
/// </summary>
|
||||||
|
public string? OpenId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? SentAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已读时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ReadAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ConvertedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误信息。
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? TemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Channels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标类型。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标标签。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AudienceTags { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标文案。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预计触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int EstimatedReachCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间类型。
|
||||||
|
/// </summary>
|
||||||
|
public string ScheduleType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实际发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? SentAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 成功发送数。
|
||||||
|
/// </summary>
|
||||||
|
public int SentCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已读数。
|
||||||
|
/// </summary>
|
||||||
|
public int ReadCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化数。
|
||||||
|
/// </summary>
|
||||||
|
public int ConvertedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal OpenRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化率(百分比)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConversionRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误信息。
|
||||||
|
/// </summary>
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收件明细。
|
||||||
|
/// </summary>
|
||||||
|
public List<MemberMessageReachRecipientResponse> Recipients { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息调度元信息响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageDispatchMetaResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 时间类型。
|
||||||
|
/// </summary>
|
||||||
|
public string ScheduleType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ScheduledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hangfire 任务 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? HangfireJobId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageTemplateResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string TemplateId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类。
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用次数。
|
||||||
|
/// </summary>
|
||||||
|
public int UsageCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近使用时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string? LastUsedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板列表响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageTemplateListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<MemberMessageTemplateResponse> 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 sealed class MemberMessageAudienceEstimateResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 预计触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int ReachCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,808 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城规则详情查询请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRuleDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存积分商城规则请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SavePointMallRuleRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用消费获取。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsConsumeRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每消费多少元触发一次积分计算。
|
||||||
|
/// </summary>
|
||||||
|
public int ConsumeAmountPerStep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每步获得积分。
|
||||||
|
/// </summary>
|
||||||
|
public int ConsumeRewardPointsPerStep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用评价奖励。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsReviewRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 评价奖励积分。
|
||||||
|
/// </summary>
|
||||||
|
public int ReviewRewardPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用注册奖励。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRegisterRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册奖励积分。
|
||||||
|
/// </summary>
|
||||||
|
public int RegisterRewardPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用签到奖励。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSigninRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 签到奖励积分。
|
||||||
|
/// </summary>
|
||||||
|
public int SigninRewardPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 有效期模式(permanent/yearly_clear)。
|
||||||
|
/// </summary>
|
||||||
|
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品列表查询请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallProductListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(enabled/disabled,可空)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键字。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品详情查询请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallProductDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string PointMallProductId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存积分商城商品请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SavePointMallProductRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID(编辑时传)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PointMallProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示图片。
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型(product/coupon/physical)。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemType { get; set; } = "product";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联优惠券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? CouponTemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实物名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? PhysicalName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 领取方式(store_pickup/delivery)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PickupMethod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品描述。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换方式(points/mixed)。
|
||||||
|
/// </summary>
|
||||||
|
public string ExchangeType { get; set; } = "points";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所需积分。
|
||||||
|
/// </summary>
|
||||||
|
public int RequiredPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 现金部分。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CashAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 库存总量。
|
||||||
|
/// </summary>
|
||||||
|
public int StockTotal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每人限兑次数。
|
||||||
|
/// </summary>
|
||||||
|
public int? PerMemberLimit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通知渠道(in_app/sms)。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> NotifyChannels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(enabled/disabled)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "enabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 修改积分商城商品状态请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChangePointMallProductStatusRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string PointMallProductId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(enabled/disabled)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除积分商城商品请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeletePointMallProductRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string PointMallProductId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城兑换记录分页查询请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRecordListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型(product/coupon/physical)。
|
||||||
|
/// </summary>
|
||||||
|
public string? RedeemType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? EndDate { 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 PointMallRecordDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出积分商城兑换记录请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportPointMallRecordRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型(product/coupon/physical)。
|
||||||
|
/// </summary>
|
||||||
|
public string? RedeemType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string? EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键字。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入积分商城兑换记录请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WritePointMallRecordRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string PointMallProductId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换时间(可空,默认当前时间)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? RedeemedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销积分商城兑换记录请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VerifyPointMallRecordRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销方式(scan/manual)。
|
||||||
|
/// </summary>
|
||||||
|
public string VerifyMethod { get; set; } = "manual";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? VerifyRemark { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城规则响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRuleResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用消费获取。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsConsumeRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每消费多少元触发一次积分计算。
|
||||||
|
/// </summary>
|
||||||
|
public int ConsumeAmountPerStep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每步获得积分。
|
||||||
|
/// </summary>
|
||||||
|
public int ConsumeRewardPointsPerStep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用评价奖励。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsReviewRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 评价奖励积分。
|
||||||
|
/// </summary>
|
||||||
|
public int ReviewRewardPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用注册奖励。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRegisterRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册奖励积分。
|
||||||
|
/// </summary>
|
||||||
|
public int RegisterRewardPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用签到奖励。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSigninRewardEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 签到奖励积分。
|
||||||
|
/// </summary>
|
||||||
|
public int SigninRewardPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 有效期模式(permanent/yearly_clear)。
|
||||||
|
/// </summary>
|
||||||
|
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城规则统计响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRuleStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 累计发放积分。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalIssuedPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已兑换积分。
|
||||||
|
/// </summary>
|
||||||
|
public int RedeemedPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分用户。
|
||||||
|
/// </summary>
|
||||||
|
public int PointMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换率(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal RedeemRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城规则详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRuleDetailResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 规则。
|
||||||
|
/// </summary>
|
||||||
|
public PointMallRuleResponse Rule { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计。
|
||||||
|
/// </summary>
|
||||||
|
public PointMallRuleStatsResponse Stats { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallProductResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string PointMallProductId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示图片。
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型(product/coupon/physical)。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemType { get; set; } = "product";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemTypeText { get; set; } = "商品";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联优惠券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? CouponTemplateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实物名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? PhysicalName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 领取方式(store_pickup/delivery)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PickupMethod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品描述。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换方式(points/mixed)。
|
||||||
|
/// </summary>
|
||||||
|
public string ExchangeType { get; set; } = "points";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所需积分。
|
||||||
|
/// </summary>
|
||||||
|
public int RequiredPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 现金部分。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CashAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始库存。
|
||||||
|
/// </summary>
|
||||||
|
public int StockTotal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 剩余库存。
|
||||||
|
/// </summary>
|
||||||
|
public int StockAvailable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已兑换数量。
|
||||||
|
/// </summary>
|
||||||
|
public int RedeemedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每人限兑次数。
|
||||||
|
/// </summary>
|
||||||
|
public int? PerMemberLimit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通知渠道。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> NotifyChannels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(enabled/disabled)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "enabled";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = "上架";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间。
|
||||||
|
/// </summary>
|
||||||
|
public string UpdatedAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品列表响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallProductListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<PointMallProductResponse> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城兑换记录响应。
|
||||||
|
/// </summary>
|
||||||
|
public class PointMallRecordResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换记录 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换单号。
|
||||||
|
/// </summary>
|
||||||
|
public string RecordNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string PointMallProductId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品名称。
|
||||||
|
/// </summary>
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型(product/coupon/physical)。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemType { get; set; } = "product";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型文案。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemTypeText { get; set; } = "商品";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换方式(points/mixed)。
|
||||||
|
/// </summary>
|
||||||
|
public string ExchangeType { get; set; } = "points";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员名称。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员手机号(脱敏)。
|
||||||
|
/// </summary>
|
||||||
|
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消耗积分。
|
||||||
|
/// </summary>
|
||||||
|
public int UsedPoints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 现金部分。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CashAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "issued";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态文案。
|
||||||
|
/// </summary>
|
||||||
|
public string StatusText { get; set; } = "已发放";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换时间。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发放时间。
|
||||||
|
/// </summary>
|
||||||
|
public string? IssuedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销时间。
|
||||||
|
/// </summary>
|
||||||
|
public string? VerifiedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城兑换记录详情响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRecordDetailResponse : PointMallRecordResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 核销方式(scan/manual)。
|
||||||
|
/// </summary>
|
||||||
|
public string? VerifyMethod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销方式文案。
|
||||||
|
/// </summary>
|
||||||
|
public string? VerifyMethodText { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销备注。
|
||||||
|
/// </summary>
|
||||||
|
public string? VerifyRemark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销人 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? VerifiedBy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城兑换记录统计响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRecordStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 今日兑换。
|
||||||
|
/// </summary>
|
||||||
|
public int TodayRedeemCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待领取实物。
|
||||||
|
/// </summary>
|
||||||
|
public int PendingPhysicalCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月消耗积分。
|
||||||
|
/// </summary>
|
||||||
|
public int CurrentMonthUsedPoints { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城兑换记录分页响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRecordListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<PointMallRecordResponse> 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 PointMallRecordStatsResponse Stats { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城兑换记录导出响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PointMallRecordExportResponse
|
||||||
|
{
|
||||||
|
/// <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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Domain.Payments.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/settlement")]
|
||||||
|
public sealed class FinanceSettlementController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService) : BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:finance:settlement:view";
|
||||||
|
private const string ExportPermission = "tenant:finance:settlement:export";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账统计。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementStatsResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceSettlementStatsResponse>> Stats(
|
||||||
|
[FromQuery] FinanceSettlementStatsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var stats = await mediator.Send(new GetFinanceSettlementStatsQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceSettlementStatsResponse>.Ok(new FinanceSettlementStatsResponse
|
||||||
|
{
|
||||||
|
TodayArrivedAmount = stats.TodayArrivedAmount,
|
||||||
|
YesterdayArrivedAmount = stats.YesterdayArrivedAmount,
|
||||||
|
CurrentMonthArrivedAmount = stats.CurrentMonthArrivedAmount,
|
||||||
|
CurrentMonthTransactionCount = stats.CurrentMonthTransactionCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账账户信息。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("account")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementAccountResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceSettlementAccountResponse>> Account(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await mediator.Send(new GetFinanceSettlementAccountQuery(), cancellationToken);
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<FinanceSettlementAccountResponse>.Error(ErrorCodes.NotFound, "结算账户信息不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<FinanceSettlementAccountResponse>.Ok(new FinanceSettlementAccountResponse
|
||||||
|
{
|
||||||
|
BankName = account.BankName,
|
||||||
|
BankAccountName = account.BankAccountName,
|
||||||
|
BankAccountNoMasked = account.BankAccountNoMasked,
|
||||||
|
WechatMerchantNoMasked = account.WechatMerchantNoMasked,
|
||||||
|
AlipayPidMasked = account.AlipayPidMasked,
|
||||||
|
SettlementPeriodText = account.SettlementPeriodText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账汇总列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceSettlementListResultResponse>> List(
|
||||||
|
[FromQuery] FinanceSettlementListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new SearchFinanceSettlementListQuery
|
||||||
|
{
|
||||||
|
StoreId = parsed.StoreId,
|
||||||
|
StartAt = parsed.StartAt,
|
||||||
|
EndAt = parsed.EndAt,
|
||||||
|
PaymentMethod = parsed.PaymentMethod,
|
||||||
|
Page = Math.Max(1, request.Page),
|
||||||
|
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceSettlementListResultResponse>.Ok(new FinanceSettlementListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapListItem).ToList(),
|
||||||
|
Total = result.Total,
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账明细(展开行)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementDetailResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceSettlementDetailResultResponse>> Detail(
|
||||||
|
[FromQuery] FinanceSettlementDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var arrivedDate = ParseRequiredDate(request.ArrivedDate, nameof(request.ArrivedDate));
|
||||||
|
var paymentMethod = ParseRequiredSettlementChannel(request.Channel);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetFinanceSettlementDetailQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
ArrivedDate = arrivedDate,
|
||||||
|
PaymentMethod = paymentMethod,
|
||||||
|
Take = 50
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceSettlementDetailResultResponse>.Ok(new FinanceSettlementDetailResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapDetailItem).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出到账汇总 CSV。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export")]
|
||||||
|
[PermissionAuthorize(ExportPermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FinanceSettlementExportResponse>> Export(
|
||||||
|
[FromQuery] FinanceSettlementFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var parsed = await ParseFilterAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ExportFinanceSettlementCsvQuery
|
||||||
|
{
|
||||||
|
StoreId = parsed.StoreId,
|
||||||
|
StartAt = parsed.StartAt,
|
||||||
|
EndAt = parsed.EndAt,
|
||||||
|
PaymentMethod = parsed.PaymentMethod
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FinanceSettlementExportResponse>.Ok(new FinanceSettlementExportResponse
|
||||||
|
{
|
||||||
|
FileName = result.FileName,
|
||||||
|
FileContentBase64 = result.FileContentBase64,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, PaymentMethod? PaymentMethod)> ParseFilterAsync(
|
||||||
|
FinanceSettlementFilterRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var startAt = ParseDateOrNull(request.StartDate);
|
||||||
|
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
|
||||||
|
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (storeId, startAt, endAt, ParseOptionalSettlementChannel(request.Channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime ParseRequiredDate(string? value, string parameterName)
|
||||||
|
{
|
||||||
|
return ParseDateOrNull(value)
|
||||||
|
?? throw new BusinessException(ErrorCodes.BadRequest, $"{parameterName} 必填,格式为 yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDateOrNull(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
value,
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None,
|
||||||
|
out var parsed))
|
||||||
|
{
|
||||||
|
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PaymentMethod ParseRequiredSettlementChannel(string? channel)
|
||||||
|
{
|
||||||
|
return ParseOptionalSettlementChannel(channel)
|
||||||
|
?? throw new BusinessException(ErrorCodes.BadRequest, "channel 必填,仅支持 wechat 或 alipay");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PaymentMethod? ParseOptionalSettlementChannel(string? channel)
|
||||||
|
{
|
||||||
|
return (channel ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"wechat" => PaymentMethod.WeChatPay,
|
||||||
|
"alipay" => PaymentMethod.Alipay,
|
||||||
|
"" => null,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 仅支持 wechat 或 alipay")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceSettlementListItemResponse MapListItem(FinanceSettlementListItemDto source)
|
||||||
|
{
|
||||||
|
return new FinanceSettlementListItemResponse
|
||||||
|
{
|
||||||
|
ArrivedDate = source.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||||
|
Channel = source.Channel,
|
||||||
|
ChannelText = source.ChannelText,
|
||||||
|
TransactionCount = source.TransactionCount,
|
||||||
|
ArrivedAmount = source.ArrivedAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FinanceSettlementDetailItemResponse MapDetailItem(FinanceSettlementDetailItemDto source)
|
||||||
|
{
|
||||||
|
return new FinanceSettlementDetailItemResponse
|
||||||
|
{
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Amount = source.Amount,
|
||||||
|
PaidAt = source.PaidAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Hangfire;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
using TakeoutSaaS.TenantApi.Services;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员消息触达管理。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/member/message-reach")]
|
||||||
|
public sealed class MemberMessageReachController(
|
||||||
|
IMemberMessageReachAppService memberMessageReachAppService,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService)
|
||||||
|
: BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:member:message-reach:view";
|
||||||
|
private const string ManagePermission = "tenant:member:message-reach:manage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取页面统计。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachStatsResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageReachStatsResponse>> Stats(
|
||||||
|
[FromQuery] MemberMessageReachStatsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||||
|
var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken);
|
||||||
|
return ApiResponse<MemberMessageReachStatsResponse>.Ok(new MemberMessageReachStatsResponse
|
||||||
|
{
|
||||||
|
MonthlySentCount = result.MonthlySentCount,
|
||||||
|
ReachMemberCount = result.ReachMemberCount,
|
||||||
|
OpenRate = result.OpenRate,
|
||||||
|
ConversionRate = result.ConversionRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询消息列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageReachListResultResponse>> List(
|
||||||
|
[FromQuery] MemberMessageReachListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var result = await memberMessageReachAppService.SearchMessagesAsync(
|
||||||
|
tenantId,
|
||||||
|
new SearchMemberMessageInput
|
||||||
|
{
|
||||||
|
Status = request.Status,
|
||||||
|
Channel = request.Channel,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Page = request.Page,
|
||||||
|
PageSize = request.PageSize
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberMessageReachListResultResponse>.Ok(new MemberMessageReachListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapMessageListItem).ToList(),
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取消息详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageReachDetailResponse>> Detail(
|
||||||
|
[FromQuery] MemberMessageReachDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||||
|
var result = await memberMessageReachAppService.GetMessageDetailAsync(tenantId, messageId, cancellationToken);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<MemberMessageReachDetailResponse>.Error(ErrorCodes.NotFound, "消息不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<MemberMessageReachDetailResponse>.Ok(MapMessageDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存消息(草稿/发送)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("save")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageDispatchMetaResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageDispatchMetaResponse>> Save(
|
||||||
|
[FromBody] SaveMemberMessageReachRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
|
||||||
|
var messageId = StoreApiHelpers.ParseSnowflakeOrNull(request.MessageId);
|
||||||
|
var previousMeta = messageId.HasValue
|
||||||
|
? await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, messageId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var saved = await memberMessageReachAppService.SaveMessageAsync(
|
||||||
|
tenantId,
|
||||||
|
new SaveMemberMessageInput
|
||||||
|
{
|
||||||
|
MessageId = messageId,
|
||||||
|
StoreId = StoreApiHelpers.ParseSnowflakeOrNull(request.StoreId),
|
||||||
|
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||||
|
Title = request.Title,
|
||||||
|
Content = request.Content,
|
||||||
|
Channels = request.Channels,
|
||||||
|
AudienceType = request.AudienceType,
|
||||||
|
AudienceTags = request.AudienceTags,
|
||||||
|
ScheduleType = request.ScheduleType,
|
||||||
|
ScheduledAt = request.ScheduledAt,
|
||||||
|
SubmitAction = request.SubmitAction
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 1. 清理旧任务(若存在)。
|
||||||
|
if (!string.IsNullOrWhiteSpace(previousMeta?.HangfireJobId))
|
||||||
|
{
|
||||||
|
BackgroundJob.Delete(previousMeta.HangfireJobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 发送动作创建新任务并回写任务 ID。
|
||||||
|
if (string.Equals(request.SubmitAction, "send", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var newJobId = ScheduleDispatchJob(saved.MessageId, saved.ScheduleType, saved.ScheduledAt);
|
||||||
|
await memberMessageReachAppService.BindDispatchJobAsync(tenantId, saved.MessageId, newJobId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 返回最新调度状态。
|
||||||
|
var latest = await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, saved.MessageId, cancellationToken);
|
||||||
|
return ApiResponse<MemberMessageDispatchMetaResponse>.Ok(MapDispatchMeta(latest ?? saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除消息。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<object>> Delete(
|
||||||
|
[FromBody] DeleteMemberMessageReachRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
|
||||||
|
var oldJobId = await memberMessageReachAppService.DeleteMessageAsync(tenantId, messageId, cancellationToken);
|
||||||
|
if (!string.IsNullOrWhiteSpace(oldJobId))
|
||||||
|
{
|
||||||
|
BackgroundJob.Delete(oldJobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<object>.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 估算目标人群。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("audience/estimate")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageAudienceEstimateResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageAudienceEstimateResponse>> EstimateAudience(
|
||||||
|
[FromBody] MemberMessageAudienceEstimateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var result = await memberMessageReachAppService.EstimateAudienceAsync(
|
||||||
|
tenantId,
|
||||||
|
new MemberMessageAudienceEstimateInput
|
||||||
|
{
|
||||||
|
AudienceType = request.AudienceType,
|
||||||
|
Tags = request.Tags
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberMessageAudienceEstimateResponse>.Ok(new MemberMessageAudienceEstimateResponse
|
||||||
|
{
|
||||||
|
ReachCount = result.ReachCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询模板。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("template/list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageTemplateListResultResponse>> TemplateList(
|
||||||
|
[FromQuery] MemberMessageTemplateListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var result = await memberMessageReachAppService.SearchTemplatesAsync(
|
||||||
|
tenantId,
|
||||||
|
new SearchMemberMessageTemplateInput
|
||||||
|
{
|
||||||
|
Category = request.Category,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Page = request.Page,
|
||||||
|
PageSize = request.PageSize
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberMessageTemplateListResultResponse>.Ok(new MemberMessageTemplateListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapTemplate).ToList(),
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取模板详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("template/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageTemplateResponse>> TemplateDetail(
|
||||||
|
[FromQuery] MemberMessageTemplateDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||||
|
var result = await memberMessageReachAppService.GetTemplateAsync(tenantId, templateId, cancellationToken);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<MemberMessageTemplateResponse>.Error(ErrorCodes.NotFound, "模板不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存模板。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("template/save")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<MemberMessageTemplateResponse>> SaveTemplate(
|
||||||
|
[FromBody] SaveMemberMessageTemplateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var result = await memberMessageReachAppService.SaveTemplateAsync(
|
||||||
|
tenantId,
|
||||||
|
new SaveMemberMessageTemplateInput
|
||||||
|
{
|
||||||
|
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
|
||||||
|
Name = request.Name,
|
||||||
|
Category = request.Category,
|
||||||
|
Content = request.Content
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除模板。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("template/delete")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<object>> DeleteTemplate(
|
||||||
|
[FromBody] DeleteMemberMessageTemplateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = ResolveTenantId();
|
||||||
|
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
|
||||||
|
await memberMessageReachAppService.DeleteTemplateAsync(tenantId, templateId, cancellationToken);
|
||||||
|
return ApiResponse<object>.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ResolveTenantId()
|
||||||
|
{
|
||||||
|
var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> ResolveTenantIdAsync(string? storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
if (string.IsNullOrWhiteSpace(storeId))
|
||||||
|
{
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(
|
||||||
|
dbContext,
|
||||||
|
tenantId,
|
||||||
|
merchantId,
|
||||||
|
parsedStoreId,
|
||||||
|
cancellationToken);
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ScheduleDispatchJob(long messageId, string scheduleType, DateTime? scheduledAtUtc)
|
||||||
|
{
|
||||||
|
if (string.Equals(scheduleType, "scheduled", StringComparison.OrdinalIgnoreCase) && scheduledAtUtc.HasValue)
|
||||||
|
{
|
||||||
|
var delay = scheduledAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
|
||||||
|
if (delay < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
delay = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackgroundJob.Schedule<MemberMessageReachDispatchJobRunner>(
|
||||||
|
runner => runner.ExecuteAsync(messageId),
|
||||||
|
delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackgroundJob.Enqueue<MemberMessageReachDispatchJobRunner>(runner => runner.ExecuteAsync(messageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberMessageReachListItemResponse MapMessageListItem(MemberMessageReachListItemDto source)
|
||||||
|
{
|
||||||
|
return new MemberMessageReachListItemResponse
|
||||||
|
{
|
||||||
|
MessageId = source.MessageId.ToString(),
|
||||||
|
Title = source.Title,
|
||||||
|
Channels = source.Channels.ToList(),
|
||||||
|
AudienceText = source.AudienceText,
|
||||||
|
EstimatedReachCount = source.EstimatedReachCount,
|
||||||
|
Status = source.Status,
|
||||||
|
SentAt = FormatDateTime(source.SentAt),
|
||||||
|
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||||
|
OpenRate = source.OpenRate,
|
||||||
|
ConversionRate = source.ConversionRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberMessageReachDetailResponse MapMessageDetail(MemberMessageReachDetailDto source)
|
||||||
|
{
|
||||||
|
return new MemberMessageReachDetailResponse
|
||||||
|
{
|
||||||
|
MessageId = source.MessageId.ToString(),
|
||||||
|
TemplateId = source.TemplateId?.ToString(),
|
||||||
|
Title = source.Title,
|
||||||
|
Content = source.Content,
|
||||||
|
Channels = source.Channels.ToList(),
|
||||||
|
AudienceType = source.AudienceType,
|
||||||
|
AudienceTags = source.AudienceTags.ToList(),
|
||||||
|
AudienceText = source.AudienceText,
|
||||||
|
EstimatedReachCount = source.EstimatedReachCount,
|
||||||
|
ScheduleType = source.ScheduleType,
|
||||||
|
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||||
|
Status = source.Status,
|
||||||
|
SentAt = FormatDateTime(source.SentAt),
|
||||||
|
SentCount = source.SentCount,
|
||||||
|
ReadCount = source.ReadCount,
|
||||||
|
ConvertedCount = source.ConvertedCount,
|
||||||
|
OpenRate = source.OpenRate,
|
||||||
|
ConversionRate = source.ConversionRate,
|
||||||
|
LastError = source.LastError,
|
||||||
|
Recipients = source.Recipients.Select(item => new MemberMessageReachRecipientResponse
|
||||||
|
{
|
||||||
|
MemberId = item.MemberId.ToString(),
|
||||||
|
Channel = item.Channel,
|
||||||
|
Status = item.Status,
|
||||||
|
Mobile = item.Mobile,
|
||||||
|
OpenId = item.OpenId,
|
||||||
|
SentAt = FormatDateTime(item.SentAt),
|
||||||
|
ReadAt = FormatDateTime(item.ReadAt),
|
||||||
|
ConvertedAt = FormatDateTime(item.ConvertedAt),
|
||||||
|
ErrorMessage = item.ErrorMessage
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberMessageDispatchMetaResponse MapDispatchMeta(MemberMessageDispatchMetaDto source)
|
||||||
|
{
|
||||||
|
return new MemberMessageDispatchMetaResponse
|
||||||
|
{
|
||||||
|
MessageId = source.MessageId.ToString(),
|
||||||
|
Status = source.Status,
|
||||||
|
ScheduleType = source.ScheduleType,
|
||||||
|
ScheduledAt = FormatDateTime(source.ScheduledAt),
|
||||||
|
HangfireJobId = source.HangfireJobId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberMessageTemplateResponse MapTemplate(MemberMessageTemplateDto source)
|
||||||
|
{
|
||||||
|
return new MemberMessageTemplateResponse
|
||||||
|
{
|
||||||
|
TemplateId = source.TemplateId.ToString(),
|
||||||
|
Name = source.Name,
|
||||||
|
Category = source.Category,
|
||||||
|
Content = source.Content,
|
||||||
|
UsageCount = source.UsageCount,
|
||||||
|
LastUsedAt = FormatDateTime(source.LastUsedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatDateTime(DateTime? value)
|
||||||
|
{
|
||||||
|
return value.HasValue
|
||||||
|
? value.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||||
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Member;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员中心积分商城管理。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/member/points-mall")]
|
||||||
|
public sealed class MemberPointsMallController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService)
|
||||||
|
: BaseApiController
|
||||||
|
{
|
||||||
|
private const string ViewPermission = "tenant:member:points-mall:view";
|
||||||
|
private const string ManagePermission = "tenant:member:points-mall:manage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取积分规则详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("rule/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRuleDetailResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRuleDetailResultResponse>> RuleDetail(
|
||||||
|
[FromQuery] PointMallRuleDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetPointMallRuleDetailQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRuleDetailResultResponse>.Ok(new PointMallRuleDetailResultResponse
|
||||||
|
{
|
||||||
|
Rule = MapRule(result.Rule),
|
||||||
|
Stats = new PointMallRuleStatsResponse
|
||||||
|
{
|
||||||
|
TotalIssuedPoints = result.Stats.TotalIssuedPoints,
|
||||||
|
RedeemedPoints = result.Stats.RedeemedPoints,
|
||||||
|
PointMembers = result.Stats.PointMembers,
|
||||||
|
RedeemRate = result.Stats.RedeemRate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存积分规则。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("rule/save")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRuleResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRuleResponse>> SaveRule(
|
||||||
|
[FromBody] SavePointMallRuleRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new SavePointMallRuleCommand
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
IsConsumeRewardEnabled = request.IsConsumeRewardEnabled,
|
||||||
|
ConsumeAmountPerStep = request.ConsumeAmountPerStep,
|
||||||
|
ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep,
|
||||||
|
IsReviewRewardEnabled = request.IsReviewRewardEnabled,
|
||||||
|
ReviewRewardPoints = request.ReviewRewardPoints,
|
||||||
|
IsRegisterRewardEnabled = request.IsRegisterRewardEnabled,
|
||||||
|
RegisterRewardPoints = request.RegisterRewardPoints,
|
||||||
|
IsSigninRewardEnabled = request.IsSigninRewardEnabled,
|
||||||
|
SigninRewardPoints = request.SigninRewardPoints,
|
||||||
|
ExpiryMode = request.ExpiryMode
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRuleResponse>.Ok(MapRule(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询兑换商品列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("product/list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallProductListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallProductListResultResponse>> ProductList(
|
||||||
|
[FromQuery] PointMallProductListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetPointMallProductListQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
Status = request.Status,
|
||||||
|
Keyword = request.Keyword
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallProductListResultResponse>.Ok(new PointMallProductListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapProduct).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询兑换商品详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("product/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallProductResponse>> ProductDetail(
|
||||||
|
[FromQuery] PointMallProductDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetPointMallProductDetailQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存兑换商品。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("product/save")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallProductResponse>> SaveProduct(
|
||||||
|
[FromBody] SavePointMallProductRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new SavePointMallProductCommand
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PointMallProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.PointMallProductId),
|
||||||
|
Name = request.Name,
|
||||||
|
ImageUrl = request.ImageUrl,
|
||||||
|
RedeemType = request.RedeemType,
|
||||||
|
ProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.ProductId),
|
||||||
|
CouponTemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.CouponTemplateId),
|
||||||
|
PhysicalName = request.PhysicalName,
|
||||||
|
PickupMethod = request.PickupMethod,
|
||||||
|
Description = request.Description,
|
||||||
|
ExchangeType = request.ExchangeType,
|
||||||
|
RequiredPoints = request.RequiredPoints,
|
||||||
|
CashAmount = request.CashAmount,
|
||||||
|
StockTotal = request.StockTotal,
|
||||||
|
PerMemberLimit = request.PerMemberLimit,
|
||||||
|
NotifyChannels = request.NotifyChannels,
|
||||||
|
Status = request.Status
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 修改兑换商品状态。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("product/status")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallProductResponse>> ChangeProductStatus(
|
||||||
|
[FromBody] ChangePointMallProductStatusRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ChangePointMallProductStatusCommand
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||||
|
Status = request.Status
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallProductResponse>.Ok(MapProduct(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除兑换商品。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("product/delete")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<object>> DeleteProduct(
|
||||||
|
[FromBody] DeletePointMallProductRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
await mediator.Send(new DeletePointMallProductCommand
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<object>.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询兑换记录分页。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("record/list")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRecordListResultResponse>> RecordList(
|
||||||
|
[FromQuery] PointMallRecordListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetPointMallRecordListQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
RedeemType = request.RedeemType,
|
||||||
|
Status = request.Status,
|
||||||
|
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||||
|
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
Page = request.Page,
|
||||||
|
PageSize = request.PageSize
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRecordListResultResponse>.Ok(new PointMallRecordListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapRecord).ToList(),
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize,
|
||||||
|
TotalCount = result.TotalCount,
|
||||||
|
Stats = new PointMallRecordStatsResponse
|
||||||
|
{
|
||||||
|
TodayRedeemCount = result.Stats.TodayRedeemCount,
|
||||||
|
PendingPhysicalCount = result.Stats.PendingPhysicalCount,
|
||||||
|
CurrentMonthUsedPoints = result.Stats.CurrentMonthUsedPoints
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询兑换记录详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("record/detail")]
|
||||||
|
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRecordDetailResponse>> RecordDetail(
|
||||||
|
[FromQuery] PointMallRecordDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new GetPointMallRecordDetailQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出兑换记录 CSV。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("record/export")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRecordExportResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRecordExportResponse>> ExportRecord(
|
||||||
|
[FromQuery] ExportPointMallRecordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new ExportPointMallRecordCsvQuery
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
RedeemType = request.RedeemType,
|
||||||
|
Status = request.Status,
|
||||||
|
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
|
||||||
|
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
|
||||||
|
Keyword = request.Keyword
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRecordExportResponse>.Ok(new PointMallRecordExportResponse
|
||||||
|
{
|
||||||
|
FileName = result.FileName,
|
||||||
|
FileContentBase64 = result.FileContentBase64,
|
||||||
|
TotalCount = result.TotalCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入兑换记录。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("record/write")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRecordResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRecordResponse>> WriteRecord(
|
||||||
|
[FromBody] WritePointMallRecordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new WritePointMallRecordCommand
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)),
|
||||||
|
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
|
||||||
|
RedeemedAt = request.RedeemedAt
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRecordResponse>.Ok(MapRecord(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 核销兑换记录。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("record/verify")]
|
||||||
|
[PermissionAuthorize(ManagePermission)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PointMallRecordDetailResponse>> VerifyRecord(
|
||||||
|
[FromBody] VerifyPointMallRecordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||||
|
|
||||||
|
var result = await mediator.Send(new VerifyPointMallRecordCommand
|
||||||
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
|
||||||
|
VerifyMethod = request.VerifyMethod,
|
||||||
|
VerifyRemark = request.VerifyRemark
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDateOrNull(string? value, string fieldName)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: StoreApiHelpers.ParseDateOnly(value, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointMallRuleResponse MapRule(MemberPointMallRuleDto source)
|
||||||
|
{
|
||||||
|
return new PointMallRuleResponse
|
||||||
|
{
|
||||||
|
StoreId = source.StoreId.ToString(),
|
||||||
|
IsConsumeRewardEnabled = source.IsConsumeRewardEnabled,
|
||||||
|
ConsumeAmountPerStep = source.ConsumeAmountPerStep,
|
||||||
|
ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep,
|
||||||
|
IsReviewRewardEnabled = source.IsReviewRewardEnabled,
|
||||||
|
ReviewRewardPoints = source.ReviewRewardPoints,
|
||||||
|
IsRegisterRewardEnabled = source.IsRegisterRewardEnabled,
|
||||||
|
RegisterRewardPoints = source.RegisterRewardPoints,
|
||||||
|
IsSigninRewardEnabled = source.IsSigninRewardEnabled,
|
||||||
|
SigninRewardPoints = source.SigninRewardPoints,
|
||||||
|
ExpiryMode = source.ExpiryMode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointMallProductResponse MapProduct(MemberPointMallProductDto source)
|
||||||
|
{
|
||||||
|
return new PointMallProductResponse
|
||||||
|
{
|
||||||
|
PointMallProductId = source.PointMallProductId.ToString(),
|
||||||
|
StoreId = source.StoreId.ToString(),
|
||||||
|
Name = source.Name,
|
||||||
|
ImageUrl = source.ImageUrl,
|
||||||
|
RedeemType = source.RedeemType,
|
||||||
|
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||||
|
ProductId = source.ProductId?.ToString(),
|
||||||
|
CouponTemplateId = source.CouponTemplateId?.ToString(),
|
||||||
|
PhysicalName = source.PhysicalName,
|
||||||
|
PickupMethod = source.PickupMethod,
|
||||||
|
Description = source.Description,
|
||||||
|
ExchangeType = source.ExchangeType,
|
||||||
|
RequiredPoints = source.RequiredPoints,
|
||||||
|
CashAmount = source.CashAmount,
|
||||||
|
StockTotal = source.StockTotal,
|
||||||
|
StockAvailable = source.StockAvailable,
|
||||||
|
RedeemedCount = source.RedeemedCount,
|
||||||
|
PerMemberLimit = source.PerMemberLimit,
|
||||||
|
NotifyChannels = source.NotifyChannels.ToList(),
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = ResolveProductStatusText(source.Status),
|
||||||
|
UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointMallRecordResponse MapRecord(MemberPointMallRecordDto source)
|
||||||
|
{
|
||||||
|
return new PointMallRecordResponse
|
||||||
|
{
|
||||||
|
RecordId = source.RecordId.ToString(),
|
||||||
|
RecordNo = source.RecordNo,
|
||||||
|
PointMallProductId = source.PointMallProductId.ToString(),
|
||||||
|
ProductName = source.ProductName,
|
||||||
|
RedeemType = source.RedeemType,
|
||||||
|
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||||
|
ExchangeType = source.ExchangeType,
|
||||||
|
MemberId = source.MemberId.ToString(),
|
||||||
|
MemberName = source.MemberName,
|
||||||
|
MemberMobileMasked = source.MemberMobileMasked,
|
||||||
|
UsedPoints = source.UsedPoints,
|
||||||
|
CashAmount = source.CashAmount,
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = ResolveRecordStatusText(source.Status),
|
||||||
|
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointMallRecordDetailResponse MapRecordDetail(MemberPointMallRecordDetailDto source)
|
||||||
|
{
|
||||||
|
var response = new PointMallRecordDetailResponse
|
||||||
|
{
|
||||||
|
RecordId = source.RecordId.ToString(),
|
||||||
|
RecordNo = source.RecordNo,
|
||||||
|
PointMallProductId = source.PointMallProductId.ToString(),
|
||||||
|
ProductName = source.ProductName,
|
||||||
|
RedeemType = source.RedeemType,
|
||||||
|
RedeemTypeText = ResolveRedeemTypeText(source.RedeemType),
|
||||||
|
ExchangeType = source.ExchangeType,
|
||||||
|
MemberId = source.MemberId.ToString(),
|
||||||
|
MemberName = source.MemberName,
|
||||||
|
MemberMobileMasked = source.MemberMobileMasked,
|
||||||
|
UsedPoints = source.UsedPoints,
|
||||||
|
CashAmount = source.CashAmount,
|
||||||
|
Status = source.Status,
|
||||||
|
StatusText = ResolveRecordStatusText(source.Status),
|
||||||
|
RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
VerifyMethod = source.VerifyMethod,
|
||||||
|
VerifyMethodText = ResolveVerifyMethodText(source.VerifyMethod),
|
||||||
|
VerifyRemark = source.VerifyRemark,
|
||||||
|
VerifiedBy = source.VerifiedBy?.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRedeemTypeText(string value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"product" => "商品",
|
||||||
|
"coupon" => "优惠券",
|
||||||
|
"physical" => "实物",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveProductStatusText(string value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"enabled" => "上架",
|
||||||
|
"disabled" => "下架",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRecordStatusText(string value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"pending_pickup" => "待领取",
|
||||||
|
"issued" => "已发放",
|
||||||
|
"completed" => "已完成",
|
||||||
|
"canceled" => "已取消",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveVerifyMethodText(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"scan" => "扫码核销",
|
||||||
|
"manual" => "手动核销",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ using Serilog;
|
|||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using TakeoutSaaS.Application.App.Common.Geo;
|
using TakeoutSaaS.Application.App.Common.Geo;
|
||||||
using TakeoutSaaS.Application.App.Extensions;
|
using TakeoutSaaS.Application.App.Extensions;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
using TakeoutSaaS.Application.Dictionary.Extensions;
|
using TakeoutSaaS.Application.Dictionary.Extensions;
|
||||||
using TakeoutSaaS.Application.Identity.Extensions;
|
using TakeoutSaaS.Application.Identity.Extensions;
|
||||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||||
|
using TakeoutSaaS.Application.Sms.Extensions;
|
||||||
using TakeoutSaaS.Application.Storage.Extensions;
|
using TakeoutSaaS.Application.Storage.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||||
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||||
@@ -22,6 +25,7 @@ using TakeoutSaaS.Module.Authorization.Extensions;
|
|||||||
using TakeoutSaaS.Module.Messaging.Extensions;
|
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||||
using TakeoutSaaS.Module.Messaging.Options;
|
using TakeoutSaaS.Module.Messaging.Options;
|
||||||
using TakeoutSaaS.Module.Scheduler.Extensions;
|
using TakeoutSaaS.Module.Scheduler.Extensions;
|
||||||
|
using TakeoutSaaS.Module.Sms.Extensions;
|
||||||
using TakeoutSaaS.Module.Storage.Extensions;
|
using TakeoutSaaS.Module.Storage.Extensions;
|
||||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
@@ -118,6 +122,7 @@ if (!string.IsNullOrWhiteSpace(redisConn))
|
|||||||
|
|
||||||
// 6. 注册应用层与基础设施(仅租户侧所需)
|
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||||
builder.Services.AddAppApplication();
|
builder.Services.AddAppApplication();
|
||||||
|
builder.Services.AddSmsApplication(builder.Configuration);
|
||||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||||
@@ -132,6 +137,7 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
|||||||
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
|
||||||
builder.Services.AddMessagingApplication();
|
builder.Services.AddMessagingApplication();
|
||||||
builder.Services.AddMessagingModule(builder.Configuration);
|
builder.Services.AddMessagingModule(builder.Configuration);
|
||||||
|
builder.Services.AddSmsModule(builder.Configuration);
|
||||||
builder.Services.AddMassTransit(configurator =>
|
builder.Services.AddMassTransit(configurator =>
|
||||||
{
|
{
|
||||||
// 注册 SignalR 推送消费者
|
// 注册 SignalR 推送消费者
|
||||||
@@ -167,6 +173,16 @@ builder.Services.AddMassTransit(configurator =>
|
|||||||
builder.Services.AddStorageModule(builder.Configuration);
|
builder.Services.AddStorageModule(builder.Configuration);
|
||||||
builder.Services.AddStorageApplication();
|
builder.Services.AddStorageApplication();
|
||||||
builder.Services.AddSchedulerModule(builder.Configuration);
|
builder.Services.AddSchedulerModule(builder.Configuration);
|
||||||
|
builder.Services.AddOptions<MemberMessagingOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection("MemberMessaging"))
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
builder.Services.AddHttpClient<IMemberMessageWeChatSender, MemberMessageWeChatSender>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
});
|
||||||
|
builder.Services.AddScoped<MemberMessageReachDispatchJobRunner>();
|
||||||
|
|
||||||
// 9.1 注册腾讯地图地理编码服务(服务端签名)
|
// 9.1 注册腾讯地图地理编码服务(服务端签名)
|
||||||
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
|
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员消息触达发送任务执行器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachDispatchJobRunner(
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
ITenantContextAccessor tenantContextAccessor,
|
||||||
|
IMemberMessageReachAppService memberMessageReachAppService,
|
||||||
|
ILogger<MemberMessageReachDispatchJobRunner> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 执行消息发送任务。
|
||||||
|
/// </summary>
|
||||||
|
[AutomaticRetry(Attempts = 0)]
|
||||||
|
public async Task ExecuteAsync(long messageId)
|
||||||
|
{
|
||||||
|
// 1. 查询任务所属租户,避免跨租户执行。
|
||||||
|
var jobMeta = await dbContext.MemberReachMessages
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Id == messageId)
|
||||||
|
.Select(item => new JobMeta(item.Id, item.TenantId))
|
||||||
|
.SingleOrDefaultAsync();
|
||||||
|
if (jobMeta is null || jobMeta.TenantId <= 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("会员消息任务不存在或租户无效,MessageId={MessageId}", messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 切换租户作用域并执行发送逻辑。
|
||||||
|
using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await memberMessageReachAppService.ExecuteDispatchAsync(jobMeta.TenantId, jobMeta.Id, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "会员消息任务执行失败,TenantId={TenantId} MessageId={MessageId}", jobMeta.TenantId, jobMeta.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record JobMeta(long Id, long TenantId);
|
||||||
|
}
|
||||||
@@ -125,6 +125,49 @@
|
|||||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Sms": {
|
||||||
|
"Provider": "Tencent",
|
||||||
|
"DefaultSignName": "外卖SaaS",
|
||||||
|
"UseMock": true,
|
||||||
|
"Tencent": {
|
||||||
|
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||||
|
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||||
|
"SdkAppId": "1400000000",
|
||||||
|
"SignName": "外卖SaaS",
|
||||||
|
"Region": "ap-beijing",
|
||||||
|
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||||
|
},
|
||||||
|
"Aliyun": {
|
||||||
|
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||||
|
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||||
|
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||||
|
"SignName": "外卖SaaS",
|
||||||
|
"Region": "cn-hangzhou"
|
||||||
|
},
|
||||||
|
"SceneTemplates": {
|
||||||
|
"login": "LOGIN_TEMPLATE_ID",
|
||||||
|
"register": "REGISTER_TEMPLATE_ID",
|
||||||
|
"reset": "RESET_TEMPLATE_ID",
|
||||||
|
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||||
|
},
|
||||||
|
"VerificationCode": {
|
||||||
|
"CodeLength": 6,
|
||||||
|
"ExpireMinutes": 5,
|
||||||
|
"CooldownSeconds": 60,
|
||||||
|
"CachePrefix": "sms:code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MemberMessaging": {
|
||||||
|
"SmsScene": "member_message",
|
||||||
|
"WeChatMini": {
|
||||||
|
"AppId": "WECHAT_MINI_APP_ID",
|
||||||
|
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||||
|
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||||
|
"PagePath": "pages/member/message-center/index",
|
||||||
|
"TitleDataKey": "thing1",
|
||||||
|
"ContentDataKey": "thing2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Scheduler": {
|
"Scheduler": {
|
||||||
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||||
"WorkerCount": 10,
|
"WorkerCount": 10,
|
||||||
|
|||||||
@@ -123,6 +123,49 @@
|
|||||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Sms": {
|
||||||
|
"Provider": "Tencent",
|
||||||
|
"DefaultSignName": "外卖SaaS",
|
||||||
|
"UseMock": false,
|
||||||
|
"Tencent": {
|
||||||
|
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||||
|
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||||
|
"SdkAppId": "1400000000",
|
||||||
|
"SignName": "外卖SaaS",
|
||||||
|
"Region": "ap-beijing",
|
||||||
|
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||||
|
},
|
||||||
|
"Aliyun": {
|
||||||
|
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||||
|
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||||
|
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||||
|
"SignName": "外卖SaaS",
|
||||||
|
"Region": "cn-hangzhou"
|
||||||
|
},
|
||||||
|
"SceneTemplates": {
|
||||||
|
"login": "LOGIN_TEMPLATE_ID",
|
||||||
|
"register": "REGISTER_TEMPLATE_ID",
|
||||||
|
"reset": "RESET_TEMPLATE_ID",
|
||||||
|
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
|
||||||
|
},
|
||||||
|
"VerificationCode": {
|
||||||
|
"CodeLength": 6,
|
||||||
|
"ExpireMinutes": 5,
|
||||||
|
"CooldownSeconds": 60,
|
||||||
|
"CachePrefix": "sms:code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MemberMessaging": {
|
||||||
|
"SmsScene": "member_message",
|
||||||
|
"WeChatMini": {
|
||||||
|
"AppId": "WECHAT_MINI_APP_ID",
|
||||||
|
"AppSecret": "WECHAT_MINI_APP_SECRET",
|
||||||
|
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
|
||||||
|
"PagePath": "pages/member/message-center/index",
|
||||||
|
"TitleDataKey": "thing1",
|
||||||
|
"ContentDataKey": "thing2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Scheduler": {
|
"Scheduler": {
|
||||||
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||||
"WorkerCount": 10,
|
"WorkerCount": 10,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using MediatR;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using TakeoutSaaS.Application.App.Common.Behaviors;
|
using TakeoutSaaS.Application.App.Common.Behaviors;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
using TakeoutSaaS.Application.App.Personal.Services;
|
using TakeoutSaaS.Application.App.Personal.Services;
|
||||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||||
using TakeoutSaaS.Application.App.Stores.Services;
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
@@ -35,6 +36,9 @@ public static class AppApplicationServiceCollectionExtensions
|
|||||||
// 2. 注册门店模块上下文服务
|
// 2. 注册门店模块上下文服务
|
||||||
services.AddScoped<StoreContextService>();
|
services.AddScoped<StoreContextService>();
|
||||||
|
|
||||||
|
// 3. (空行后) 注册会员消息触达服务
|
||||||
|
services.AddScoped<IMemberMessageReachAppService, MemberMessageReachAppService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账查询汇总行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 到账日期(UTC 日期)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ArrivedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道编码(wechat/alipay)。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public string ChannelText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易笔数。
|
||||||
|
/// </summary>
|
||||||
|
public int TransactionCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ArrivedAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账查询分页结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceSettlementListItemDto> 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>
|
||||||
|
/// 到账明细行 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementDetailItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 订单号。
|
||||||
|
/// </summary>
|
||||||
|
public string OrderNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PaidAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账明细结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementDetailResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 明细列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FinanceSettlementDetailItemDto> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账统计 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 今日到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TodayArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 昨日到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal YesterdayArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月到账金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CurrentMonthArrivedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月交易笔数。
|
||||||
|
/// </summary>
|
||||||
|
public int CurrentMonthTransactionCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账账户信息 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementAccountDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 银行名称。
|
||||||
|
/// </summary>
|
||||||
|
public string BankName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开户名。
|
||||||
|
/// </summary>
|
||||||
|
public string BankAccountName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脱敏银行账号。
|
||||||
|
/// </summary>
|
||||||
|
public string BankAccountNoMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脱敏微信商户号。
|
||||||
|
/// </summary>
|
||||||
|
public string WechatMerchantNoMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脱敏支付宝 PID。
|
||||||
|
/// </summary>
|
||||||
|
public string AlipayPidMasked { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结算周期文案。
|
||||||
|
/// </summary>
|
||||||
|
public string SettlementPeriodText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账导出 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FinanceSettlementExportDto
|
||||||
|
{
|
||||||
|
/// <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,71 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账汇总导出处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceSettlementCsvQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<ExportFinanceSettlementCsvQuery, FinanceSettlementExportDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceSettlementExportDto> Handle(
|
||||||
|
ExportFinanceSettlementCsvQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var rows = await financeTransactionRepository.ListSettlementForExportAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.StartAt,
|
||||||
|
request.EndAt,
|
||||||
|
request.PaymentMethod,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var list = rows.Select(FinanceSettlementMapping.ToListItem).ToList();
|
||||||
|
var csv = BuildCsv(list);
|
||||||
|
return new FinanceSettlementExportDto
|
||||||
|
{
|
||||||
|
FileName = $"settlement-{request.StoreId}-{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
|
||||||
|
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(csv)),
|
||||||
|
TotalCount = list.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCsv(IReadOnlyList<FinanceSettlementListItemDto> rows)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append('\uFEFF');
|
||||||
|
sb.AppendLine("到账日期,支付渠道,交易笔数,到账金额");
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
sb.AppendLine(string.Join(',',
|
||||||
|
Escape(row.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||||
|
Escape(row.ChannelText),
|
||||||
|
Escape(row.TransactionCount.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
Escape(FinanceSettlementMapping.FormatAmount(row.ArrivedAmount))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string? value)
|
||||||
|
{
|
||||||
|
var normalized = value ?? string.Empty;
|
||||||
|
if (normalized.Contains(',') || normalized.Contains('"') || normalized.Contains('\n'))
|
||||||
|
{
|
||||||
|
return $"\"{normalized.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Models;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账查询映射辅助。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FinanceSettlementMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式转渠道编码。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToChannelCode(PaymentMethod paymentMethod)
|
||||||
|
{
|
||||||
|
return paymentMethod switch
|
||||||
|
{
|
||||||
|
PaymentMethod.WeChatPay => "wechat",
|
||||||
|
PaymentMethod.Alipay => "alipay",
|
||||||
|
_ => "unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式转渠道文案。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToChannelText(PaymentMethod paymentMethod)
|
||||||
|
{
|
||||||
|
return paymentMethod switch
|
||||||
|
{
|
||||||
|
PaymentMethod.WeChatPay => "微信支付",
|
||||||
|
PaymentMethod.Alipay => "支付宝",
|
||||||
|
_ => "未知渠道"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射到账汇总行。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceSettlementListItemDto ToListItem(FinanceSettlementListItemSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceSettlementListItemDto
|
||||||
|
{
|
||||||
|
ArrivedDate = source.ArrivedDate,
|
||||||
|
Channel = ToChannelCode(source.PaymentMethod),
|
||||||
|
ChannelText = ToChannelText(source.PaymentMethod),
|
||||||
|
TransactionCount = source.TransactionCount,
|
||||||
|
ArrivedAmount = decimal.Round(source.ArrivedAmount, 2, MidpointRounding.AwayFromZero)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 映射到账明细行。
|
||||||
|
/// </summary>
|
||||||
|
public static FinanceSettlementDetailItemDto ToDetailItem(FinanceSettlementDetailItemSnapshot source)
|
||||||
|
{
|
||||||
|
return new FinanceSettlementDetailItemDto
|
||||||
|
{
|
||||||
|
OrderNo = source.OrderNo,
|
||||||
|
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
|
||||||
|
PaidAt = source.PaidAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 格式化金额(导出场景)。
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatAmount(decimal value)
|
||||||
|
{
|
||||||
|
return decimal.Round(value, 2, MidpointRounding.AwayFromZero)
|
||||||
|
.ToString("0.00", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账账户信息查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceSettlementAccountQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceSettlementAccountQuery, FinanceSettlementAccountDto?>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceSettlementAccountDto?> Handle(
|
||||||
|
GetFinanceSettlementAccountQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var snapshot = await financeTransactionRepository.GetSettlementAccountAsync(
|
||||||
|
tenantId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FinanceSettlementAccountDto
|
||||||
|
{
|
||||||
|
BankName = snapshot.BankName,
|
||||||
|
BankAccountName = snapshot.BankAccountName,
|
||||||
|
BankAccountNoMasked = snapshot.BankAccountNoMasked,
|
||||||
|
WechatMerchantNoMasked = snapshot.WechatMerchantNoMasked,
|
||||||
|
AlipayPidMasked = snapshot.AlipayPidMasked,
|
||||||
|
SettlementPeriodText = snapshot.SettlementPeriodText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账明细查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceSettlementDetailQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceSettlementDetailQuery, FinanceSettlementDetailResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceSettlementDetailResultDto> Handle(
|
||||||
|
GetFinanceSettlementDetailQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var rows = await financeTransactionRepository.GetSettlementDetailsAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.ArrivedDate,
|
||||||
|
request.PaymentMethod,
|
||||||
|
request.Take,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceSettlementDetailResultDto
|
||||||
|
{
|
||||||
|
Items = rows.Select(FinanceSettlementMapping.ToDetailItem).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账统计查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceSettlementStatsQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<GetFinanceSettlementStatsQuery, FinanceSettlementStatsDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceSettlementStatsDto> Handle(
|
||||||
|
GetFinanceSettlementStatsQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var snapshot = await financeTransactionRepository.GetSettlementStatsAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceSettlementStatsDto
|
||||||
|
{
|
||||||
|
TodayArrivedAmount = snapshot.TodayArrivedAmount,
|
||||||
|
YesterdayArrivedAmount = snapshot.YesterdayArrivedAmount,
|
||||||
|
CurrentMonthArrivedAmount = snapshot.CurrentMonthArrivedAmount,
|
||||||
|
CurrentMonthTransactionCount = snapshot.CurrentMonthTransactionCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账汇总分页查询处理器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceSettlementListQueryHandler(
|
||||||
|
IFinanceTransactionRepository financeTransactionRepository,
|
||||||
|
ITenantProvider tenantProvider)
|
||||||
|
: IRequestHandler<SearchFinanceSettlementListQuery, FinanceSettlementListResultDto>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<FinanceSettlementListResultDto> Handle(
|
||||||
|
SearchFinanceSettlementListQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||||
|
var normalizedPage = Math.Max(1, request.Page);
|
||||||
|
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||||
|
|
||||||
|
var snapshot = await financeTransactionRepository.SearchSettlementPageAsync(
|
||||||
|
tenantId,
|
||||||
|
request.StoreId,
|
||||||
|
request.StartAt,
|
||||||
|
request.EndAt,
|
||||||
|
request.PaymentMethod,
|
||||||
|
normalizedPage,
|
||||||
|
normalizedPageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new FinanceSettlementListResultDto
|
||||||
|
{
|
||||||
|
Items = snapshot.Items.Select(FinanceSettlementMapping.ToListItem).ToList(),
|
||||||
|
Total = snapshot.TotalCount,
|
||||||
|
Page = normalizedPage,
|
||||||
|
PageSize = normalizedPageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出到账汇总 CSV。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportFinanceSettlementCsvQuery : IRequest<FinanceSettlementExportDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始时间(UTC,闭区间)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束时间(UTC,开区间)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? EndAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式筛选。
|
||||||
|
/// </summary>
|
||||||
|
public PaymentMethod? PaymentMethod { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账账户信息。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceSettlementAccountQuery : IRequest<FinanceSettlementAccountDto?>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账明细。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceSettlementDetailQuery : IRequest<FinanceSettlementDetailResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账日期(UTC 日期)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ArrivedDate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道(微信/支付宝)。
|
||||||
|
/// </summary>
|
||||||
|
public PaymentMethod PaymentMethod { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 限制条数。
|
||||||
|
/// </summary>
|
||||||
|
public int Take { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账统计。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetFinanceSettlementStatsQuery : IRequest<FinanceSettlementStatsDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Payments.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询到账汇总分页。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchFinanceSettlementListQuery : IRequest<FinanceSettlementListResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始时间(UTC,闭区间)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结束时间(UTC,开区间)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? EndAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 支付方式筛选。
|
||||||
|
/// </summary>
|
||||||
|
public PaymentMethod? PaymentMethod { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息触达统计 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本月发送消息条数。
|
||||||
|
/// </summary>
|
||||||
|
public int MonthlySentCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int ReachMemberCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开率百分比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal OpenRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化率百分比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConversionRate { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息列表结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MemberMessageReachListItemDto> Items { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息列表项 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachListItemDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MessageId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标描述。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预计触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int EstimatedReachCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? SentAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ScheduledAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开率百分比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal OpenRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化率百分比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConversionRate { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息详情 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachDetailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MessageId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息正文。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标类型。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AudienceTags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标描述。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预计触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int EstimatedReachCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间类型。
|
||||||
|
/// </summary>
|
||||||
|
public string ScheduleType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ScheduledAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实际发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? SentAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送成功数量。
|
||||||
|
/// </summary>
|
||||||
|
public int SentCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已读数量。
|
||||||
|
/// </summary>
|
||||||
|
public int ReadCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化数量。
|
||||||
|
/// </summary>
|
||||||
|
public int ConvertedCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开率百分比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal OpenRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化率百分比(0-100)。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ConversionRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后错误信息。
|
||||||
|
/// </summary>
|
||||||
|
public string? LastError { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收件明细。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MemberMessageReachRecipientDto> Recipients { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收件明细 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachRecipientDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MemberId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手机号快照。
|
||||||
|
/// </summary>
|
||||||
|
public string? Mobile { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenId 快照。
|
||||||
|
/// </summary>
|
||||||
|
public string? OpenId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? SentAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已读时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ReadAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 转化时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ConvertedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 失败信息。
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板列表结果 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageTemplateListResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表项。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MemberMessageTemplateDto> Items { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageTemplateDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板标识。
|
||||||
|
/// </summary>
|
||||||
|
public long TemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板分类。
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用次数。
|
||||||
|
/// </summary>
|
||||||
|
public int UsageCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最近使用时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastUsedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标人群估算 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageAudienceEstimateDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 预计触达人数。
|
||||||
|
/// </summary>
|
||||||
|
public int ReachCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息调度元信息 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageDispatchMetaDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息标识。
|
||||||
|
/// </summary>
|
||||||
|
public long MessageId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送状态。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间类型。
|
||||||
|
/// </summary>
|
||||||
|
public string ScheduleType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ScheduledAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hangfire 任务 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? HangfireJobId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存消息请求输入。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberMessageInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 消息标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? MessageId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标类型。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标标签。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AudienceTags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送时间类型。
|
||||||
|
/// </summary>
|
||||||
|
public string ScheduleType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时发送时间(UTC)。
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ScheduledAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提交动作(draft/send)。
|
||||||
|
/// </summary>
|
||||||
|
public string SubmitAction { get; init; } = "draft";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索消息输入。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchMemberMessageInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 状态过滤。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道过滤。
|
||||||
|
/// </summary>
|
||||||
|
public string? Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题关键词。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索模板输入。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchMemberMessageTemplateInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分类。
|
||||||
|
/// </summary>
|
||||||
|
public string? Category { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键词。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存模板输入。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveMemberMessageTemplateInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 模板标识。
|
||||||
|
/// </summary>
|
||||||
|
public long? TemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板分类。
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板内容。
|
||||||
|
/// </summary>
|
||||||
|
public string Content { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 估算人群输入。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageAudienceEstimateInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 目标类型。
|
||||||
|
/// </summary>
|
||||||
|
public string AudienceType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标签列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Enums;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach;
|
||||||
|
|
||||||
|
internal static class MemberMessageReachMapping
|
||||||
|
{
|
||||||
|
internal static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static MemberMessageAudienceType ParseAudienceType(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"all" => MemberMessageAudienceType.All,
|
||||||
|
"tag" => MemberMessageAudienceType.Tags,
|
||||||
|
"tags" => MemberMessageAudienceType.Tags,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "audienceType 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToAudienceTypeText(MemberMessageAudienceType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
MemberMessageAudienceType.All => "all",
|
||||||
|
MemberMessageAudienceType.Tags => "tag",
|
||||||
|
_ => "all"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageScheduleType ParseScheduleType(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"immediate" => MemberMessageScheduleType.Immediate,
|
||||||
|
"scheduled" => MemberMessageScheduleType.Scheduled,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "scheduleType 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToScheduleTypeText(MemberMessageScheduleType value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
MemberMessageScheduleType.Immediate => "immediate",
|
||||||
|
MemberMessageScheduleType.Scheduled => "scheduled",
|
||||||
|
_ => "immediate"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageStatus ParseStatusOrNull(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"draft" => MemberMessageStatus.Draft,
|
||||||
|
"pending" => MemberMessageStatus.Pending,
|
||||||
|
"sending" => MemberMessageStatus.Sending,
|
||||||
|
"sent" => MemberMessageStatus.Sent,
|
||||||
|
"failed" => MemberMessageStatus.Failed,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageStatus? TryParseStatus(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseStatusOrNull(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToStatusText(MemberMessageStatus value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
MemberMessageStatus.Draft => "draft",
|
||||||
|
MemberMessageStatus.Pending => "pending",
|
||||||
|
MemberMessageStatus.Sending => "sending",
|
||||||
|
MemberMessageStatus.Sent => "sent",
|
||||||
|
MemberMessageStatus.Failed => "failed",
|
||||||
|
_ => "draft"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageChannel ParseChannel(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"inapp" => MemberMessageChannel.InApp,
|
||||||
|
"sms" => MemberMessageChannel.Sms,
|
||||||
|
"wechat-mini" => MemberMessageChannel.WeChatMini,
|
||||||
|
"wechat" => MemberMessageChannel.WeChatMini,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageChannel? TryParseChannel(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseChannel(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToChannelText(MemberMessageChannel value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
MemberMessageChannel.InApp => "inapp",
|
||||||
|
MemberMessageChannel.Sms => "sms",
|
||||||
|
MemberMessageChannel.WeChatMini => "wechat-mini",
|
||||||
|
_ => "inapp"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToRecipientStatusText(MemberMessageRecipientStatus value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
MemberMessageRecipientStatus.Pending => "pending",
|
||||||
|
MemberMessageRecipientStatus.Sent => "sent",
|
||||||
|
MemberMessageRecipientStatus.Failed => "failed",
|
||||||
|
_ => "pending"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageTemplateCategory ParseTemplateCategory(string? value)
|
||||||
|
{
|
||||||
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"marketing" => MemberMessageTemplateCategory.Marketing,
|
||||||
|
"notice" => MemberMessageTemplateCategory.Notice,
|
||||||
|
"recall" => MemberMessageTemplateCategory.Recall,
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageTemplateCategory? TryParseTemplateCategory(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseTemplateCategory(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToTemplateCategoryText(MemberMessageTemplateCategory value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
MemberMessageTemplateCategory.Marketing => "marketing",
|
||||||
|
MemberMessageTemplateCategory.Notice => "notice",
|
||||||
|
MemberMessageTemplateCategory.Recall => "recall",
|
||||||
|
_ => "notice"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<string> NormalizeTags(IReadOnlyList<string>? tags)
|
||||||
|
{
|
||||||
|
return (tags ?? [])
|
||||||
|
.Select(item => (item ?? string.Empty).Trim())
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<string> NormalizeChannels(IReadOnlyList<string>? channels)
|
||||||
|
{
|
||||||
|
var parsed = (channels ?? [])
|
||||||
|
.Select(ParseChannel)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (parsed.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.Select(ToChannelText).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string SerializeStringArray(IReadOnlyList<string> source)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(source, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<string> DeserializeStringArray(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<string>>(value, JsonOptions)?
|
||||||
|
.Select(item => (item ?? string.Empty).Trim())
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList() ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static decimal ResolveRatePercent(int numerator, int denominator)
|
||||||
|
{
|
||||||
|
if (denominator <= 0 || numerator <= 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimal.Round((decimal)numerator * 100m / denominator, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageTemplateDto ToTemplateDto(MemberMessageTemplate source)
|
||||||
|
{
|
||||||
|
return new MemberMessageTemplateDto
|
||||||
|
{
|
||||||
|
TemplateId = source.Id,
|
||||||
|
Name = source.Name,
|
||||||
|
Category = ToTemplateCategoryText(source.Category),
|
||||||
|
Content = source.Content,
|
||||||
|
UsageCount = source.UsageCount,
|
||||||
|
LastUsedAt = source.LastUsedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MemberMessageReachRecipientDto ToRecipientDto(MemberReachRecipient source)
|
||||||
|
{
|
||||||
|
return new MemberMessageReachRecipientDto
|
||||||
|
{
|
||||||
|
MemberId = source.MemberId,
|
||||||
|
Channel = ToChannelText(source.Channel),
|
||||||
|
Status = ToRecipientStatusText(source.Status),
|
||||||
|
Mobile = source.Mobile,
|
||||||
|
OpenId = source.OpenId,
|
||||||
|
SentAt = source.SentAt,
|
||||||
|
ReadAt = source.ReadAt,
|
||||||
|
ConvertedAt = source.ConvertedAt,
|
||||||
|
ErrorMessage = source.ErrorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员消息模块配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessagingOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会员消息短信场景码。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string SmsScene { get; set; } = "member_message";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信小程序发送配置。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public MemberMessagingWeChatMiniOptions WeChatMini { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信小程序消息发送配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessagingWeChatMiniOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 小程序 AppId。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string AppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 小程序 AppSecret。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string AppSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订阅消息模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string SubscribeTemplateId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 小程序跳转页面路径。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string PagePath { get; set; } = "pages/index/index";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题字段键名。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string TitleDataKey { get; set; } = "thing1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内容字段键名。
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string ContentDataKey { get; set; } = "thing2";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员消息触达应用服务。
|
||||||
|
/// </summary>
|
||||||
|
public interface IMemberMessageReachAppService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取月度统计。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageReachStatsDto> GetStatsAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询消息。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageReachListResultDto> SearchMessagesAsync(
|
||||||
|
long tenantId,
|
||||||
|
SearchMemberMessageInput input,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取消息详情。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageReachDetailDto?> GetMessageDetailAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取消息调度元信息。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageDispatchMetaDto?> GetDispatchMetaAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存消息草稿或发送任务。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageDispatchMetaDto> SaveMessageAsync(
|
||||||
|
long tenantId,
|
||||||
|
SaveMemberMessageInput input,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 绑定消息对应的 Hangfire 任务 ID。
|
||||||
|
/// </summary>
|
||||||
|
Task BindDispatchJobAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
string? hangfireJobId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除消息并返回原任务 ID。
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> DeleteMessageAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 估算目标人群数量。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageAudienceEstimateDto> EstimateAudienceAsync(
|
||||||
|
long tenantId,
|
||||||
|
MemberMessageAudienceEstimateInput input,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询模板。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageTemplateListResultDto> SearchTemplatesAsync(
|
||||||
|
long tenantId,
|
||||||
|
SearchMemberMessageTemplateInput input,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取模板详情。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageTemplateDto?> GetTemplateAsync(
|
||||||
|
long tenantId,
|
||||||
|
long templateId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存模板。
|
||||||
|
/// </summary>
|
||||||
|
Task<MemberMessageTemplateDto> SaveTemplateAsync(
|
||||||
|
long tenantId,
|
||||||
|
SaveMemberMessageTemplateInput input,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除模板。
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteTemplateAsync(
|
||||||
|
long tenantId,
|
||||||
|
long templateId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行消息发送。
|
||||||
|
/// </summary>
|
||||||
|
Task ExecuteDispatchAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信小程序订阅消息发送器。
|
||||||
|
/// </summary>
|
||||||
|
public interface IMemberMessageWeChatSender
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发送微信订阅消息。
|
||||||
|
/// </summary>
|
||||||
|
Task SendAsync(
|
||||||
|
string openId,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||||
|
using TakeoutSaaS.Module.Sms.Abstractions;
|
||||||
|
using TakeoutSaaS.Module.Sms.Models;
|
||||||
|
using TakeoutSaaS.Module.Sms.Options;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会员消息触达应用服务实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageReachAppService(
|
||||||
|
IMemberMessageReachRepository memberMessageReachRepository,
|
||||||
|
IMemberRepository memberRepository,
|
||||||
|
IMiniUserRepository miniUserRepository,
|
||||||
|
ISmsSenderResolver smsSenderResolver,
|
||||||
|
IOptionsMonitor<SmsOptions> smsOptionsMonitor,
|
||||||
|
IOptionsMonitor<MemberMessagingOptions> memberMessagingOptionsMonitor,
|
||||||
|
IMemberMessageWeChatSender memberMessageWeChatSender,
|
||||||
|
ILogger<MemberMessageReachAppService> logger)
|
||||||
|
: IMemberMessageReachAppService
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> AudienceTagAliasMap = BuildAudienceTagAliasMap();
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> AudienceTagDisplayMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["highfrequency"] = "高频客户",
|
||||||
|
["newcustomer"] = "新客",
|
||||||
|
["dormant"] = "沉睡客户",
|
||||||
|
["lost"] = "流失客户",
|
||||||
|
["lunchregular"] = "午餐常客",
|
||||||
|
["highspend"] = "大额消费"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlySet<string> EmptyTagSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageReachStatsDto> GetStatsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 校验租户上下文。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
|
||||||
|
// 2. 读取当月统计快照并计算转化率。
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var monthStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var monthEnd = monthStart.AddMonths(1);
|
||||||
|
var snapshot = await memberMessageReachRepository.GetMonthlyStatsAsync(tenantId, monthStart, monthEnd, cancellationToken);
|
||||||
|
var openRate = MemberMessageReachMapping.ResolveRatePercent(snapshot.ReadRecipientCount, snapshot.SentRecipientCount);
|
||||||
|
var conversionRate = MemberMessageReachMapping.ResolveRatePercent(snapshot.ConvertedRecipientCount, snapshot.SentRecipientCount);
|
||||||
|
|
||||||
|
// 3. 返回页面统计 DTO。
|
||||||
|
return new MemberMessageReachStatsDto
|
||||||
|
{
|
||||||
|
MonthlySentCount = snapshot.SentMessageCount,
|
||||||
|
ReachMemberCount = snapshot.ReachMemberCount,
|
||||||
|
OpenRate = openRate,
|
||||||
|
ConversionRate = conversionRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageReachListResultDto> SearchMessagesAsync(
|
||||||
|
long tenantId,
|
||||||
|
SearchMemberMessageInput input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 校验租户与查询参数。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var page = input.Page <= 0 ? 1 : input.Page;
|
||||||
|
var pageSize = Math.Clamp(input.PageSize, 1, 100);
|
||||||
|
var status = MemberMessageReachMapping.TryParseStatus(input.Status);
|
||||||
|
var channel = MemberMessageReachMapping.TryParseChannel(input.Channel);
|
||||||
|
|
||||||
|
// 2. 调用仓储分页查询。
|
||||||
|
var (items, total) = await memberMessageReachRepository.SearchMessagesAsync(
|
||||||
|
tenantId,
|
||||||
|
status,
|
||||||
|
channel,
|
||||||
|
input.Keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 3. 映射分页结果。
|
||||||
|
return new MemberMessageReachListResultDto
|
||||||
|
{
|
||||||
|
Items = items.Select(ToMessageListItem).ToList(),
|
||||||
|
TotalCount = total,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageReachDetailDto?> GetMessageDetailAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询主消息记录。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询并映射收件明细。
|
||||||
|
var recipients = await memberMessageReachRepository.GetRecipientsAsync(tenantId, messageId, cancellationToken);
|
||||||
|
var channels = MemberMessageReachMapping.DeserializeStringArray(message.ChannelsJson);
|
||||||
|
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(message.AudienceTagsJson);
|
||||||
|
|
||||||
|
// 3. 返回详情数据。
|
||||||
|
return new MemberMessageReachDetailDto
|
||||||
|
{
|
||||||
|
MessageId = message.Id,
|
||||||
|
TemplateId = message.TemplateId,
|
||||||
|
Title = message.Title,
|
||||||
|
Content = message.Content,
|
||||||
|
Channels = channels,
|
||||||
|
AudienceType = MemberMessageReachMapping.ToAudienceTypeText(message.AudienceType),
|
||||||
|
AudienceTags = audienceTags,
|
||||||
|
AudienceText = BuildAudienceText(message.AudienceType, audienceTags, message.EstimatedReachCount),
|
||||||
|
EstimatedReachCount = message.EstimatedReachCount,
|
||||||
|
ScheduleType = MemberMessageReachMapping.ToScheduleTypeText(message.ScheduleType),
|
||||||
|
ScheduledAt = message.ScheduledAt,
|
||||||
|
Status = MemberMessageReachMapping.ToStatusText(message.Status),
|
||||||
|
SentAt = message.SentAt,
|
||||||
|
SentCount = message.SentCount,
|
||||||
|
ReadCount = message.ReadCount,
|
||||||
|
ConvertedCount = message.ConvertedCount,
|
||||||
|
OpenRate = MemberMessageReachMapping.ResolveRatePercent(message.ReadCount, message.SentCount),
|
||||||
|
ConversionRate = MemberMessageReachMapping.ResolveRatePercent(message.ConvertedCount, message.SentCount),
|
||||||
|
LastError = message.LastError,
|
||||||
|
Recipients = recipients.Select(MemberMessageReachMapping.ToRecipientDto).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageDispatchMetaDto?> GetDispatchMetaAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询消息并返回调度元数据。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToDispatchMeta(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageDispatchMetaDto> SaveMessageAsync(
|
||||||
|
long tenantId,
|
||||||
|
SaveMemberMessageInput input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 入参校验与基础归一化。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var submitAction = NormalizeSubmitAction(input.SubmitAction);
|
||||||
|
var title = NormalizeRequiredText(input.Title, 128, nameof(input.Title));
|
||||||
|
var content = NormalizeRequiredText(input.Content, 4096, nameof(input.Content));
|
||||||
|
var channels = MemberMessageReachMapping.NormalizeChannels(input.Channels);
|
||||||
|
var audienceType = MemberMessageReachMapping.ParseAudienceType(input.AudienceType);
|
||||||
|
var audienceTags = MemberMessageReachMapping.NormalizeTags(input.AudienceTags);
|
||||||
|
var scheduleType = MemberMessageReachMapping.ParseScheduleType(input.ScheduleType);
|
||||||
|
var scheduledAt = NormalizeScheduledAt(scheduleType, submitAction, input.ScheduledAt);
|
||||||
|
if (audienceType == MemberMessageAudienceType.Tags && audienceTags.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "按标签筛选时至少选择一个标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 估算目标人群并读取/创建消息实体。
|
||||||
|
var profiles = await ResolveAudienceProfilesAsync(tenantId, audienceType, audienceTags, cancellationToken);
|
||||||
|
var estimatedReachCount = profiles.Count;
|
||||||
|
var isNew = !input.MessageId.HasValue;
|
||||||
|
MemberReachMessage message;
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
message = new MemberReachMessage
|
||||||
|
{
|
||||||
|
TenantId = tenantId
|
||||||
|
};
|
||||||
|
await memberMessageReachRepository.AddMessageAsync(message, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, input.MessageId!.Value, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
|
||||||
|
EnsureMessageEditable(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 覆盖消息字段并重置发送态字段。
|
||||||
|
message.StoreId = input.StoreId;
|
||||||
|
message.TemplateId = input.TemplateId;
|
||||||
|
message.Title = title;
|
||||||
|
message.Content = content;
|
||||||
|
message.ChannelsJson = MemberMessageReachMapping.SerializeStringArray(channels);
|
||||||
|
message.AudienceType = audienceType;
|
||||||
|
message.AudienceTagsJson = MemberMessageReachMapping.SerializeStringArray(audienceTags);
|
||||||
|
message.EstimatedReachCount = estimatedReachCount;
|
||||||
|
message.ScheduleType = scheduleType;
|
||||||
|
message.ScheduledAt = scheduleType == MemberMessageScheduleType.Scheduled ? scheduledAt : null;
|
||||||
|
message.Status = submitAction == "send" ? MemberMessageStatus.Pending : MemberMessageStatus.Draft;
|
||||||
|
message.HangfireJobId = null;
|
||||||
|
message.SentAt = null;
|
||||||
|
message.SentCount = 0;
|
||||||
|
message.ReadCount = 0;
|
||||||
|
message.ConvertedCount = 0;
|
||||||
|
message.LastError = null;
|
||||||
|
|
||||||
|
// 4. 编辑场景清理旧收件记录,确保再次发送时数据一致。
|
||||||
|
if (!isNew)
|
||||||
|
{
|
||||||
|
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, message.Id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 持久化并返回调度信息。
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
return ToDispatchMeta(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task BindDispatchJobAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
string? hangfireJobId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询并绑定任务 ID。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
|
||||||
|
message.HangfireJobId = string.IsNullOrWhiteSpace(hangfireJobId)
|
||||||
|
? null
|
||||||
|
: Truncate(hangfireJobId.Trim(), 64);
|
||||||
|
|
||||||
|
// 2. 保存更新结果。
|
||||||
|
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> DeleteMessageAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询待删除消息并校验状态。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
|
||||||
|
if (message.Status == MemberMessageStatus.Sending)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "消息发送中,暂不允许删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 先删收件明细,再删主记录。
|
||||||
|
var oldHangfireJobId = message.HangfireJobId;
|
||||||
|
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, messageId, cancellationToken);
|
||||||
|
await memberMessageReachRepository.DeleteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
|
// 3. 持久化删除。
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
return oldHangfireJobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageAudienceEstimateDto> EstimateAudienceAsync(
|
||||||
|
long tenantId,
|
||||||
|
MemberMessageAudienceEstimateInput input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 解析目标规则。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var audienceType = MemberMessageReachMapping.ParseAudienceType(input.AudienceType);
|
||||||
|
var tags = MemberMessageReachMapping.NormalizeTags(input.Tags);
|
||||||
|
if (audienceType == MemberMessageAudienceType.Tags && tags.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "按标签筛选时至少选择一个标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 计算可触达人数。
|
||||||
|
var profiles = await ResolveAudienceProfilesAsync(tenantId, audienceType, tags, cancellationToken);
|
||||||
|
return new MemberMessageAudienceEstimateDto
|
||||||
|
{
|
||||||
|
ReachCount = profiles.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageTemplateListResultDto> SearchTemplatesAsync(
|
||||||
|
long tenantId,
|
||||||
|
SearchMemberMessageTemplateInput input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 归一化分页参数。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var page = input.Page <= 0 ? 1 : input.Page;
|
||||||
|
var pageSize = Math.Clamp(input.PageSize, 1, 100);
|
||||||
|
var category = MemberMessageReachMapping.TryParseTemplateCategory(input.Category);
|
||||||
|
|
||||||
|
// 2. 分页查询模板。
|
||||||
|
var (items, total) = await memberMessageReachRepository.SearchTemplatesAsync(
|
||||||
|
tenantId,
|
||||||
|
category,
|
||||||
|
input.Keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 3. 映射并返回。
|
||||||
|
return new MemberMessageTemplateListResultDto
|
||||||
|
{
|
||||||
|
Items = items.Select(MemberMessageReachMapping.ToTemplateDto).ToList(),
|
||||||
|
TotalCount = total,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageTemplateDto?> GetTemplateAsync(
|
||||||
|
long tenantId,
|
||||||
|
long templateId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询模板详情。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, templateId, cancellationToken);
|
||||||
|
return template is null ? null : MemberMessageReachMapping.ToTemplateDto(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<MemberMessageTemplateDto> SaveTemplateAsync(
|
||||||
|
long tenantId,
|
||||||
|
SaveMemberMessageTemplateInput input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 校验并归一化模板入参。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var name = NormalizeRequiredText(input.Name, 64, nameof(input.Name));
|
||||||
|
var content = NormalizeRequiredText(input.Content, 4096, nameof(input.Content));
|
||||||
|
var category = MemberMessageReachMapping.ParseTemplateCategory(input.Category);
|
||||||
|
|
||||||
|
// 2. 校验同租户模板名称唯一。
|
||||||
|
var existingTemplateByName = await memberMessageReachRepository.FindTemplateByNameAsync(tenantId, name, cancellationToken);
|
||||||
|
if (existingTemplateByName is not null && existingTemplateByName.Id != input.TemplateId.GetValueOrDefault())
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "模板名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查询或创建模板实体。
|
||||||
|
var isNew = !input.TemplateId.HasValue;
|
||||||
|
MemberMessageTemplate template;
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
template = new MemberMessageTemplate
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UsageCount = 0
|
||||||
|
};
|
||||||
|
await memberMessageReachRepository.AddTemplateAsync(template, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, input.TemplateId!.Value, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "模板不存在");
|
||||||
|
await memberMessageReachRepository.UpdateTemplateAsync(template, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 赋值并保存模板。
|
||||||
|
template.Name = name;
|
||||||
|
template.Content = content;
|
||||||
|
template.Category = category;
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
return MemberMessageReachMapping.ToTemplateDto(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeleteTemplateAsync(
|
||||||
|
long tenantId,
|
||||||
|
long templateId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询模板并执行删除。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, templateId, cancellationToken)
|
||||||
|
?? throw new BusinessException(ErrorCodes.NotFound, "模板不存在");
|
||||||
|
await memberMessageReachRepository.DeleteTemplateAsync(template, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 持久化删除。
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ExecuteDispatchAsync(
|
||||||
|
long tenantId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 查询消息并校验状态。
|
||||||
|
EnsureTenantId(tenantId);
|
||||||
|
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("消息发送任务未找到消息记录,TenantId={TenantId} MessageId={MessageId}", tenantId, messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Status == MemberMessageStatus.Sending || message.Status == MemberMessageStatus.Sent)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"消息发送任务跳过,状态无需发送,TenantId={TenantId} MessageId={MessageId} Status={Status}",
|
||||||
|
tenantId,
|
||||||
|
messageId,
|
||||||
|
message.Status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Status != MemberMessageStatus.Pending)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"消息发送任务跳过,状态非待发送,TenantId={TenantId} MessageId={MessageId} Status={Status}",
|
||||||
|
tenantId,
|
||||||
|
messageId,
|
||||||
|
message.Status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 将消息状态推进为发送中并清理旧任务标识。
|
||||||
|
message.Status = MemberMessageStatus.Sending;
|
||||||
|
message.HangfireJobId = null;
|
||||||
|
message.LastError = null;
|
||||||
|
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 3. 解析目标人群与渠道配置。
|
||||||
|
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(message.AudienceTagsJson);
|
||||||
|
var audienceProfiles = await ResolveAudienceProfilesAsync(tenantId, message.AudienceType, audienceTags, cancellationToken);
|
||||||
|
var channels = MemberMessageReachMapping.DeserializeStringArray(message.ChannelsJson)
|
||||||
|
.Select(MemberMessageReachMapping.ParseChannel)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (channels.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "消息渠道为空,无法执行发送");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 清理旧收件明细并准备渠道所需映射。
|
||||||
|
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, messageId, cancellationToken);
|
||||||
|
var openIdMap = await ResolveMiniUserOpenIdMapAsync(tenantId, audienceProfiles, cancellationToken);
|
||||||
|
|
||||||
|
// 5. 按“会员 x 渠道”创建发送明细。
|
||||||
|
var recipients = new List<MemberReachRecipient>(Math.Max(1, audienceProfiles.Count * channels.Count));
|
||||||
|
var errorMessages = new List<string>();
|
||||||
|
foreach (var profile in audienceProfiles)
|
||||||
|
{
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
var recipient = new MemberReachRecipient
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
MessageId = messageId,
|
||||||
|
MemberId = profile.Id,
|
||||||
|
Channel = channel,
|
||||||
|
Status = MemberMessageRecipientStatus.Pending,
|
||||||
|
Mobile = string.IsNullOrWhiteSpace(profile.Mobile) ? null : profile.Mobile.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 5.1 按渠道执行真实发送。
|
||||||
|
switch (channel)
|
||||||
|
{
|
||||||
|
case MemberMessageChannel.InApp:
|
||||||
|
recipient.Status = MemberMessageRecipientStatus.Sent;
|
||||||
|
recipient.SentAt = DateTime.UtcNow;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MemberMessageChannel.Sms:
|
||||||
|
{
|
||||||
|
var phone = NormalizePhoneNumber(profile.Mobile);
|
||||||
|
if (string.IsNullOrWhiteSpace(phone))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "会员手机号为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient.Mobile = phone;
|
||||||
|
await SendSmsAsync(phone, message.Title, message.Content, cancellationToken);
|
||||||
|
recipient.Status = MemberMessageRecipientStatus.Sent;
|
||||||
|
recipient.SentAt = DateTime.UtcNow;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MemberMessageChannel.WeChatMini:
|
||||||
|
{
|
||||||
|
if (!openIdMap.TryGetValue(profile.UserId, out var openId) || string.IsNullOrWhiteSpace(openId))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "会员未绑定小程序 OpenId");
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient.OpenId = openId;
|
||||||
|
await memberMessageWeChatSender.SendAsync(openId, message.Title, message.Content, cancellationToken);
|
||||||
|
recipient.Status = MemberMessageRecipientStatus.Sent;
|
||||||
|
recipient.SentAt = DateTime.UtcNow;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "不支持的消息渠道");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 5.2 单个收件人发送失败不影响整体流程,保留失败明细。
|
||||||
|
recipient.Status = MemberMessageRecipientStatus.Failed;
|
||||||
|
recipient.ErrorMessage = Truncate(CleanupErrorMessage(ex.Message), 512);
|
||||||
|
errorMessages.Add($"会员{profile.Id}-{MemberMessageReachMapping.ToChannelText(channel)}:{recipient.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients.Add(recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 写入收件明细并回填消息统计。
|
||||||
|
await memberMessageReachRepository.AddRecipientsAsync(recipients, cancellationToken);
|
||||||
|
message.EstimatedReachCount = audienceProfiles.Count;
|
||||||
|
message.SentCount = recipients.Count(item => item.Status == MemberMessageRecipientStatus.Sent);
|
||||||
|
message.ReadCount = recipients.Count(item => item.ReadAt.HasValue);
|
||||||
|
message.ConvertedCount = recipients.Count(item => item.ConvertedAt.HasValue);
|
||||||
|
message.SentAt = DateTime.UtcNow;
|
||||||
|
message.Status = message.SentCount > 0 ? MemberMessageStatus.Sent : MemberMessageStatus.Failed;
|
||||||
|
message.LastError = BuildErrorSummary(errorMessages);
|
||||||
|
|
||||||
|
// 7. 若使用模板发送,更新模板使用次数。
|
||||||
|
if (message.TemplateId.HasValue)
|
||||||
|
{
|
||||||
|
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, message.TemplateId.Value, cancellationToken);
|
||||||
|
if (template is not null)
|
||||||
|
{
|
||||||
|
template.UsageCount += 1;
|
||||||
|
template.LastUsedAt = DateTime.UtcNow;
|
||||||
|
await memberMessageReachRepository.UpdateTemplateAsync(template, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 保存最终状态。
|
||||||
|
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 9. 全局异常兜底写失败态,并保留错误摘要。
|
||||||
|
logger.LogError(ex, "执行会员消息发送失败,TenantId={TenantId} MessageId={MessageId}", tenantId, messageId);
|
||||||
|
message.Status = MemberMessageStatus.Failed;
|
||||||
|
message.SentAt = DateTime.UtcNow;
|
||||||
|
message.SentCount = 0;
|
||||||
|
message.ReadCount = 0;
|
||||||
|
message.ConvertedCount = 0;
|
||||||
|
message.LastError = Truncate(CleanupErrorMessage(ex.Message), 1024);
|
||||||
|
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
|
||||||
|
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendSmsAsync(
|
||||||
|
string phoneNumber,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 读取短信模板配置并解析场景模板编码。
|
||||||
|
var smsOptions = smsOptionsMonitor.CurrentValue;
|
||||||
|
var messageOptions = memberMessagingOptionsMonitor.CurrentValue;
|
||||||
|
var smsScene = string.IsNullOrWhiteSpace(messageOptions.SmsScene)
|
||||||
|
? "member_message"
|
||||||
|
: messageOptions.SmsScene.Trim();
|
||||||
|
if (!smsOptions.SceneTemplates.TryGetValue(smsScene, out var templateCode) ||
|
||||||
|
string.IsNullOrWhiteSpace(templateCode))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, $"未配置短信模板场景:{smsScene}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 组装变量并调用短信通道发送。
|
||||||
|
var sender = smsSenderResolver.Resolve();
|
||||||
|
var variables = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["title"] = Truncate(title.Trim(), 20),
|
||||||
|
["content"] = Truncate(content.Trim(), 64)
|
||||||
|
};
|
||||||
|
var request = new SmsSendRequest(phoneNumber, templateCode, variables, smsOptions.DefaultSignName);
|
||||||
|
var result = await sender.SendAsync(request, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{result.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<MemberProfile>> ResolveAudienceProfilesAsync(
|
||||||
|
long tenantId,
|
||||||
|
MemberMessageAudienceType audienceType,
|
||||||
|
IReadOnlyList<string> tags,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 获取租户全部会员。
|
||||||
|
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
||||||
|
if (profiles.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 全量人群直接返回。
|
||||||
|
if (audienceType == MemberMessageAudienceType.All)
|
||||||
|
{
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 标签人群解析标签规则并构建会员标签映射。
|
||||||
|
var normalizedInputTags = tags
|
||||||
|
.Select(ToCanonicalAudienceTag)
|
||||||
|
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (normalizedInputTags.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var profileIds = profiles.Select(profile => profile.Id).ToList();
|
||||||
|
var profileTags = await memberRepository.GetProfileTagsByMemberIdsAsync(tenantId, profileIds, cancellationToken);
|
||||||
|
var profileTagLookup = profileTags
|
||||||
|
.GroupBy(item => item.MemberProfileId)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => group
|
||||||
|
.Select(tag => ToCanonicalAudienceTag(tag.TagName))
|
||||||
|
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// 4. 应用固定规则筛选目标会员。
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var selected = profiles
|
||||||
|
.Where(profile =>
|
||||||
|
{
|
||||||
|
var tagsOfProfile = profileTagLookup.TryGetValue(profile.Id, out var set)
|
||||||
|
? set
|
||||||
|
: EmptyTagSet;
|
||||||
|
|
||||||
|
return normalizedInputTags.Any(tag => MatchesAudienceTag(profile, tagsOfProfile, tag, now));
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<long, string>> ResolveMiniUserOpenIdMapAsync(
|
||||||
|
long tenantId,
|
||||||
|
IReadOnlyList<MemberProfile> profiles,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 收集会员关联的小程序用户标识。
|
||||||
|
var miniUserIds = profiles
|
||||||
|
.Select(profile => profile.UserId)
|
||||||
|
.Where(userId => userId > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (miniUserIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 批量查询并映射 OpenId。
|
||||||
|
var miniUsers = await miniUserRepository.GetByIdsAsync(miniUserIds, tenantId, cancellationToken);
|
||||||
|
return miniUsers
|
||||||
|
.Where(user => !string.IsNullOrWhiteSpace(user.OpenId))
|
||||||
|
.ToDictionary(user => user.Id, user => user.OpenId, comparer: EqualityComparer<long>.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberMessageReachListItemDto ToMessageListItem(MemberReachMessage source)
|
||||||
|
{
|
||||||
|
var channels = MemberMessageReachMapping.DeserializeStringArray(source.ChannelsJson);
|
||||||
|
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(source.AudienceTagsJson);
|
||||||
|
return new MemberMessageReachListItemDto
|
||||||
|
{
|
||||||
|
MessageId = source.Id,
|
||||||
|
Title = source.Title,
|
||||||
|
Channels = channels,
|
||||||
|
AudienceText = BuildAudienceText(source.AudienceType, audienceTags, source.EstimatedReachCount),
|
||||||
|
EstimatedReachCount = source.EstimatedReachCount,
|
||||||
|
Status = MemberMessageReachMapping.ToStatusText(source.Status),
|
||||||
|
SentAt = source.SentAt,
|
||||||
|
ScheduledAt = source.ScheduledAt,
|
||||||
|
OpenRate = MemberMessageReachMapping.ResolveRatePercent(source.ReadCount, source.SentCount),
|
||||||
|
ConversionRate = MemberMessageReachMapping.ResolveRatePercent(source.ConvertedCount, source.SentCount)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemberMessageDispatchMetaDto ToDispatchMeta(MemberReachMessage source)
|
||||||
|
{
|
||||||
|
return new MemberMessageDispatchMetaDto
|
||||||
|
{
|
||||||
|
MessageId = source.Id,
|
||||||
|
Status = MemberMessageReachMapping.ToStatusText(source.Status),
|
||||||
|
ScheduleType = MemberMessageReachMapping.ToScheduleTypeText(source.ScheduleType),
|
||||||
|
ScheduledAt = source.ScheduledAt,
|
||||||
|
HangfireJobId = source.HangfireJobId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureTenantId(long tenantId)
|
||||||
|
{
|
||||||
|
if (tenantId <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "tenantId 非法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureMessageEditable(MemberReachMessage message)
|
||||||
|
{
|
||||||
|
if (message.Status == MemberMessageStatus.Sending)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "消息发送中,暂不允许编辑");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Status == MemberMessageStatus.Sent)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "已发送消息不允许编辑");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSubmitAction(string? submitAction)
|
||||||
|
{
|
||||||
|
var action = (submitAction ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return action switch
|
||||||
|
{
|
||||||
|
"draft" => "draft",
|
||||||
|
"send" => "send",
|
||||||
|
_ => throw new BusinessException(ErrorCodes.BadRequest, "submitAction 非法")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequiredText(string? value, int maxLength, string fieldName)
|
||||||
|
{
|
||||||
|
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 DateTime? NormalizeScheduledAt(
|
||||||
|
MemberMessageScheduleType scheduleType,
|
||||||
|
string submitAction,
|
||||||
|
DateTime? scheduledAt)
|
||||||
|
{
|
||||||
|
if (scheduleType == MemberMessageScheduleType.Immediate)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitAction == "draft")
|
||||||
|
{
|
||||||
|
return scheduledAt?.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduledAt.HasValue)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "定时发送必须设置 scheduledAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
var utcTime = scheduledAt.Value.ToUniversalTime();
|
||||||
|
if (utcTime <= DateTime.UtcNow.AddMinutes(1))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "定时发送时间必须晚于当前时间 1 分钟");
|
||||||
|
}
|
||||||
|
|
||||||
|
return utcTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesAudienceTag(
|
||||||
|
MemberProfile profile,
|
||||||
|
IReadOnlySet<string> profileTags,
|
||||||
|
string targetTag,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var hasProfileTag = profileTags.Contains(targetTag);
|
||||||
|
|
||||||
|
return targetTag switch
|
||||||
|
{
|
||||||
|
"newcustomer" => hasProfileTag || profile.JoinedAt >= nowUtc.AddDays(-30),
|
||||||
|
"dormant" => hasProfileTag || (profile.JoinedAt <= nowUtc.AddDays(-90) && profile.StoredBalance <= 0m && profile.PointsBalance <= 0),
|
||||||
|
"lost" => hasProfileTag || (profile.JoinedAt <= nowUtc.AddDays(-180) && profile.Status != MemberStatus.Active),
|
||||||
|
"highspend" => hasProfileTag || profile.StoredRechargeBalance >= 1000m || profile.StoredBalance >= 1000m,
|
||||||
|
_ => hasProfileTag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildAudienceText(
|
||||||
|
MemberMessageAudienceType audienceType,
|
||||||
|
IReadOnlyList<string> tags,
|
||||||
|
int estimatedReachCount)
|
||||||
|
{
|
||||||
|
if (audienceType == MemberMessageAudienceType.All)
|
||||||
|
{
|
||||||
|
return "全部会员";
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayTags = tags
|
||||||
|
.Select(ResolveAudienceDisplayName)
|
||||||
|
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (displayTags.Count == 0)
|
||||||
|
{
|
||||||
|
return $"标签人群({estimatedReachCount}人)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{string.Join("、", displayTags)}({estimatedReachCount}人)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveAudienceDisplayName(string sourceTag)
|
||||||
|
{
|
||||||
|
var canonical = ToCanonicalAudienceTag(sourceTag);
|
||||||
|
return AudienceTagDisplayMap.TryGetValue(canonical, out var displayName)
|
||||||
|
? displayName
|
||||||
|
: (sourceTag ?? string.Empty).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToCanonicalAudienceTag(string? sourceTag)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeAudienceTag(sourceTag);
|
||||||
|
return AudienceTagAliasMap.TryGetValue(normalized, out var canonical)
|
||||||
|
? canonical
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeAudienceTag(string? sourceTag)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceTag))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = sourceTag.Trim().ToLowerInvariant();
|
||||||
|
var filtered = trimmed
|
||||||
|
.Where(ch => ch is not (' ' or '-' or '_'))
|
||||||
|
.ToArray();
|
||||||
|
return new string(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> BuildAudienceTagAliasMap()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["highfrequency"] = "highfrequency",
|
||||||
|
["高频客户"] = "highfrequency",
|
||||||
|
["高频用户"] = "highfrequency",
|
||||||
|
["高频"] = "highfrequency",
|
||||||
|
|
||||||
|
["newcustomer"] = "newcustomer",
|
||||||
|
["新客"] = "newcustomer",
|
||||||
|
["新客户"] = "newcustomer",
|
||||||
|
|
||||||
|
["dormant"] = "dormant",
|
||||||
|
["沉睡客户"] = "dormant",
|
||||||
|
["沉睡用户"] = "dormant",
|
||||||
|
|
||||||
|
["lost"] = "lost",
|
||||||
|
["流失客户"] = "lost",
|
||||||
|
["流失用户"] = "lost",
|
||||||
|
|
||||||
|
["lunchregular"] = "lunchregular",
|
||||||
|
["午餐常客"] = "lunchregular",
|
||||||
|
|
||||||
|
["highspend"] = "highspend",
|
||||||
|
["大额消费"] = "highspend",
|
||||||
|
["高消费"] = "highspend"
|
||||||
|
};
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildErrorSummary(IReadOnlyList<string> errors)
|
||||||
|
{
|
||||||
|
if (errors.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = string.Join(" | ", errors.Take(5));
|
||||||
|
return Truncate(content, 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CleanupErrorMessage(string? message)
|
||||||
|
{
|
||||||
|
return (message ?? string.Empty)
|
||||||
|
.Replace('\r', ' ')
|
||||||
|
.Replace('\n', ' ')
|
||||||
|
.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizePhoneNumber(string? mobile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(mobile))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = mobile.Trim();
|
||||||
|
return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim();
|
||||||
|
if (normalized.Length <= maxLength)
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized[..maxLength];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微信小程序订阅消息发送器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemberMessageWeChatSender(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IDistributedCache cache,
|
||||||
|
IOptionsMonitor<MemberMessagingOptions> optionsMonitor)
|
||||||
|
: IMemberMessageWeChatSender
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SendAsync(
|
||||||
|
string openId,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(openId))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "openId 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = optionsMonitor.CurrentValue.WeChatMini;
|
||||||
|
var accessToken = await ResolveAccessTokenAsync(options, cancellationToken);
|
||||||
|
|
||||||
|
var requestBody = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["touser"] = openId.Trim(),
|
||||||
|
["template_id"] = options.SubscribeTemplateId,
|
||||||
|
["page"] = options.PagePath,
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
[options.TitleDataKey] = new { value = Truncate(title, 20) },
|
||||||
|
[options.ContentDataKey] = new { value = Truncate(content, 20) }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await httpClient.PostAsJsonAsync(
|
||||||
|
$"cgi-bin/message/subscribe/send?access_token={Uri.EscapeDataString(accessToken)}",
|
||||||
|
requestBody,
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<WeChatErrorPayload>(JsonOptions, cancellationToken);
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.InternalServerError, "微信发送失败:响应为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.ErrorCode != 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(
|
||||||
|
ErrorCodes.InternalServerError,
|
||||||
|
$"微信发送失败:{payload.ErrorCode} {payload.ErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveAccessTokenAsync(
|
||||||
|
MemberMessagingWeChatMiniOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cacheKey = $"member-message:wechat:access-token:{options.AppId}";
|
||||||
|
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await httpClient.GetAsync(
|
||||||
|
$"cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(options.AppId)}&secret={Uri.EscapeDataString(options.AppSecret)}",
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<WeChatTokenPayload>(JsonOptions, cancellationToken);
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.InternalServerError, "微信 access_token 获取失败:响应为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.ErrorCode != 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(
|
||||||
|
ErrorCodes.InternalServerError,
|
||||||
|
$"微信 access_token 获取失败:{payload.ErrorCode} {payload.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(payload.AccessToken))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.InternalServerError, "微信 access_token 获取失败:token 为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ttlSeconds = payload.ExpiresIn > 120 ? payload.ExpiresIn - 120 : payload.ExpiresIn;
|
||||||
|
await cache.SetStringAsync(
|
||||||
|
cacheKey,
|
||||||
|
payload.AccessToken,
|
||||||
|
new DistributedCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Math.Max(60, ttlSeconds))
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return payload.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim();
|
||||||
|
if (normalized.Length <= maxLength)
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized[..maxLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WeChatTokenPayload
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errcode")]
|
||||||
|
public int ErrorCode { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errmsg")]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WeChatErrorPayload
|
||||||
|
{
|
||||||
|
[JsonPropertyName("errcode")]
|
||||||
|
public int ErrorCode { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errmsg")]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 修改积分商城商品状态命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChangePointMallProductStatusCommand : IRequest<MemberPointMallProductDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品标识。
|
||||||
|
/// </summary>
|
||||||
|
public long PointMallProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(enabled/disabled)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = "disabled";
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除积分商城商品命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeletePointMallProductCommand : IRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品标识。
|
||||||
|
/// </summary>
|
||||||
|
public long PointMallProductId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存积分商城兑换商品命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SavePointMallProductCommand : IRequest<MemberPointMallProductDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店标识。
|
||||||
|
/// </summary>
|
||||||
|
public long StoreId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 积分商城商品标识(编辑时传)。
|
||||||
|
/// </summary>
|
||||||
|
public long? PointMallProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示图片。
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换类型(product/coupon/physical)。
|
||||||
|
/// </summary>
|
||||||
|
public string RedeemType { get; init; } = "product";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long? ProductId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关联优惠券模板 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long? CouponTemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实物名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? PhysicalName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 领取方式(store_pickup/delivery)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PickupMethod { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品描述。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兑换方式(points/mixed)。
|
||||||
|
/// </summary>
|
||||||
|
public string ExchangeType { get; init; } = "points";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所需积分。
|
||||||
|
/// </summary>
|
||||||
|
public int RequiredPoints { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 现金部分。
|
||||||
|
/// </summary>
|
||||||
|
public decimal CashAmount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 库存总量。
|
||||||
|
/// </summary>
|
||||||
|
public int StockTotal { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每人限兑次数(null 表示不限)。
|
||||||
|
/// </summary>
|
||||||
|
public int? PerMemberLimit { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到账通知渠道。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(enabled/disabled)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = "enabled";
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user